refactor(posts): move post-processor projects under Posts/
Group the Cincinnati and GravographIS plugin projects in a Posts/ folder so new machine posts have one home. Project names, namespaces, and the runtime Posts/ deploy target are unchanged; only relative paths in the solution and project references move.
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Data class carrying all context needed to emit one Cincinnati-format G-code feature block.
|
||||
/// </summary>
|
||||
public sealed class FeatureContext
|
||||
{
|
||||
public List<ICode> Codes { get; set; } = new();
|
||||
public int FeatureNumber { get; set; }
|
||||
public string PartName { get; set; } = "";
|
||||
public bool IsFirstFeatureOfPart { get; set; }
|
||||
public bool IsLastFeatureOnSheet { get; set; }
|
||||
public bool IsSafetyHeadraise { get; set; }
|
||||
public bool IsExteriorFeature { get; set; }
|
||||
public bool IsEtch { get; set; }
|
||||
public string LibraryFile { get; set; } = "";
|
||||
public double CutDistance { get; set; }
|
||||
public double SheetDiagonal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Part location on the plate. Added to all output X/Y coordinates
|
||||
/// so part-relative programs become plate-absolute under G90.
|
||||
/// </summary>
|
||||
public Vector PartLocation { get; set; } = Vector.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Maps (drawingId, variableName) to assigned machine variable numbers.
|
||||
/// Used to emit #number references instead of literal values for user variables.
|
||||
/// </summary>
|
||||
public Dictionary<(int drawingId, string varName), int> UserVariableMapping { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The drawing ID for the current part, used to look up user variable mappings.
|
||||
/// </summary>
|
||||
public int DrawingId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this feature is a cut-off line. Used to substitute plate-edge
|
||||
/// coordinates with sheet width/length variables.
|
||||
/// </summary>
|
||||
public bool IsCutOff { get; set; }
|
||||
|
||||
/// <summary>Plate width (Y extent for vertical cutoffs).</summary>
|
||||
public double PlateWidth { get; set; }
|
||||
|
||||
/// <summary>Plate length (X extent for horizontal cutoffs).</summary>
|
||||
public double PlateLength { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits one Cincinnati-format G-code feature block (one contour) to a TextWriter.
|
||||
/// Handles rapid positioning, pierce, kerf compensation, anti-dive, feedrate modal
|
||||
/// suppression, arc I/J conversion (absolute to incremental), and M47 head raise.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiFeatureWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
private readonly CoordinateFormatter _fmt;
|
||||
private readonly SpeedClassifier _speedClassifier;
|
||||
|
||||
public CincinnatiFeatureWriter(CincinnatiPostConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||
_speedClassifier = new SpeedClassifier();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a complete feature block for the given context.
|
||||
/// </summary>
|
||||
public void Write(TextWriter writer, FeatureContext ctx)
|
||||
{
|
||||
var currentPos = Vector.Zero;
|
||||
var lastFeedVar = "";
|
||||
var kerfEmitted = false;
|
||||
var offset = ctx.PartLocation;
|
||||
|
||||
// Find the pierce point from the first rapid move
|
||||
var piercePoint = FindPiercePoint(ctx.Codes);
|
||||
|
||||
// 1. Rapid to pierce point (with line number if configured)
|
||||
WriteRapidToPierce(writer, ctx, piercePoint, offset);
|
||||
|
||||
// 2. Part name comment on first feature of each part
|
||||
if (ctx.IsFirstFeatureOfPart && !string.IsNullOrEmpty(ctx.PartName))
|
||||
writer.WriteLine(CoordinateFormatter.Comment($"PART: {ctx.PartName}"));
|
||||
|
||||
// 3. G89 process params
|
||||
if (_config.ProcessParameterMode == G89Mode.LibraryFile)
|
||||
{
|
||||
var lib = ctx.LibraryFile;
|
||||
if (!string.IsNullOrEmpty(lib))
|
||||
{
|
||||
var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal);
|
||||
var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
|
||||
writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})");
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteLine("(WARNING: No library found)");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Pierce/beam on — G85 for etch (no pierce), G84 for cut
|
||||
writer.WriteLine(ctx.IsEtch ? "G85" : "G84");
|
||||
|
||||
// 5. Anti-dive off
|
||||
if (_config.UseAntiDive)
|
||||
writer.WriteLine("M130 (ANTI DIVE OFF)");
|
||||
|
||||
// Update current position to pierce point
|
||||
currentPos = piercePoint;
|
||||
|
||||
// 6. Lead-in + contour moves with kerf comp and feedrate variables
|
||||
foreach (var code in ctx.Codes)
|
||||
{
|
||||
if (code is RapidMove)
|
||||
continue; // skip rapids in contour (already handled above)
|
||||
|
||||
if (code is LinearMove linear)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Kerf compensation on first cutting move (skip for etch)
|
||||
if (
|
||||
!ctx.IsEtch
|
||||
&& !kerfEmitted
|
||||
&& _config.KerfCompensation == KerfMode.ControllerSide
|
||||
)
|
||||
{
|
||||
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
|
||||
kerfEmitted = true;
|
||||
}
|
||||
|
||||
var xCoord = FormatCoordWithVars(
|
||||
linear.EndPoint.X + offset.X,
|
||||
"X",
|
||||
linear.VariableRefs,
|
||||
ctx
|
||||
);
|
||||
var yCoord = FormatCoordWithVars(
|
||||
linear.EndPoint.Y + offset.Y,
|
||||
"Y",
|
||||
linear.VariableRefs,
|
||||
ctx
|
||||
);
|
||||
sb.Append($"G1 X{xCoord} Y{yCoord}");
|
||||
|
||||
// Feedrate — etch always uses process feedrate
|
||||
var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer);
|
||||
if (feedVar != lastFeedVar)
|
||||
{
|
||||
sb.Append($" F{feedVar}");
|
||||
lastFeedVar = feedVar;
|
||||
}
|
||||
|
||||
writer.WriteLine(sb.ToString());
|
||||
currentPos = linear.EndPoint;
|
||||
}
|
||||
else if (code is ArcMove arc)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Kerf compensation on first cutting move (skip for etch)
|
||||
if (
|
||||
!ctx.IsEtch
|
||||
&& !kerfEmitted
|
||||
&& _config.KerfCompensation == KerfMode.ControllerSide
|
||||
)
|
||||
{
|
||||
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
|
||||
kerfEmitted = true;
|
||||
}
|
||||
|
||||
// G2 = CW, G3 = CCW
|
||||
var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3";
|
||||
var xCoord = FormatCoordWithVars(
|
||||
arc.EndPoint.X + offset.X,
|
||||
"X",
|
||||
arc.VariableRefs,
|
||||
ctx
|
||||
);
|
||||
var yCoord = FormatCoordWithVars(
|
||||
arc.EndPoint.Y + offset.Y,
|
||||
"Y",
|
||||
arc.VariableRefs,
|
||||
ctx
|
||||
);
|
||||
sb.Append($"{gCode} X{xCoord} Y{yCoord}");
|
||||
|
||||
// Convert absolute center to incremental I/J
|
||||
var i = arc.CenterPoint.X - currentPos.X;
|
||||
var j = arc.CenterPoint.Y - currentPos.Y;
|
||||
sb.Append($" I{_fmt.FormatCoord(i)} J{_fmt.FormatCoord(j)}");
|
||||
|
||||
// Feedrate — etch always uses process feedrate, cut uses layer/radius-based
|
||||
var radius = currentPos.DistanceTo(arc.CenterPoint);
|
||||
var isFullCircle = IsFullCircle(currentPos, arc.EndPoint);
|
||||
var feedVar = ctx.IsEtch ? "#148" : GetArcFeedrate(arc.Layer, radius, isFullCircle);
|
||||
if (feedVar != lastFeedVar)
|
||||
{
|
||||
sb.Append($" F{feedVar}");
|
||||
lastFeedVar = feedVar;
|
||||
}
|
||||
|
||||
writer.WriteLine(sb.ToString());
|
||||
currentPos = arc.EndPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Cancel kerf compensation
|
||||
if (kerfEmitted)
|
||||
writer.WriteLine("G40");
|
||||
|
||||
// 8. Beam off
|
||||
writer.WriteLine(_config.UseSpeedGas ? "M135" : "M35");
|
||||
|
||||
// 9. Anti-dive on
|
||||
if (_config.UseAntiDive)
|
||||
writer.WriteLine("M131 (ANTI DIVE ON)");
|
||||
|
||||
// 10. Head raise (unless last feature on sheet)
|
||||
if (!ctx.IsLastFeatureOnSheet)
|
||||
WriteM47(writer, ctx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a coordinate value, using a #number variable reference if the motion
|
||||
/// has a VariableRef for this axis and the variable is mapped (non-inline).
|
||||
/// For cut-off features, plate-edge coordinates are substituted with
|
||||
/// the sheet width/length variables.
|
||||
/// Inline variables fall through to literal formatting.
|
||||
/// </summary>
|
||||
private string FormatCoordWithVars(
|
||||
double value,
|
||||
string axis,
|
||||
Dictionary<string, string> variableRefs,
|
||||
FeatureContext ctx
|
||||
)
|
||||
{
|
||||
// User-defined variable references take priority
|
||||
if (
|
||||
variableRefs != null
|
||||
&& variableRefs.TryGetValue(axis, out var varName)
|
||||
&& ctx.UserVariableMapping != null
|
||||
&& ctx.UserVariableMapping.TryGetValue((ctx.DrawingId, varName), out var varNum)
|
||||
)
|
||||
{
|
||||
return $"#{varNum}";
|
||||
}
|
||||
|
||||
// Cut-off plate-edge substitution
|
||||
if (ctx.IsCutOff)
|
||||
{
|
||||
var sheetVar = MatchCutOffSheetVariable(value, axis, ctx);
|
||||
if (sheetVar != null)
|
||||
return sheetVar;
|
||||
}
|
||||
|
||||
return _fmt.FormatCoord(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For cut-off coordinates, checks if the value matches a plate edge dimension
|
||||
/// and returns the sheet variable reference (e.g., "#110") if so.
|
||||
/// </summary>
|
||||
private string MatchCutOffSheetVariable(double value, string axis, FeatureContext ctx)
|
||||
{
|
||||
// Vertical cutoffs travel along Y — the Y endpoint at the plate edge = sheet width
|
||||
// Horizontal cutoffs travel along X — the X endpoint at the plate edge = sheet length
|
||||
if (axis == "Y" && Tolerance.IsEqualTo(value, ctx.PlateWidth))
|
||||
return $"#{_config.SheetWidthVariable}";
|
||||
if (axis == "X" && Tolerance.IsEqualTo(value, ctx.PlateLength))
|
||||
return $"#{_config.SheetLengthVariable}";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Vector FindPiercePoint(List<ICode> codes)
|
||||
{
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove rapid)
|
||||
return rapid.EndPoint;
|
||||
}
|
||||
|
||||
// If no rapid move, use the endpoint of the first motion
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is Motion motion)
|
||||
return motion.EndPoint;
|
||||
}
|
||||
|
||||
return Vector.Zero;
|
||||
}
|
||||
|
||||
private void WriteRapidToPierce(
|
||||
TextWriter writer,
|
||||
FeatureContext ctx,
|
||||
Vector piercePoint,
|
||||
Vector offset
|
||||
)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (_config.UseLineNumbers)
|
||||
sb.Append($"N{ctx.FeatureNumber} ");
|
||||
|
||||
var xCoord = FormatCoordWithVars(piercePoint.X + offset.X, "X", null, ctx);
|
||||
var yCoord = FormatCoordWithVars(piercePoint.Y + offset.Y, "Y", null, ctx);
|
||||
sb.Append($"G0 X{xCoord} Y{yCoord}");
|
||||
|
||||
writer.WriteLine(sb.ToString());
|
||||
}
|
||||
|
||||
private void WriteM47(TextWriter writer, FeatureContext ctx)
|
||||
{
|
||||
if (ctx.IsSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
|
||||
{
|
||||
writer.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
|
||||
return;
|
||||
}
|
||||
|
||||
var mode = ctx.IsExteriorFeature ? _config.ExteriorM47 : _config.InteriorM47;
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case M47Mode.Always:
|
||||
writer.WriteLine("M47");
|
||||
break;
|
||||
case M47Mode.BlockDelete:
|
||||
writer.WriteLine("/M47");
|
||||
break;
|
||||
case M47Mode.None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetLinearFeedVariable(LayerType layer)
|
||||
{
|
||||
return layer switch
|
||||
{
|
||||
LayerType.Leadin => "#126",
|
||||
LayerType.Leadout => "#129",
|
||||
_ => "#148",
|
||||
};
|
||||
}
|
||||
|
||||
private string GetArcFeedrate(LayerType layer, double radius, bool isFullCircle)
|
||||
{
|
||||
if (layer == LayerType.Leadin)
|
||||
return "#127";
|
||||
if (layer == LayerType.Leadout)
|
||||
return "#129";
|
||||
if (isFullCircle)
|
||||
return "[#148*#128]";
|
||||
return GetArcCutFeedrate(radius);
|
||||
}
|
||||
|
||||
private string GetArcCutFeedrate(double radius)
|
||||
{
|
||||
if (_config.ArcFeedrate == ArcFeedrateMode.None)
|
||||
return "#148";
|
||||
|
||||
// Find the smallest range that contains this radius
|
||||
ArcFeedrateRange best = null;
|
||||
foreach (var range in _config.ArcFeedrateRanges)
|
||||
{
|
||||
if (radius <= range.MaxRadius && (best == null || range.MaxRadius < best.MaxRadius))
|
||||
best = range;
|
||||
}
|
||||
|
||||
if (best == null)
|
||||
return "#148";
|
||||
|
||||
return _config.ArcFeedrate == ArcFeedrateMode.Variables
|
||||
? $"#{best.VariableNumber}"
|
||||
: $"[#148*{best.FeedratePercent.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}]";
|
||||
}
|
||||
|
||||
private static bool IsFullCircle(Vector start, Vector end)
|
||||
{
|
||||
return Tolerance.IsEqualTo(start.X, end.X) && Tolerance.IsEqualTo(start.Y, end.Y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a Cincinnati-format part sub-program definition.
|
||||
/// Each sub-program contains the complete cutting sequence for one unique part geometry
|
||||
/// (drawing + rotation), with coordinates normalized to origin (0,0).
|
||||
/// Called via M98 from sheet sub-programs.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiPartSubprogramWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
private readonly CincinnatiFeatureWriter _featureWriter;
|
||||
private readonly CoordinateFormatter _fmt;
|
||||
private readonly Dictionary<int, int> _holeSubprograms;
|
||||
|
||||
public CincinnatiPartSubprogramWriter(
|
||||
CincinnatiPostConfig config,
|
||||
Dictionary<int, int> holeSubprograms = null
|
||||
)
|
||||
{
|
||||
_config = config;
|
||||
_featureWriter = new CincinnatiFeatureWriter(config);
|
||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||
_holeSubprograms = holeSubprograms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a complete part sub-program for the given normalized program.
|
||||
/// The program coordinates must already be normalized to origin (0,0).
|
||||
/// </summary>
|
||||
public void Write(
|
||||
TextWriter w,
|
||||
Program normalizedProgram,
|
||||
string drawingName,
|
||||
int subNumber,
|
||||
string cutLibrary,
|
||||
string etchLibrary,
|
||||
double sheetDiagonal
|
||||
)
|
||||
{
|
||||
var allFeatures = FeatureUtils.SplitByRapids(normalizedProgram.Codes);
|
||||
if (allFeatures.Count == 0)
|
||||
return;
|
||||
|
||||
// Classify and order: etch features first, then cut features
|
||||
var ordered = FeatureUtils.ClassifyAndOrder(allFeatures);
|
||||
|
||||
w.WriteLine("(*****************************************************)");
|
||||
w.WriteLine($":{subNumber}");
|
||||
w.WriteLine(CoordinateFormatter.Comment($"PART: {drawingName}"));
|
||||
|
||||
for (var i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var (codes, isEtch) = ordered[i];
|
||||
var isLastFeature = i == ordered.Count - 1;
|
||||
|
||||
// SubProgramCall features are emitted as M98 hole calls
|
||||
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
|
||||
{
|
||||
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
|
||||
continue;
|
||||
}
|
||||
|
||||
var featureNumber = i == 0 ? _config.FeatureLineNumberStart : 1000 + i + 1;
|
||||
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
Codes = codes,
|
||||
FeatureNumber = featureNumber,
|
||||
PartName = drawingName,
|
||||
IsFirstFeatureOfPart = false,
|
||||
IsLastFeatureOnSheet = isLastFeature,
|
||||
IsSafetyHeadraise = false,
|
||||
IsExteriorFeature = false,
|
||||
IsEtch = isEtch,
|
||||
LibraryFile = isEtch ? etchLibrary : cutLibrary,
|
||||
CutDistance = cutDistance,
|
||||
SheetDiagonal = sheetDiagonal,
|
||||
};
|
||||
|
||||
_featureWriter.Write(w, ctx);
|
||||
}
|
||||
|
||||
w.WriteLine($"M99 (END OF {drawingName})");
|
||||
}
|
||||
|
||||
private void WriteHoleSubprogramCall(
|
||||
TextWriter w,
|
||||
SubProgramCall call,
|
||||
int featureIndex,
|
||||
bool isLastFeature
|
||||
)
|
||||
{
|
||||
var postSubNum =
|
||||
_holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
|
||||
? num
|
||||
: call.Id;
|
||||
|
||||
var featureNumber =
|
||||
featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (_config.UseLineNumbers)
|
||||
sb.Append($"N{featureNumber} ");
|
||||
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
|
||||
w.WriteLine(sb.ToString());
|
||||
|
||||
w.WriteLine($"M98 P{postSubNum}");
|
||||
|
||||
w.WriteLine("G52 X0 Y0");
|
||||
|
||||
if (!isLastFeature)
|
||||
w.WriteLine("M47");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the program has no leading rapid, inserts a synthetic rapid at the
|
||||
/// last motion endpoint (the contour return point). This ensures the feature
|
||||
/// writer knows the true pierce location and preserves the first contour segment.
|
||||
/// </summary>
|
||||
internal static void EnsureLeadingRapid(Program pgm)
|
||||
{
|
||||
if (pgm.Codes.Count == 0 || pgm.Codes[0] is RapidMove)
|
||||
return;
|
||||
|
||||
for (var i = pgm.Codes.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (pgm.Codes[i] is Motion lastMotion)
|
||||
{
|
||||
pgm.Codes.Insert(0, new RapidMove(lastMotion.EndPoint));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sub-program key for matching parts to their sub-programs.
|
||||
/// </summary>
|
||||
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
|
||||
(part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
|
||||
|
||||
/// <summary>
|
||||
/// Scans all plates and builds a mapping of unique part geometries to sub-program numbers,
|
||||
/// along with their normalized programs for writing.
|
||||
/// </summary>
|
||||
internal static (
|
||||
Dictionary<(int, long), int> mapping,
|
||||
List<(int subNum, string name, Program program)> entries
|
||||
) BuildRegistry(IEnumerable<Plate> plates, int startNumber)
|
||||
{
|
||||
var mapping = new Dictionary<(int, long), int>();
|
||||
var entries = new List<(int, string, Program)>();
|
||||
var nextSubNum = startNumber;
|
||||
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
var key = SubprogramKey(part);
|
||||
if (!mapping.ContainsKey(key))
|
||||
{
|
||||
var subNum = nextSubNum++;
|
||||
mapping[key] = subNum;
|
||||
|
||||
var pgm = part.Program.Clone() as Program;
|
||||
pgm.Mode = Mode.Absolute;
|
||||
var bbox = pgm.BoundingBox();
|
||||
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
|
||||
|
||||
// If the program has no leading rapid, the feature writer
|
||||
// will use the first motion endpoint as the pierce point,
|
||||
// losing the first contour segment. Insert a synthetic rapid
|
||||
// at the contour's return point (last motion endpoint) so
|
||||
// the full contour is preserved.
|
||||
EnsureLeadingRapid(pgm);
|
||||
|
||||
entries.Add((subNum, part.BaseDrawing.Name, pgm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (mapping, entries);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans all parts across all plates and builds a nest-level registry of unique
|
||||
/// hole sub-programs. Deduplicates by comparing sub-program code content.
|
||||
/// </summary>
|
||||
internal static (
|
||||
Dictionary<int, int> modelToPostMapping,
|
||||
List<(int subNum, Program program)> entries
|
||||
) BuildHoleRegistry(IEnumerable<Plate> plates, int startNumber)
|
||||
{
|
||||
var mapping = new Dictionary<int, int>();
|
||||
var entries = new List<(int, Program)>();
|
||||
var contentIndex = new Dictionary<string, int>();
|
||||
var nextSubNum = startNumber;
|
||||
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
foreach (var code in part.Program.Codes)
|
||||
{
|
||||
if (code is not SubProgramCall call)
|
||||
continue;
|
||||
if (mapping.ContainsKey(call.Id))
|
||||
continue;
|
||||
|
||||
var canonical = ProgramToCanonical(call.Program);
|
||||
if (contentIndex.TryGetValue(canonical, out var existingNum))
|
||||
{
|
||||
mapping[call.Id] = existingNum;
|
||||
}
|
||||
else
|
||||
{
|
||||
var subNum = nextSubNum++;
|
||||
mapping[call.Id] = subNum;
|
||||
contentIndex[canonical] = subNum;
|
||||
entries.Add((subNum, call.Program));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (mapping, entries);
|
||||
}
|
||||
|
||||
private static string ProgramToCanonical(Program pgm)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(pgm.Mode == Mode.Absolute ? "A" : "I");
|
||||
foreach (var code in pgm.Codes)
|
||||
{
|
||||
if (code is LinearMove lm)
|
||||
sb.Append($"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}");
|
||||
else if (code is ArcMove am)
|
||||
sb.Append(
|
||||
$"A{am.EndPoint.X:F6},{am.EndPoint.Y:F6},{am.CenterPoint.X:F6},{am.CenterPoint.Y:F6},{(int)am.Rotation},{(int)am.Layer}"
|
||||
);
|
||||
else if (code is RapidMove rm)
|
||||
sb.Append($"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
/// <summary>
|
||||
/// Specifies how coordinate positioning is handled between parts.
|
||||
/// </summary>
|
||||
public enum CoordinateMode
|
||||
{
|
||||
/// <summary>Set absolute position.</summary>
|
||||
G92,
|
||||
|
||||
/// <summary>Use relative/incremental positioning.</summary>
|
||||
G91,
|
||||
|
||||
/// <summary>Use machine coordinate system.</summary>
|
||||
G53,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how G89 (hole drilling/tapping parameters) are provided.
|
||||
/// </summary>
|
||||
public enum G89Mode
|
||||
{
|
||||
/// <summary>Use external library file for G89 parameters.</summary>
|
||||
LibraryFile,
|
||||
|
||||
/// <summary>Explicitly define G89 parameters in the program.</summary>
|
||||
Explicit,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies where kerf compensation is applied.
|
||||
/// </summary>
|
||||
public enum KerfMode
|
||||
{
|
||||
/// <summary>Controller side (using cutter compensation codes).</summary>
|
||||
ControllerSide,
|
||||
|
||||
/// <summary>Pre-applied to part geometry during post-processing.</summary>
|
||||
PreApplied,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies which side of the cut line kerf compensation is applied to.
|
||||
/// </summary>
|
||||
public enum KerfSide
|
||||
{
|
||||
/// <summary>Kerf applied to the left side of the cut.</summary>
|
||||
Left,
|
||||
|
||||
/// <summary>Kerf applied to the right side of the cut.</summary>
|
||||
Right,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how M47 (optional stop) commands are used.
|
||||
/// </summary>
|
||||
public enum M47Mode
|
||||
{
|
||||
/// <summary>Always include M47.</summary>
|
||||
Always,
|
||||
|
||||
/// <summary>Include M47 with block delete functionality.</summary>
|
||||
BlockDelete,
|
||||
|
||||
/// <summary>Automatically determine M47 placement.</summary>
|
||||
Auto,
|
||||
|
||||
/// <summary>Do not use M47.</summary>
|
||||
None,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies when pallet exchange occurs.
|
||||
/// </summary>
|
||||
public enum PalletMode
|
||||
{
|
||||
/// <summary>No pallet exchange.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Pallet exchange at end of sheet.</summary>
|
||||
EndOfSheet,
|
||||
|
||||
/// <summary>Pallet exchange at start and end of sheet.</summary>
|
||||
StartAndEnd,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Cincinnati post processor.
|
||||
/// Defines machine-specific parameters, output format, and cutting strategies.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiPostConfig
|
||||
{
|
||||
[Category("1. Output")]
|
||||
[DisplayName("Configuration Name")]
|
||||
[Description("Configuration name/identifier (e.g. CL940).")]
|
||||
public string ConfigurationName { get; set; } = "CL940";
|
||||
|
||||
[Category("1. Output")]
|
||||
[DisplayName("Posted Units")]
|
||||
[Description("Units for posted output (Inches or Millimeters).")]
|
||||
public Units PostedUnits { get; set; } = Units.Inches;
|
||||
|
||||
[Category("1. Output")]
|
||||
[DisplayName("Decimal Accuracy")]
|
||||
[Description("Number of decimal places for numeric output.")]
|
||||
public int PostedAccuracy { get; set; } = 4;
|
||||
|
||||
[Category("1. Output")]
|
||||
[DisplayName("Use Line Numbers")]
|
||||
[Description("Include line numbers in output.")]
|
||||
public bool UseLineNumbers { get; set; } = true;
|
||||
|
||||
[Category("1. Output")]
|
||||
[DisplayName("Feature Line Number Start")]
|
||||
[Description("Starting line number for features.")]
|
||||
public int FeatureLineNumberStart { get; set; } = 1;
|
||||
|
||||
[Category("2. Subprograms")]
|
||||
[DisplayName("Use Sheet Subprograms")]
|
||||
[Description("Use subprograms for sheet operations.")]
|
||||
public bool UseSheetSubprograms { get; set; } = true;
|
||||
|
||||
[Category("2. Subprograms")]
|
||||
[DisplayName("Sheet Subprogram Start")]
|
||||
[Description("Starting subprogram number for sheet operations.")]
|
||||
public int SheetSubprogramStart { get; set; } = 101;
|
||||
|
||||
[Category("2. Subprograms")]
|
||||
[DisplayName("Use Part Subprograms")]
|
||||
[Description(
|
||||
"Use M98 sub-programs for part geometry. Reduces output size for repeated parts."
|
||||
)]
|
||||
public bool UsePartSubprograms { get; set; } = false;
|
||||
|
||||
[Category("2. Subprograms")]
|
||||
[DisplayName("Part Subprogram Start")]
|
||||
[Description("Starting sub-program number for part geometry sub-programs.")]
|
||||
public int PartSubprogramStart { get; set; } = 200;
|
||||
|
||||
[Category("2. Subprograms")]
|
||||
[DisplayName("Variable Declaration Subprogram")]
|
||||
[Description("Subprogram number for variable declarations.")]
|
||||
public int VariableDeclarationSubprogram { get; set; } = 100;
|
||||
|
||||
[Category("3. Positioning")]
|
||||
[DisplayName("Coordinate Mode Between Parts")]
|
||||
[Description("How coordinate positioning is handled between parts (G92, G91, or G53).")]
|
||||
public CoordinateMode CoordModeBetweenParts { get; set; } = CoordinateMode.G92;
|
||||
|
||||
[Category("4. Process")]
|
||||
[DisplayName("Process Parameter Mode")]
|
||||
[Description("How G89 parameters are provided (LibraryFile or Explicit).")]
|
||||
public G89Mode ProcessParameterMode { get; set; } = G89Mode.LibraryFile;
|
||||
|
||||
[Category("4. Process")]
|
||||
[DisplayName("Default Assist Gas")]
|
||||
[Description("Default assist gas when Nest.AssistGas is empty.")]
|
||||
public string DefaultAssistGas { get; set; } = "O2";
|
||||
|
||||
[Category("4. Process")]
|
||||
[DisplayName("Default Etch Gas")]
|
||||
[Description("Gas used for etch operations.")]
|
||||
public string DefaultEtchGas { get; set; } = "N2";
|
||||
|
||||
[Category("4. Process")]
|
||||
[DisplayName("Use Exact Stop Mode")]
|
||||
[Description("Enable exact stop mode (G61).")]
|
||||
public bool UseExactStopMode { get; set; } = false;
|
||||
|
||||
[Category("4. Process")]
|
||||
[DisplayName("Use Speed/Gas Commands")]
|
||||
[Description("Enable speed/gas commands in output.")]
|
||||
public bool UseSpeedGas { get; set; } = false;
|
||||
|
||||
[Category("4. Process")]
|
||||
[DisplayName("Use Anti-Dive")]
|
||||
[Description("Enable anti-dive functionality.")]
|
||||
public bool UseAntiDive { get; set; } = true;
|
||||
|
||||
[Category("4. Process")]
|
||||
[DisplayName("Use Smart Rapids")]
|
||||
[Description("Enable smart rapids optimization.")]
|
||||
public bool UseSmartRapids { get; set; } = false;
|
||||
|
||||
[Category("5. Kerf")]
|
||||
[DisplayName("Kerf Compensation")]
|
||||
[Description("Where kerf compensation is applied (ControllerSide or PreApplied).")]
|
||||
public KerfMode KerfCompensation { get; set; } = KerfMode.ControllerSide;
|
||||
|
||||
[Category("5. Kerf")]
|
||||
[DisplayName("Default Kerf Side")]
|
||||
[Description("Default side for kerf compensation (Left or Right).")]
|
||||
public KerfSide DefaultKerfSide { get; set; } = KerfSide.Left;
|
||||
|
||||
[Category("6. M47 (Optional Stop)")]
|
||||
[DisplayName("Interior M47")]
|
||||
[Description("How M47 is used in interior cuts.")]
|
||||
public M47Mode InteriorM47 { get; set; } = M47Mode.Always;
|
||||
|
||||
[Category("6. M47 (Optional Stop)")]
|
||||
[DisplayName("Exterior M47")]
|
||||
[Description("How M47 is used in exterior cuts.")]
|
||||
public M47Mode ExteriorM47 { get; set; } = M47Mode.Always;
|
||||
|
||||
[Category("6. M47 (Optional Stop)")]
|
||||
[DisplayName("M47 Override Distance Threshold")]
|
||||
[Description("Distance threshold for M47 override. Null = no override.")]
|
||||
public double? M47OverrideDistanceThreshold { get; set; } = null;
|
||||
|
||||
[Category("7. Safety")]
|
||||
[DisplayName("Safety Headraise Distance")]
|
||||
[Description("Safety head raise distance in machine units. Null = disabled.")]
|
||||
public int? SafetyHeadraiseDistance { get; set; } = 2000;
|
||||
|
||||
[Category("8. Pallet")]
|
||||
[DisplayName("Pallet Exchange")]
|
||||
[Description("When pallet exchange occurs (None, EndOfSheet, or StartAndEnd).")]
|
||||
public PalletMode PalletExchange { get; set; } = PalletMode.EndOfSheet;
|
||||
|
||||
[Category("9. Feedrates")]
|
||||
[DisplayName("Lead-In Feedrate %")]
|
||||
[Description("Feedrate percentage for lead-in moves (e.g. 0.5 = 50%).")]
|
||||
public double LeadInFeedratePercent { get; set; } = 0.5;
|
||||
|
||||
[Category("9. Feedrates")]
|
||||
[DisplayName("Lead-In Arc Line 2 Feedrate %")]
|
||||
[Description("Feedrate percentage for lead-in arc-to-line moves.")]
|
||||
public double LeadInArcLine2FeedratePercent { get; set; } = 0.5;
|
||||
|
||||
[Category("9. Feedrates")]
|
||||
[DisplayName("Lead-Out Feedrate %")]
|
||||
[Description("Feedrate percentage for lead-out moves.")]
|
||||
public double LeadOutFeedratePercent { get; set; } = 0.5;
|
||||
|
||||
[Category("9. Feedrates")]
|
||||
[DisplayName("Circle Feedrate Multiplier")]
|
||||
[Description("Feedrate multiplier for circular cuts (e.g. 0.8 = 80%).")]
|
||||
public double CircleFeedrateMultiplier { get; set; } = 0.8;
|
||||
|
||||
[Category("9. Feedrates")]
|
||||
[DisplayName("Arc Feedrate Mode")]
|
||||
[Description("Arc feedrate calculation mode (None, Percentages, or Variables).")]
|
||||
public ArcFeedrateMode ArcFeedrate { get; set; } = ArcFeedrateMode.None;
|
||||
|
||||
[Category("9. Feedrates")]
|
||||
[DisplayName("Arc Feedrate Ranges")]
|
||||
[Description(
|
||||
"Radius-based arc feedrate ranges. Matched from smallest to largest MaxRadius."
|
||||
)]
|
||||
public List<ArcFeedrateRange> ArcFeedrateRanges { get; set; } =
|
||||
new()
|
||||
{
|
||||
new()
|
||||
{
|
||||
MaxRadius = 0.125,
|
||||
FeedratePercent = 0.25,
|
||||
VariableNumber = 123,
|
||||
},
|
||||
new()
|
||||
{
|
||||
MaxRadius = 0.750,
|
||||
FeedratePercent = 0.50,
|
||||
VariableNumber = 124,
|
||||
},
|
||||
new()
|
||||
{
|
||||
MaxRadius = 4.500,
|
||||
FeedratePercent = 0.80,
|
||||
VariableNumber = 125,
|
||||
},
|
||||
};
|
||||
|
||||
[Category("A. Variables")]
|
||||
[DisplayName("User Variable Start")]
|
||||
[Description("Starting variable number for user-defined variables (#200, #201, etc.).")]
|
||||
public int UserVariableStart { get; set; } = 200;
|
||||
|
||||
[Category("A. Variables")]
|
||||
[DisplayName("Sheet Width Variable")]
|
||||
[Description("Variable number for sheet width.")]
|
||||
public int SheetWidthVariable { get; set; } = 110;
|
||||
|
||||
[Category("A. Variables")]
|
||||
[DisplayName("Sheet Length Variable")]
|
||||
[Description("Variable number for sheet length.")]
|
||||
public int SheetLengthVariable { get; set; } = 111;
|
||||
|
||||
[Category("B. Libraries")]
|
||||
[DisplayName("Material Libraries")]
|
||||
[Description(
|
||||
"Material-to-library mapping for cut operations. Maps (material, thickness, gas) to a G89 library file."
|
||||
)]
|
||||
public List<MaterialLibraryEntry> MaterialLibraries { get; set; } = new();
|
||||
|
||||
[Category("B. Libraries")]
|
||||
[DisplayName("Etch Libraries")]
|
||||
[Description("Gas-to-library mapping for etch operations.")]
|
||||
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
|
||||
|
||||
[Category("B. Libraries")]
|
||||
[DisplayName("Selected Library")]
|
||||
[Description(
|
||||
"Overrides Material/Thickness/Gas auto-resolution. Pick an existing entry from Material Libraries, or leave blank to auto-resolve."
|
||||
)]
|
||||
[TypeConverter(typeof(MaterialLibraryNameConverter))]
|
||||
public string SelectedLibrary { get; set; } = "";
|
||||
|
||||
public string FindBestLibrary(string materialName, double thickness)
|
||||
{
|
||||
if (MaterialLibraries == null || string.IsNullOrEmpty(materialName))
|
||||
return "";
|
||||
|
||||
return MaterialLibraries
|
||||
.Where(e =>
|
||||
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
.OrderBy(e => System.Math.Abs(e.Thickness - thickness))
|
||||
.Select(e => e.Library)
|
||||
.FirstOrDefault()
|
||||
?? "";
|
||||
}
|
||||
}
|
||||
|
||||
public class MaterialLibraryEntry
|
||||
{
|
||||
public string Material { get; set; } = "";
|
||||
public double Thickness { get; set; }
|
||||
public string Gas { get; set; } = "";
|
||||
public string Library { get; set; } = "";
|
||||
}
|
||||
|
||||
public class EtchLibraryEntry
|
||||
{
|
||||
public string Gas { get; set; } = "";
|
||||
public string Library { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how arc feedrates are calculated based on radius.
|
||||
/// </summary>
|
||||
public enum ArcFeedrateMode
|
||||
{
|
||||
/// <summary>No radius-based arc feedrate adjustment (only full circles use multiplier).</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Inline percentage expressions: F [#148*pct] based on radius range.</summary>
|
||||
Percentages,
|
||||
|
||||
/// <summary>Radius-range-based variables: F #varNum based on radius range.</summary>
|
||||
Variables,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a radius range and its associated feedrate for arc moves.
|
||||
/// </summary>
|
||||
public class ArcFeedrateRange
|
||||
{
|
||||
/// <summary>Maximum radius for this range (inclusive).</summary>
|
||||
public double MaxRadius { get; set; }
|
||||
|
||||
/// <summary>Feedrate as a fraction of process feedrate (e.g. 0.25 = 25%).</summary>
|
||||
public double FeedratePercent { get; set; }
|
||||
|
||||
/// <summary>Variable number for Variables mode (e.g. 123).</summary>
|
||||
public int VariableNumber { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class CincinnatiPostProcessor
|
||||
: IConfigurablePostProcessor,
|
||||
IPostProcessorNestAware,
|
||||
IMaterialProvidingPostProcessor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public string Name => "Cincinnati CL-707";
|
||||
public string Author => "OpenNest";
|
||||
public string Description => "Cincinnati CL-707/CL-800/CL-900/CL-940/CLX family";
|
||||
|
||||
public CincinnatiPostConfig Config { get; }
|
||||
|
||||
object IConfigurablePostProcessor.Config => Config;
|
||||
|
||||
public IEnumerable<string> GetMaterialNames()
|
||||
{
|
||||
if (Config?.MaterialLibraries == null)
|
||||
return System.Array.Empty<string>();
|
||||
|
||||
return Config
|
||||
.MaterialLibraries.Select(e => e.Material)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
}
|
||||
|
||||
public void PrepareForNest(Nest nest)
|
||||
{
|
||||
var materialName = nest?.Material?.Name ?? "";
|
||||
var thickness = nest?.Thickness ?? 0.0;
|
||||
Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness);
|
||||
}
|
||||
|
||||
public CincinnatiPostProcessor()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
Config = JsonSerializer.Deserialize<CincinnatiPostConfig>(json, JsonOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
Config = new CincinnatiPostConfig();
|
||||
SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public CincinnatiPostProcessor(CincinnatiPostConfig config)
|
||||
{
|
||||
Config = config;
|
||||
}
|
||||
|
||||
public void SaveConfig()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
var json = JsonSerializer.Serialize(Config, JsonOptions);
|
||||
File.WriteAllText(configPath, json);
|
||||
}
|
||||
|
||||
private static string GetConfigPath()
|
||||
{
|
||||
var assemblyPath = typeof(CincinnatiPostProcessor).Assembly.Location;
|
||||
var dir = Path.GetDirectoryName(assemblyPath);
|
||||
var name = Path.GetFileNameWithoutExtension(assemblyPath);
|
||||
return Path.Combine(dir, name + ".json");
|
||||
}
|
||||
|
||||
public void Post(Nest nest, Stream outputStream)
|
||||
{
|
||||
// 1. Create variable manager and register standard variables
|
||||
var vars = CreateVariableManager();
|
||||
|
||||
// 2. Filter to non-empty plates
|
||||
var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToList();
|
||||
|
||||
// 3. Register user variables from drawing programs
|
||||
var userVarMapping = RegisterUserVariables(vars, plates);
|
||||
|
||||
// 4. Resolve gas and library files
|
||||
var resolver = new MaterialLibraryResolver(Config);
|
||||
var gas = MaterialLibraryResolver.ResolveGas(nest, Config);
|
||||
var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas);
|
||||
|
||||
// Resolve cut library from nest material/thickness for preamble
|
||||
var firstPlate = plates.FirstOrDefault();
|
||||
var initialCutLibrary = resolver.ResolveCutLibrary(
|
||||
nest.Material?.Name ?? "",
|
||||
nest.Thickness,
|
||||
gas
|
||||
);
|
||||
|
||||
// 5. Build part sub-program registry (if enabled)
|
||||
Dictionary<(int, long), int> partSubprograms = null;
|
||||
List<(int subNum, string name, Program program)> subprogramEntries = null;
|
||||
|
||||
if (Config.UsePartSubprograms)
|
||||
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(
|
||||
plates,
|
||||
Config.PartSubprogramStart
|
||||
);
|
||||
|
||||
// 5b. Build hole sub-program registry (SubProgramCalls across all parts)
|
||||
var holeStartNumber = Config.PartSubprogramStart + (subprogramEntries?.Count ?? 0);
|
||||
var (holeMapping, holeEntries) = CincinnatiPartSubprogramWriter.BuildHoleRegistry(
|
||||
plates,
|
||||
holeStartNumber
|
||||
);
|
||||
|
||||
// 6. Create writers
|
||||
var preamble = new CincinnatiPreambleWriter(Config);
|
||||
var sheetWriter = new CincinnatiSheetWriter(
|
||||
Config,
|
||||
vars,
|
||||
holeMapping.Count > 0 ? holeMapping : null
|
||||
);
|
||||
|
||||
// 7. Build material description from nest
|
||||
var material = nest.Material;
|
||||
var materialDesc =
|
||||
material != null
|
||||
? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}"
|
||||
: "";
|
||||
|
||||
// 8. Write to stream
|
||||
using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true);
|
||||
|
||||
// Main program
|
||||
preamble.WriteMainProgram(
|
||||
writer,
|
||||
nest.Name ?? "NEST",
|
||||
materialDesc,
|
||||
plates,
|
||||
initialCutLibrary
|
||||
);
|
||||
|
||||
// Variable declaration subprogram
|
||||
preamble.WriteVariableDeclaration(writer, vars);
|
||||
|
||||
// Sheet subprograms (one per unique layout, quantity handled via L count in main)
|
||||
for (var i = 0; i < plates.Count; i++)
|
||||
{
|
||||
var plate = plates[i];
|
||||
var layoutIndex = i + 1;
|
||||
var subNumber = Config.SheetSubprogramStart + i;
|
||||
var cutLibrary = resolver.ResolveCutLibrary(
|
||||
nest.Material?.Name ?? "",
|
||||
nest.Thickness,
|
||||
gas
|
||||
);
|
||||
sheetWriter.Write(
|
||||
writer,
|
||||
plate,
|
||||
nest.Name ?? "NEST",
|
||||
layoutIndex,
|
||||
subNumber,
|
||||
cutLibrary,
|
||||
etchLibrary,
|
||||
partSubprograms,
|
||||
userVarMapping
|
||||
);
|
||||
}
|
||||
|
||||
// Part sub-programs (if enabled)
|
||||
if (subprogramEntries != null)
|
||||
{
|
||||
var partSubWriter = new CincinnatiPartSubprogramWriter(
|
||||
Config,
|
||||
holeMapping.Count > 0 ? holeMapping : null
|
||||
);
|
||||
var sheetDiagonal =
|
||||
firstPlate != null
|
||||
? System.Math.Sqrt(
|
||||
firstPlate.Size.Width * firstPlate.Size.Width
|
||||
+ firstPlate.Size.Length * firstPlate.Size.Length
|
||||
)
|
||||
: 100.0;
|
||||
|
||||
foreach (var (subNum, name, pgm) in subprogramEntries)
|
||||
{
|
||||
partSubWriter.Write(
|
||||
writer,
|
||||
pgm,
|
||||
name,
|
||||
subNum,
|
||||
initialCutLibrary,
|
||||
etchLibrary,
|
||||
sheetDiagonal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hole sub-programs (SubProgramCall definitions)
|
||||
if (holeEntries.Count > 0)
|
||||
{
|
||||
var holeSubWriter = new CincinnatiPartSubprogramWriter(Config);
|
||||
var sheetDiagonal =
|
||||
firstPlate != null
|
||||
? System.Math.Sqrt(
|
||||
firstPlate.Size.Width * firstPlate.Size.Width
|
||||
+ firstPlate.Size.Length * firstPlate.Size.Length
|
||||
)
|
||||
: 100.0;
|
||||
|
||||
foreach (var (subNum, pgm) in holeEntries)
|
||||
{
|
||||
CincinnatiPartSubprogramWriter.EnsureLeadingRapid(pgm);
|
||||
holeSubWriter.Write(
|
||||
writer,
|
||||
pgm,
|
||||
"HOLE",
|
||||
subNum,
|
||||
initialCutLibrary,
|
||||
etchLibrary,
|
||||
sheetDiagonal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
public void Post(Nest nest, string outputFile)
|
||||
{
|
||||
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
|
||||
Post(nest, fs);
|
||||
}
|
||||
|
||||
private Dictionary<(int drawingId, string varName), int> RegisterUserVariables(
|
||||
ProgramVariableManager vars,
|
||||
List<Plate> plates
|
||||
)
|
||||
{
|
||||
var mapping = new Dictionary<(int drawingId, string varName), int>();
|
||||
var nextNumber = Config.UserVariableStart;
|
||||
|
||||
// Track global variables by name so they share a single number
|
||||
var globalNumbers = new Dictionary<string, int>(
|
||||
System.StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
// Collect unique drawings from all plates
|
||||
var seenDrawings = new HashSet<int>();
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
var drawing = part.BaseDrawing;
|
||||
if (drawing.IsCutOff || !seenDrawings.Add(drawing.Id))
|
||||
continue;
|
||||
|
||||
foreach (var kvp in drawing.Program.Variables)
|
||||
{
|
||||
var varDef = kvp.Value;
|
||||
|
||||
// Skip inline variables — they emit literal values
|
||||
if (varDef.Inline)
|
||||
continue;
|
||||
|
||||
if (varDef.Global)
|
||||
{
|
||||
if (!globalNumbers.TryGetValue(varDef.Name, out var globalNum))
|
||||
{
|
||||
globalNum = nextNumber++;
|
||||
globalNumbers[varDef.Name] = globalNum;
|
||||
|
||||
// Register once in the variable manager
|
||||
var commentName = ToPascalCase(varDef.Name);
|
||||
var expression = FormatVariableValue(varDef.Value);
|
||||
vars.GetOrCreate(commentName, globalNum, expression);
|
||||
}
|
||||
|
||||
mapping[(drawing.Id, varDef.Name)] = globalNum;
|
||||
}
|
||||
else
|
||||
{
|
||||
var num = nextNumber++;
|
||||
mapping[(drawing.Id, varDef.Name)] = num;
|
||||
|
||||
// Register with drawing name prefix in the comment
|
||||
var drawingLabel = ToPascalCase(drawing.Name);
|
||||
var varLabel = ToPascalCase(varDef.Name);
|
||||
var commentName = $"{drawingLabel}{varLabel}";
|
||||
var expression = FormatVariableValue(varDef.Value);
|
||||
vars.GetOrCreate(commentName, num, expression);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a variable name from snake_case or camelCase to PascalCase.
|
||||
/// Examples: "sheet_width" → "SheetWidth", "holeSpacing" → "HoleSpacing"
|
||||
/// </summary>
|
||||
private static string ToPascalCase(string name)
|
||||
{
|
||||
var sb = new StringBuilder(name.Length);
|
||||
var capitalizeNext = true;
|
||||
|
||||
foreach (var c in name)
|
||||
{
|
||||
if (c == '_')
|
||||
{
|
||||
capitalizeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (capitalizeNext)
|
||||
{
|
||||
sb.Append(char.ToUpper(c));
|
||||
capitalizeNext = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatVariableValue(double value)
|
||||
{
|
||||
return value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private ProgramVariableManager CreateVariableManager()
|
||||
{
|
||||
var vars = new ProgramVariableManager();
|
||||
vars.GetOrCreate("ProcessFeedrate", 148); // Set by G89, no expression
|
||||
vars.GetOrCreate("LeadInFeedrate", 126, $"[#148*{Config.LeadInFeedratePercent}]");
|
||||
vars.GetOrCreate(
|
||||
"LeadInArcLine2Feedrate",
|
||||
127,
|
||||
$"[#148*{Config.LeadInArcLine2FeedratePercent}]"
|
||||
);
|
||||
vars.GetOrCreate(
|
||||
"CircleFeedrate",
|
||||
128,
|
||||
Config.CircleFeedrateMultiplier.ToString("0.#")
|
||||
);
|
||||
vars.GetOrCreate("LeadOutFeedrate", 129, $"[#148*{Config.LeadOutFeedratePercent}]");
|
||||
|
||||
if (Config.ArcFeedrate == ArcFeedrateMode.Variables)
|
||||
{
|
||||
foreach (var range in Config.ArcFeedrateRanges)
|
||||
{
|
||||
var name =
|
||||
$"ArcFeedR{range.MaxRadius.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)}";
|
||||
vars.GetOrCreate(
|
||||
name,
|
||||
range.VariableNumber,
|
||||
$"[#148*{range.FeedratePercent.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using OpenNest;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Emits the main program header and variable declaration subprogram
|
||||
/// for a Cincinnati laser post-processor output file.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiPreambleWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
|
||||
public CincinnatiPreambleWriter(CincinnatiPostConfig config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the main program header block.
|
||||
/// </summary>
|
||||
/// <param name="initialLibrary">Resolved G89 library file for the initial process setup.</param>
|
||||
public void WriteMainProgram(
|
||||
TextWriter w,
|
||||
string nestName,
|
||||
string materialDescription,
|
||||
List<Plate> plates,
|
||||
string initialLibrary
|
||||
)
|
||||
{
|
||||
w.WriteLine(CoordinateFormatter.Comment($"NEST {nestName}"));
|
||||
w.WriteLine(CoordinateFormatter.Comment($"CONFIGURATION - {_config.ConfigurationName}"));
|
||||
w.WriteLine(
|
||||
CoordinateFormatter.Comment(
|
||||
DateTime.Now.ToString(
|
||||
"MM-dd-yyyy hh:mm:ss tt",
|
||||
System.Globalization.CultureInfo.InvariantCulture
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (!string.IsNullOrEmpty(materialDescription))
|
||||
w.WriteLine(CoordinateFormatter.Comment($"Material = {materialDescription}"));
|
||||
|
||||
if (_config.UseExactStopMode)
|
||||
w.WriteLine("G61");
|
||||
|
||||
w.WriteLine(CoordinateFormatter.Comment("MAIN PROGRAM"));
|
||||
|
||||
w.WriteLine(_config.PostedUnits == Units.Millimeters ? "G21 G90" : "G20 G90");
|
||||
|
||||
if (_config.UseSmartRapids)
|
||||
w.WriteLine("G121 (SMART RAPIDS)");
|
||||
|
||||
w.WriteLine("M42");
|
||||
|
||||
if (
|
||||
_config.ProcessParameterMode == G89Mode.LibraryFile
|
||||
&& !string.IsNullOrEmpty(initialLibrary)
|
||||
)
|
||||
w.WriteLine($"G89 P{initialLibrary}");
|
||||
|
||||
w.WriteLine($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)");
|
||||
|
||||
if (_config.PalletExchange == PalletMode.StartAndEnd)
|
||||
w.WriteLine("M50");
|
||||
|
||||
w.WriteLine("GOTO1 (GOTO SHEET NUMBER)");
|
||||
|
||||
for (var i = 0; i < plates.Count; i++)
|
||||
{
|
||||
var layoutNumber = i + 1;
|
||||
var subNum = _config.SheetSubprogramStart + i;
|
||||
var qty = System.Math.Max(plates[i].Quantity, 1);
|
||||
var lParam = qty > 1 ? $" L{qty}" : "";
|
||||
var sheetLabel =
|
||||
qty > 1 ? $"LAYOUT {layoutNumber} - {qty} SHEETS" : $"LAYOUT {layoutNumber}";
|
||||
w.WriteLine($"N{layoutNumber} M98 P{subNum}{lParam} ({sheetLabel})");
|
||||
}
|
||||
|
||||
w.WriteLine("M42");
|
||||
w.WriteLine("M30 (END OF MAIN)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the variable declaration subprogram block.
|
||||
/// </summary>
|
||||
public void WriteVariableDeclaration(TextWriter w, ProgramVariableManager vars)
|
||||
{
|
||||
w.WriteLine("(*****************************************************)");
|
||||
w.WriteLine($":{_config.VariableDeclarationSubprogram}");
|
||||
w.WriteLine("(Variable Declaration Start)");
|
||||
|
||||
foreach (var line in vars.EmitDeclarations())
|
||||
w.WriteLine(line);
|
||||
|
||||
w.WriteLine("M99 (Variable Declaration End)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Emits one Cincinnati-format sheet subprogram per plate.
|
||||
/// Supports two modes: inline features (default) or M98 sub-program calls per part.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiSheetWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
private readonly ProgramVariableManager _vars;
|
||||
private readonly CoordinateFormatter _fmt;
|
||||
private readonly CincinnatiFeatureWriter _featureWriter;
|
||||
private readonly Dictionary<int, int> _holeSubprograms;
|
||||
|
||||
public CincinnatiSheetWriter(
|
||||
CincinnatiPostConfig config,
|
||||
ProgramVariableManager vars,
|
||||
Dictionary<int, int> holeSubprograms = null
|
||||
)
|
||||
{
|
||||
_config = config;
|
||||
_vars = vars;
|
||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||
_featureWriter = new CincinnatiFeatureWriter(config);
|
||||
_holeSubprograms = holeSubprograms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a complete sheet subprogram for the given plate.
|
||||
/// </summary>
|
||||
/// <param name="cutLibrary">Resolved G89 library file for cut operations.</param>
|
||||
/// <param name="etchLibrary">Resolved G89 library file for etch operations.</param>
|
||||
/// <param name="partSubprograms">
|
||||
/// Optional mapping of (drawingId, rotationKey) to sub-program number.
|
||||
/// When provided, non-cutoff parts are emitted as M98 calls instead of inline features.
|
||||
/// </param>
|
||||
public void Write(
|
||||
TextWriter w,
|
||||
Plate plate,
|
||||
string nestName,
|
||||
int layoutIndex,
|
||||
int subNumber,
|
||||
string cutLibrary,
|
||||
string etchLibrary,
|
||||
Dictionary<(int, long), int> partSubprograms = null,
|
||||
Dictionary<(int drawingId, string varName), int> userVarMapping = null
|
||||
)
|
||||
{
|
||||
if (plate.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
var width = plate.Size.Width;
|
||||
var length = plate.Size.Length;
|
||||
var sheetDiagonal = System.Math.Sqrt(width * width + length * length);
|
||||
var varDeclSub = _config.VariableDeclarationSubprogram;
|
||||
var partCount = plate.Parts.Count(p => !p.BaseDrawing.IsCutOff);
|
||||
|
||||
// 1. Sheet header
|
||||
w.WriteLine("(*****************************************************)");
|
||||
w.WriteLine($"( START OF {nestName}.{layoutIndex:D3} )");
|
||||
w.WriteLine($":{subNumber}");
|
||||
w.WriteLine($"( Layout {layoutIndex} )");
|
||||
w.WriteLine($"( SHEET NAME = {_fmt.FormatCoord(width)} X {_fmt.FormatCoord(length)} )");
|
||||
w.WriteLine($"( Total parts on sheet = {partCount} )");
|
||||
w.WriteLine(
|
||||
$"#{_config.SheetWidthVariable}={_fmt.FormatCoord(width)} (SHEET WIDTH FOR CUTOFFS)"
|
||||
);
|
||||
w.WriteLine(
|
||||
$"#{_config.SheetLengthVariable}={_fmt.FormatCoord(length)} (SHEET LENGTH FOR CUTOFFS)"
|
||||
);
|
||||
|
||||
// 2. Coordinate setup
|
||||
w.WriteLine("M42");
|
||||
w.WriteLine("N10000");
|
||||
w.WriteLine("G92 X#5021 Y#5022");
|
||||
if (!string.IsNullOrEmpty(cutLibrary))
|
||||
w.WriteLine($"G89 P{cutLibrary}");
|
||||
w.WriteLine($"M98 P{varDeclSub} (Variable Declaration)");
|
||||
w.WriteLine("G90");
|
||||
w.WriteLine("M47");
|
||||
if (!string.IsNullOrEmpty(cutLibrary))
|
||||
w.WriteLine($"G89 P{cutLibrary}");
|
||||
w.WriteLine("GOTO1( Goto Feature )");
|
||||
|
||||
// 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last
|
||||
var nonCutoffParts = plate
|
||||
.Parts.Where(p => !p.BaseDrawing.IsCutOff)
|
||||
.OrderBy(p => p.Bottom)
|
||||
.ThenBy(p => p.Left)
|
||||
.ToList();
|
||||
|
||||
var cutoffParts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToList();
|
||||
|
||||
var allParts = nonCutoffParts.Concat(cutoffParts).ToList();
|
||||
|
||||
// 4. Emit parts
|
||||
if (partSubprograms != null)
|
||||
WritePartsWithSubprograms(
|
||||
w,
|
||||
allParts,
|
||||
cutLibrary,
|
||||
etchLibrary,
|
||||
sheetDiagonal,
|
||||
width,
|
||||
length,
|
||||
partSubprograms,
|
||||
userVarMapping
|
||||
);
|
||||
else
|
||||
WritePartsInline(
|
||||
w,
|
||||
allParts,
|
||||
cutLibrary,
|
||||
etchLibrary,
|
||||
sheetDiagonal,
|
||||
width,
|
||||
length,
|
||||
userVarMapping
|
||||
);
|
||||
|
||||
// 5. Footer
|
||||
w.WriteLine("M42");
|
||||
if (_config.PalletExchange != PalletMode.None)
|
||||
w.WriteLine("M50");
|
||||
w.WriteLine($"M99 (END OF {nestName}.{layoutIndex:D3})");
|
||||
}
|
||||
|
||||
private void WritePartsWithSubprograms(
|
||||
TextWriter w,
|
||||
List<Part> allParts,
|
||||
string cutLibrary,
|
||||
string etchLibrary,
|
||||
double sheetDiagonal,
|
||||
double plateWidth,
|
||||
double plateLength,
|
||||
Dictionary<(int, long), int> partSubprograms,
|
||||
Dictionary<(int drawingId, string varName), int> userVarMapping
|
||||
)
|
||||
{
|
||||
var lastPartName = "";
|
||||
var featureIndex = 0;
|
||||
|
||||
for (var p = 0; p < allParts.Count; p++)
|
||||
{
|
||||
var part = allParts[p];
|
||||
var partName = part.BaseDrawing.Name;
|
||||
var isNewPart = partName != lastPartName;
|
||||
var isSafetyHeadraise = isNewPart && lastPartName != "";
|
||||
var isLastPart = p == allParts.Count - 1;
|
||||
|
||||
var key = CincinnatiPartSubprogramWriter.SubprogramKey(part);
|
||||
partSubprograms.TryGetValue(key, out var subNum);
|
||||
var hasSubprogram = !part.BaseDrawing.IsCutOff && subNum != 0;
|
||||
|
||||
if (hasSubprogram)
|
||||
{
|
||||
WriteSubprogramCall(
|
||||
w,
|
||||
part,
|
||||
subNum,
|
||||
featureIndex,
|
||||
partName,
|
||||
isSafetyHeadraise,
|
||||
isLastPart
|
||||
);
|
||||
featureIndex++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Inline features for cutoffs or parts without sub-programs
|
||||
var features = FeatureUtils.SplitAndClassify(part);
|
||||
for (var f = 0; f < features.Count; f++)
|
||||
{
|
||||
var (codes, isEtch) = features[f];
|
||||
var isLastFeature = isLastPart && f == features.Count - 1;
|
||||
|
||||
// SubProgramCall features are emitted as M98 hole calls
|
||||
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
|
||||
{
|
||||
WriteHoleSubprogramCall(w, holeCall, featureIndex, isLastFeature);
|
||||
featureIndex++;
|
||||
lastPartName = partName;
|
||||
continue;
|
||||
}
|
||||
|
||||
var featureNumber =
|
||||
featureIndex == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + featureIndex + 1;
|
||||
|
||||
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
Codes = codes,
|
||||
FeatureNumber = featureNumber,
|
||||
PartName = partName,
|
||||
IsFirstFeatureOfPart = isNewPart && f == 0,
|
||||
IsLastFeatureOnSheet = isLastFeature,
|
||||
IsSafetyHeadraise = isSafetyHeadraise && f == 0,
|
||||
IsExteriorFeature = false,
|
||||
IsEtch = isEtch,
|
||||
LibraryFile = isEtch ? etchLibrary : cutLibrary,
|
||||
CutDistance = cutDistance,
|
||||
SheetDiagonal = sheetDiagonal,
|
||||
PartLocation = part.Location,
|
||||
UserVariableMapping = userVarMapping,
|
||||
DrawingId = part.BaseDrawing.Id,
|
||||
IsCutOff = part.BaseDrawing.IsCutOff,
|
||||
PlateWidth = plateWidth,
|
||||
PlateLength = plateLength,
|
||||
};
|
||||
|
||||
_featureWriter.Write(w, ctx);
|
||||
featureIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
lastPartName = partName;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteSubprogramCall(
|
||||
TextWriter w,
|
||||
Part part,
|
||||
int subNum,
|
||||
int featureIndex,
|
||||
string partName,
|
||||
bool isSafetyHeadraise,
|
||||
bool isLastPart
|
||||
)
|
||||
{
|
||||
// Safety headraise before rapid to new part
|
||||
if (isSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
|
||||
w.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
|
||||
|
||||
// Rapid to part position (bounding box lower-left)
|
||||
var featureNumber =
|
||||
featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (_config.UseLineNumbers)
|
||||
sb.Append($"N{featureNumber} ");
|
||||
sb.Append($"G0 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}");
|
||||
w.WriteLine(sb.ToString());
|
||||
|
||||
// Part name comment
|
||||
w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}"));
|
||||
|
||||
// Set local coordinate system at part position
|
||||
w.WriteLine("G92 X0 Y0");
|
||||
|
||||
// Call part sub-program
|
||||
w.WriteLine($"M98 P{subNum} ({partName})");
|
||||
|
||||
// Restore sheet coordinate system
|
||||
w.WriteLine($"G92 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}");
|
||||
|
||||
// Head raise (unless last part on sheet)
|
||||
if (!isLastPart)
|
||||
w.WriteLine("M47");
|
||||
}
|
||||
|
||||
private void WriteHoleSubprogramCall(
|
||||
TextWriter w,
|
||||
SubProgramCall call,
|
||||
int featureIndex,
|
||||
bool isLastFeature
|
||||
)
|
||||
{
|
||||
var postSubNum =
|
||||
_holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
|
||||
? num
|
||||
: call.Id;
|
||||
|
||||
var featureNumber =
|
||||
featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1;
|
||||
|
||||
// Shift the local origin to the hole center via G52 (manual §1.52).
|
||||
// G52 does not move the nozzle, so the sub-program's first rapid
|
||||
// (the lead-in to the pierce point) takes the tool straight from the
|
||||
// previous feature's end to pierce. The hole sub-program is authored
|
||||
// in hole-local coordinates and resolves to `hole + local` under the
|
||||
// shift. See docs/cincinnati-post-output.md for the full bracket.
|
||||
var sb = new StringBuilder();
|
||||
if (_config.UseLineNumbers)
|
||||
sb.Append($"N{featureNumber} ");
|
||||
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
|
||||
w.WriteLine(sb.ToString());
|
||||
|
||||
w.WriteLine($"M98 P{postSubNum}");
|
||||
|
||||
// Cancel the local shift (manual §1.52).
|
||||
w.WriteLine("G52 X0 Y0");
|
||||
|
||||
if (!isLastFeature)
|
||||
w.WriteLine("M47");
|
||||
}
|
||||
|
||||
private void WritePartsInline(
|
||||
TextWriter w,
|
||||
List<Part> allParts,
|
||||
string cutLibrary,
|
||||
string etchLibrary,
|
||||
double sheetDiagonal,
|
||||
double plateWidth,
|
||||
double plateLength,
|
||||
Dictionary<(int drawingId, string varName), int> userVarMapping
|
||||
)
|
||||
{
|
||||
// Split and classify features, ordering etch before cut per part
|
||||
var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
|
||||
foreach (var part in allParts)
|
||||
{
|
||||
var partFeatures = FeatureUtils.SplitAndClassify(part);
|
||||
foreach (var (codes, isEtch) in partFeatures)
|
||||
features.Add((part, codes, isEtch));
|
||||
}
|
||||
|
||||
// Emit features
|
||||
var lastPartName = "";
|
||||
for (var i = 0; i < features.Count; i++)
|
||||
{
|
||||
var (part, codes, isEtch) = features[i];
|
||||
var partName = part.BaseDrawing.Name;
|
||||
var isFirstFeatureOfPart = partName != lastPartName;
|
||||
var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
|
||||
var isLastFeature = i == features.Count - 1;
|
||||
|
||||
// SubProgramCall features are emitted as M98 hole calls
|
||||
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
|
||||
{
|
||||
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
|
||||
lastPartName = partName;
|
||||
continue;
|
||||
}
|
||||
|
||||
var featureNumber = i == 0 ? _config.FeatureLineNumberStart : 1000 + i + 1;
|
||||
|
||||
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
Codes = codes,
|
||||
FeatureNumber = featureNumber,
|
||||
PartName = partName,
|
||||
IsFirstFeatureOfPart = isFirstFeatureOfPart,
|
||||
IsLastFeatureOnSheet = isLastFeature,
|
||||
IsSafetyHeadraise = isSafetyHeadraise,
|
||||
IsExteriorFeature = false,
|
||||
IsEtch = isEtch,
|
||||
LibraryFile = isEtch ? etchLibrary : cutLibrary,
|
||||
CutDistance = cutDistance,
|
||||
SheetDiagonal = sheetDiagonal,
|
||||
PartLocation = part.Location,
|
||||
UserVariableMapping = userVarMapping,
|
||||
DrawingId = part.BaseDrawing.Id,
|
||||
IsCutOff = part.BaseDrawing.IsCutOff,
|
||||
PlateWidth = plateWidth,
|
||||
PlateLength = plateLength,
|
||||
};
|
||||
|
||||
_featureWriter.Write(w, ctx);
|
||||
lastPartName = partName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class CoordinateFormatter
|
||||
{
|
||||
private readonly int _accuracy;
|
||||
private readonly string _format;
|
||||
|
||||
public CoordinateFormatter(int accuracy)
|
||||
{
|
||||
_accuracy = accuracy;
|
||||
_format = "0." + new string('#', accuracy);
|
||||
}
|
||||
|
||||
public string FormatCoord(double value)
|
||||
{
|
||||
return System
|
||||
.Math.Round(value, _accuracy)
|
||||
.ToString(_format, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string Comment(string text) => $"( {text} )";
|
||||
|
||||
public static string InlineComment(string text) => $"({text})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Shared utilities for splitting CNC programs into features and classifying them.
|
||||
/// </summary>
|
||||
public static class FeatureUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits a flat list of codes into feature groups, breaking on rapid moves.
|
||||
/// Each feature starts with a rapid move followed by cutting/etching moves.
|
||||
/// </summary>
|
||||
public static List<List<ICode>> SplitByRapids(List<ICode> codes)
|
||||
{
|
||||
var features = new List<List<ICode>>();
|
||||
List<ICode> current = null;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is SubProgramCall)
|
||||
{
|
||||
// Flush any pending feature
|
||||
if (current != null)
|
||||
features.Add(current);
|
||||
// SubProgramCall is its own feature
|
||||
features.Add(new List<ICode> { code });
|
||||
current = null;
|
||||
}
|
||||
else if (code is RapidMove)
|
||||
{
|
||||
if (current != null)
|
||||
features.Add(current);
|
||||
current = new List<ICode> { code };
|
||||
}
|
||||
else
|
||||
{
|
||||
current ??= new List<ICode>();
|
||||
current.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null && current.Count > 0)
|
||||
features.Add(current);
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies features as etch or cut and orders etch features before cut features.
|
||||
/// </summary>
|
||||
public static List<(List<ICode> codes, bool isEtch)> ClassifyAndOrder(
|
||||
List<List<ICode>> features
|
||||
)
|
||||
{
|
||||
var result = new List<(List<ICode>, bool)>();
|
||||
var etch = new List<List<ICode>>();
|
||||
var cut = new List<List<ICode>>();
|
||||
|
||||
foreach (var f in features)
|
||||
{
|
||||
if (IsEtch(f))
|
||||
etch.Add(f);
|
||||
else
|
||||
cut.Add(f);
|
||||
}
|
||||
|
||||
foreach (var f in etch)
|
||||
result.Add((f, true));
|
||||
foreach (var f in cut)
|
||||
result.Add((f, false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a part's program into features by rapids, classifies each as etch or cut,
|
||||
/// and orders etch features before cut features.
|
||||
/// </summary>
|
||||
public static List<(List<ICode> codes, bool isEtch)> SplitAndClassify(Part part)
|
||||
{
|
||||
part.Program.Mode = Mode.Absolute;
|
||||
var codes = part.Program.Codes;
|
||||
|
||||
// If no leading rapid, the first contour segment would be lost because
|
||||
// the feature writer pierces at the first motion endpoint. Insert a
|
||||
// synthetic rapid at the contour's return point to preserve closure.
|
||||
if (codes.Count > 0 && codes[0] is not RapidMove)
|
||||
{
|
||||
for (var i = codes.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (codes[i] is Motion lastMotion)
|
||||
{
|
||||
var withRapid = new List<ICode>(codes.Count + 1);
|
||||
withRapid.Add(new RapidMove(lastMotion.EndPoint));
|
||||
withRapid.AddRange(codes);
|
||||
codes = withRapid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ClassifyAndOrder(SplitByRapids(codes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any non-rapid move in the feature has LayerType.Scribe.
|
||||
/// </summary>
|
||||
public static bool IsEtch(List<ICode> codes)
|
||||
{
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is LinearMove linear && linear.Layer == LayerType.Scribe)
|
||||
return true;
|
||||
if (code is ArcMove arc && arc.Layer == LayerType.Scribe)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the total cut distance of a feature by summing segment lengths.
|
||||
/// </summary>
|
||||
public static double ComputeCutDistance(List<ICode> codes)
|
||||
{
|
||||
var distance = 0.0;
|
||||
var currentPos = Vector.Zero;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove rapid)
|
||||
currentPos = rapid.EndPoint;
|
||||
else if (code is LinearMove linear)
|
||||
{
|
||||
distance += currentPos.DistanceTo(linear.EndPoint);
|
||||
currentPos = linear.EndPoint;
|
||||
}
|
||||
else if (code is ArcMove arc)
|
||||
{
|
||||
distance += ComputeArcLength(currentPos, arc);
|
||||
currentPos = arc.EndPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the arc length from the current position through an arc move.
|
||||
/// Uses radius * sweep angle instead of chord length.
|
||||
/// </summary>
|
||||
public static double ComputeArcLength(Vector startPos, ArcMove arc)
|
||||
{
|
||||
var radius = startPos.DistanceTo(arc.CenterPoint);
|
||||
if (radius < Tolerance.Epsilon)
|
||||
return 0.0;
|
||||
|
||||
// Full circle: start ≈ end
|
||||
if (
|
||||
Tolerance.IsEqualTo(startPos.X, arc.EndPoint.X)
|
||||
&& Tolerance.IsEqualTo(startPos.Y, arc.EndPoint.Y)
|
||||
)
|
||||
return 2.0 * System.Math.PI * radius;
|
||||
|
||||
var startAngle = System.Math.Atan2(
|
||||
startPos.Y - arc.CenterPoint.Y,
|
||||
startPos.X - arc.CenterPoint.X
|
||||
);
|
||||
var endAngle = System.Math.Atan2(
|
||||
arc.EndPoint.Y - arc.CenterPoint.Y,
|
||||
arc.EndPoint.X - arc.CenterPoint.X
|
||||
);
|
||||
|
||||
double sweep;
|
||||
if (arc.Rotation == RotationType.CW)
|
||||
{
|
||||
sweep = startAngle - endAngle;
|
||||
if (sweep <= 0)
|
||||
sweep += 2.0 * System.Math.PI;
|
||||
}
|
||||
else
|
||||
{
|
||||
sweep = endAngle - startAngle;
|
||||
if (sweep <= 0)
|
||||
sweep += 2.0 * System.Math.PI;
|
||||
}
|
||||
|
||||
return radius * sweep;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class MaterialLibraryNameConverter : StringConverter
|
||||
{
|
||||
public override bool GetStandardValuesSupported(ITypeDescriptorContext context) => true;
|
||||
|
||||
public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) => false;
|
||||
|
||||
public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
|
||||
{
|
||||
var config = context?.Instance as CincinnatiPostConfig;
|
||||
var names = new List<string> { "" };
|
||||
|
||||
if (config?.MaterialLibraries != null)
|
||||
{
|
||||
names.AddRange(
|
||||
config
|
||||
.MaterialLibraries.Select(e => e.Library)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
);
|
||||
}
|
||||
|
||||
return new StandardValuesCollection(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
public sealed class MaterialLibraryResolver
|
||||
{
|
||||
private const double ThicknessTolerance = 0.001;
|
||||
|
||||
private readonly List<MaterialLibraryEntry> _materialLibraries;
|
||||
private readonly List<EtchLibraryEntry> _etchLibraries;
|
||||
private readonly string _selectedLibrary;
|
||||
|
||||
public MaterialLibraryResolver(CincinnatiPostConfig config)
|
||||
{
|
||||
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
|
||||
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
|
||||
_selectedLibrary = config.SelectedLibrary ?? "";
|
||||
}
|
||||
|
||||
public string ResolveCutLibrary(string materialName, double thickness, string gas)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_selectedLibrary))
|
||||
return EnsureLibExtension(_selectedLibrary);
|
||||
|
||||
var entry = _materialLibraries.FirstOrDefault(e =>
|
||||
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase)
|
||||
&& System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance
|
||||
&& string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
return EnsureLibExtension(entry?.Library ?? "");
|
||||
}
|
||||
|
||||
public string ResolveEtchLibrary(string gas)
|
||||
{
|
||||
var entry = _etchLibraries.FirstOrDefault(e =>
|
||||
string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
return EnsureLibExtension(entry?.Library ?? "");
|
||||
}
|
||||
|
||||
private static string EnsureLibExtension(string library)
|
||||
{
|
||||
if (string.IsNullOrEmpty(library))
|
||||
return library;
|
||||
|
||||
if (!library.EndsWith(".lib", StringComparison.OrdinalIgnoreCase))
|
||||
return library + ".lib";
|
||||
|
||||
return library;
|
||||
}
|
||||
|
||||
public static string ResolveGas(Nest nest, CincinnatiPostConfig config)
|
||||
{
|
||||
return !string.IsNullOrEmpty(nest.AssistGas) ? nest.AssistGas : config.DefaultAssistGas;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Posts.Cincinnati</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="OpenNest.Posts.Cincinnati.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToPostsDir" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<PostsDir>..\..\OpenNest\bin\$(Configuration)\net8.0-windows\Posts\</PostsDir>
|
||||
<ConfigJson>$(MSBuildProjectDirectory)\OpenNest.Posts.Cincinnati.json</ConfigJson>
|
||||
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.Cincinnati.json</DeployedConfigJson>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PostsDir)" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
|
||||
<Copy SourceFiles="$(ConfigJson)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" Condition="!Exists('$(DeployedConfigJson)')" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"ConfigurationName": "CL940",
|
||||
"PostedUnits": "Inches",
|
||||
"PostedAccuracy": 4,
|
||||
"UseLineNumbers": true,
|
||||
"FeatureLineNumberStart": 1,
|
||||
"UseSheetSubprograms": true,
|
||||
"SheetSubprogramStart": 101,
|
||||
"UsePartSubprograms": false,
|
||||
"PartSubprogramStart": 200,
|
||||
"VariableDeclarationSubprogram": 100,
|
||||
"CoordModeBetweenParts": "G92",
|
||||
"ProcessParameterMode": "LibraryFile",
|
||||
"DefaultAssistGas": "O2",
|
||||
"DefaultEtchGas": "N2",
|
||||
"UseExactStopMode": false,
|
||||
"UseSpeedGas": false,
|
||||
"UseAntiDive": true,
|
||||
"UseSmartRapids": false,
|
||||
"KerfCompensation": "ControllerSide",
|
||||
"DefaultKerfSide": "Left",
|
||||
"InteriorM47": "Always",
|
||||
"ExteriorM47": "Always",
|
||||
"M47OverrideDistanceThreshold": null,
|
||||
"SafetyHeadraiseDistance": 2000,
|
||||
"PalletExchange": "EndOfSheet",
|
||||
"LeadInFeedratePercent": 0.5,
|
||||
"LeadInArcLine2FeedratePercent": 0.5,
|
||||
"LeadOutFeedratePercent": 0.5,
|
||||
"CircleFeedrateMultiplier": 0.8,
|
||||
"ArcFeedrate": "None",
|
||||
"ArcFeedrateRanges": [
|
||||
{ "MaxRadius": 0.125, "FeedratePercent": 0.25, "VariableNumber": 123 },
|
||||
{ "MaxRadius": 0.75, "FeedratePercent": 0.5, "VariableNumber": 124 },
|
||||
{ "MaxRadius": 4.5, "FeedratePercent": 0.8, "VariableNumber": 125 }
|
||||
],
|
||||
"UserVariableStart": 200,
|
||||
"SheetWidthVariable": 110,
|
||||
"SheetLengthVariable": 111,
|
||||
"MaterialLibraries": [
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "AIR", "Library": "AL032AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "N2", "Library": "AL032N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "O2", "Library": "AL032O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "AIR", "Library": "AL050AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "N2", "Library": "AL050N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "O2", "Library": "AL050O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "AIR", "Library": "AL063AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "N2", "Library": "AL063N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "O2", "Library": "AL063O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "AIR", "Library": "AL080AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "N2", "Library": "AL080N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "O2", "Library": "AL080O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "AIR", "Library": "AL090AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "N2", "Library": "AL090N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "O2", "Library": "AL090O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "AIR", "Library": "AL100AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "N2", "Library": "AL100N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "O2", "Library": "AL100O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "AIR", "Library": "AL125AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "N2", "Library": "AL125N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "O2", "Library": "AL125O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "AIR", "Library": "AL190AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "N2", "Library": "AL190N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "O2", "Library": "AL190O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "AIR", "Library": "AL250AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "N2", "Library": "AL250N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "O2", "Library": "AL250O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "AIR", "Library": "AL375AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "N2", "Library": "AL375N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "O2", "Library": "AL375O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "AIR", "Library": "AL500AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "N2", "Library": "AL500N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "O2", "Library": "AL500O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.625, "Gas": "N2", "Library": "AL625N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "AIR", "Library": "AL750AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "N2", "Library": "AL750N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "O2", "Library": "AL750O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "AIR", "Library": "AL1000AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "N2", "Library": "AL1000N2" },
|
||||
|
||||
{ "Material": "Galvanized Steel", "Thickness": 0.135, "Gas": "N2", "Library": "GALV135N2" },
|
||||
{ "Material": "Galvanized Steel", "Thickness": 0.188, "Gas": "N2", "Library": "GALV188N2" },
|
||||
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "MS036AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "N2", "Library": "MS036N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "MS048AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "N2", "Library": "MS048N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "MS060AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "N2", "Library": "MS060N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "MS075AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.090, "Gas": "N2", "Library": "MS090N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "MS105AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "N2", "Library": "MS105N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "MS120AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "MS135AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2Panel" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "MS188AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2FLOORPLATE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "O2", "Library": "MS188O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "MS250AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2FLOORPLATE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "O2", "Library": "MS250O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.313, "Gas": "O2", "Library": "MS313O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.375, "Gas": "O2", "Library": "MS375O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "N2", "Library": "MS500N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "O2", "Library": "MS500O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.625, "Gas": "O2", "Library": "MS625O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.750, "Gas": "O2", "Library": "MS750O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 1.000, "Gas": "O2", "Library": "MS1000O2" },
|
||||
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "SS036AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "N2", "Library": "SS036N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "SS048AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "N2", "Library": "SS048N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "SS060AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "N2", "Library": "SS060N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "SS075AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "SS105AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "SS120AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "SS135AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "SS188AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "N2", "Library": "SS188N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "SS250AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "N2", "Library": "SS250N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.313, "Gas": "N2", "Library": "SS313N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "AIR", "Library": "SS375AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "N2", "Library": "SS375N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "AIR", "Library": "SS500AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "N2", "Library": "SS500N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.625, "Gas": "N2", "Library": "SS625N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "AIR", "Library": "SS750AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "N2", "Library": "SS750N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "AIR", "Library": "SS1000AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "N2", "Library": "SS1000N2" },
|
||||
|
||||
{ "Material": "Phenolic", "Thickness": 0.0, "Gas": "", "Library": "Phenolic" },
|
||||
{ "Material": "Gasket", "Thickness": 0.250, "Gas": "N2", "Library": "GASKET250N2" }
|
||||
],
|
||||
"EtchLibraries": [
|
||||
{ "Gas": "AIR", "Library": "EtchAIR" },
|
||||
{ "Gas": "N2", "Library": "EtchN2" },
|
||||
{ "Gas": "N2", "Library": "EtchN2_fast" },
|
||||
{ "Gas": "N2", "Library": "Etchn2_no_mark_pvc" },
|
||||
{ "Gas": "O2", "Library": "EtchO2" },
|
||||
{ "Gas": "O2", "Library": "ETCHO2FINE" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class SpeedClassifier
|
||||
{
|
||||
public double FastThreshold { get; set; } = 0.5;
|
||||
public double SlowThreshold { get; set; } = 0.1;
|
||||
|
||||
public string Classify(double contourLength, double sheetDiagonal)
|
||||
{
|
||||
var ratio = contourLength / sheetDiagonal;
|
||||
if (ratio >= FastThreshold)
|
||||
return "FAST";
|
||||
if (ratio <= SlowThreshold)
|
||||
return "SLOW";
|
||||
return "MEDIUM";
|
||||
}
|
||||
|
||||
public string FormatCutDist(double contourLength, double sheetDiagonal)
|
||||
{
|
||||
return $"CutDist={FormatValue(contourLength)}/{FormatValue(sheetDiagonal)}";
|
||||
}
|
||||
|
||||
private static string FormatValue(double value)
|
||||
{
|
||||
// Cincinnati convention: no leading zero for values < 1 (e.g., ".8702" not "0.8702")
|
||||
var rounded = System.Math.Round(value, 4);
|
||||
var str = rounded.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (rounded > 0 && rounded < 1 && str.StartsWith("0."))
|
||||
return str.Substring(1);
|
||||
return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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,90 @@
|
||||
using System.ComponentModel;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// Cut parameters for one kind of pass (engrave or cut). Edited in the post
|
||||
/// configuration PropertyGrid and persisted to JSON.
|
||||
/// </summary>
|
||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||
public sealed class LayerCutConfig
|
||||
{
|
||||
[DisplayName("Feed (mm/sec)")]
|
||||
[Description("XY and Z feed for this pass. Patches the VS and VZ wire commands.")]
|
||||
public int FeedMmPerSec { get; set; } = 10;
|
||||
|
||||
[DisplayName("Depth (inches)")]
|
||||
[Description(
|
||||
"Programmed Z plunge (DZ). Note: the spring-floated spindle means this does not set actual cut depth — tool protrusion does."
|
||||
)]
|
||||
public double Depth { get; set; } = 0.25;
|
||||
|
||||
[DisplayName("Pause Before")]
|
||||
[Description(
|
||||
"Stop the spindle and prompt the operator before this pass begins, so the tool can be swapped/adjusted."
|
||||
)]
|
||||
public bool PauseBefore { get; set; }
|
||||
|
||||
[DisplayName("Pause Message")]
|
||||
[Description("Message shown on the controller during the pause.")]
|
||||
public string PauseMessage { get; set; } = "";
|
||||
|
||||
public override string ToString() =>
|
||||
$"{FeedMmPerSec} mm/s, {Depth:0.###}\"" + (PauseBefore ? ", pause" : "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Gravograph IS post processor: one <see cref="LayerCutConfig"/>
|
||||
/// per cut kind. The engrave block applies to <see cref="LayerType.Scribe"/> paths,
|
||||
/// the cut block to <see cref="LayerType.Cut"/>/<see cref="LayerType.Leadin"/>/<see cref="LayerType.Leadout"/>.
|
||||
/// The cut block carries the tool-change pause by default.
|
||||
/// </summary>
|
||||
public sealed class GravographISPostConfig
|
||||
{
|
||||
[Category("Engrave (Scribe)")]
|
||||
[DisplayName("Engrave")]
|
||||
[Description("Parameters for engrave/scribe geometry (text).")]
|
||||
public LayerCutConfig Engrave { get; set; } =
|
||||
new LayerCutConfig
|
||||
{
|
||||
FeedMmPerSec = 10,
|
||||
Depth = 0.25,
|
||||
PauseBefore = false,
|
||||
PauseMessage = "",
|
||||
};
|
||||
|
||||
[Category("Cut")]
|
||||
[DisplayName("Cut")]
|
||||
[Description(
|
||||
"Parameters for cut geometry (outlines). Pauses for a tool change by default."
|
||||
)]
|
||||
public LayerCutConfig Cut { get; set; } =
|
||||
new LayerCutConfig
|
||||
{
|
||||
FeedMmPerSec = 3,
|
||||
Depth = 0.25,
|
||||
PauseBefore = true,
|
||||
PauseMessage = "Change tool",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cut config a polyline of the given layer should use, or null
|
||||
/// if the layer is non-cutting (<see cref="LayerType.Display"/>) and should be skipped.
|
||||
/// </summary>
|
||||
public LayerCutConfig ConfigFor(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
{
|
||||
case LayerType.Scribe:
|
||||
return Engrave;
|
||||
case LayerType.Cut:
|
||||
case LayerType.Leadin:
|
||||
case LayerType.Leadout:
|
||||
return Cut;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Ports;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
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)"/>.
|
||||
///
|
||||
/// Geometry is split by <see cref="OpenNest.CNC.LayerType"/> into an engrave pass
|
||||
/// (Scribe) and a cut pass (Cut), each with its own feed/depth from <see cref="Config"/>.
|
||||
/// The cut pass pauses by default so the operator can swap/adjust the tool.
|
||||
/// </summary>
|
||||
public sealed class GravographISPostProcessor : IConfigurablePostProcessor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
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 GravographISPostConfig Config { get; }
|
||||
|
||||
object IConfigurablePostProcessor.Config => Config;
|
||||
|
||||
public GravographISPostProcessor()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
Config =
|
||||
JsonSerializer.Deserialize<GravographISPostConfig>(json, JsonOptions)
|
||||
?? new GravographISPostConfig();
|
||||
}
|
||||
else
|
||||
{
|
||||
Config = new GravographISPostConfig();
|
||||
SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public GravographISPostProcessor(GravographISPostConfig config)
|
||||
{
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
|
||||
public void SaveConfig()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
var json = JsonSerializer.Serialize(Config, JsonOptions);
|
||||
File.WriteAllText(configPath, json);
|
||||
}
|
||||
|
||||
private static string GetConfigPath()
|
||||
{
|
||||
var assemblyPath = typeof(GravographISPostProcessor).Assembly.Location;
|
||||
var dir = Path.GetDirectoryName(assemblyPath);
|
||||
var name = Path.GetFileNameWithoutExtension(assemblyPath);
|
||||
return Path.Combine(dir, name + ".json");
|
||||
}
|
||||
|
||||
public void Post(Nest nest, Stream outputStream)
|
||||
{
|
||||
if (nest == null)
|
||||
throw new ArgumentNullException(nameof(nest));
|
||||
if (outputStream == null)
|
||||
throw new ArgumentNullException(nameof(outputStream));
|
||||
|
||||
var passes = BuildPasses(Extractor.ExtractLayered(nest));
|
||||
new GravographISWriter(WriterOptions).Write(passes, outputStream);
|
||||
}
|
||||
|
||||
public void Post(Nest nest, string outputFile)
|
||||
{
|
||||
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
|
||||
Post(nest, fs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups layer-tagged polylines into ordered tool passes: engrave (Scribe)
|
||||
/// first, then cut. Each group is stitch/reverse-optimized independently.
|
||||
/// Geometry whose layer maps to no config (Display) is skipped. When only one
|
||||
/// group is present, a single pass is returned (and so the writer emits no pause).
|
||||
/// </summary>
|
||||
public IReadOnlyList<GravographPass> BuildPasses(IEnumerable<LayeredPolyline> polylines)
|
||||
{
|
||||
if (polylines == null)
|
||||
throw new ArgumentNullException(nameof(polylines));
|
||||
|
||||
var engrave = new List<IReadOnlyList<Vector>>();
|
||||
var cut = new List<IReadOnlyList<Vector>>();
|
||||
|
||||
foreach (var poly in polylines)
|
||||
{
|
||||
if (poly == null)
|
||||
continue;
|
||||
var block = Config.ConfigFor(poly.Layer);
|
||||
if (block == null)
|
||||
continue; // non-cutting (Display) geometry
|
||||
if (ReferenceEquals(block, Config.Engrave))
|
||||
engrave.Add(poly.Points);
|
||||
else
|
||||
cut.Add(poly.Points);
|
||||
}
|
||||
|
||||
var passes = new List<GravographPass>();
|
||||
if (engrave.Count > 0)
|
||||
passes.Add(MakePass(Config.Engrave, engrave));
|
||||
if (cut.Count > 0)
|
||||
passes.Add(MakePass(Config.Cut, cut));
|
||||
|
||||
return passes;
|
||||
}
|
||||
|
||||
private GravographPass MakePass(LayerCutConfig block, List<IReadOnlyList<Vector>> polylines)
|
||||
{
|
||||
return new GravographPass
|
||||
{
|
||||
Polylines = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse),
|
||||
FeedMmPerSec = block.FeedMmPerSec,
|
||||
DepthInches = block.Depth,
|
||||
PauseBefore = block.PauseBefore,
|
||||
PauseMessage = block.PauseMessage ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/// <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,681 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// One tool pass: a run of polylines cut at a single feed/depth, optionally
|
||||
/// preceded by an operator pause (to swap or adjust the tool). The Gravograph
|
||||
/// post builds one pass for engrave and one for cut.
|
||||
/// </summary>
|
||||
public sealed class GravographPass
|
||||
{
|
||||
public IEnumerable<IReadOnlyList<Vector>> Polylines { get; set; }
|
||||
|
||||
public int FeedMmPerSec { get; set; }
|
||||
|
||||
public double DepthInches { get; set; }
|
||||
|
||||
/// <summary>When true, park to origin and prompt the operator before this pass.</summary>
|
||||
public bool PauseBefore { get; set; }
|
||||
|
||||
public string PauseMessage { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <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));
|
||||
|
||||
// A single pass at the configured feed/depth — byte-identical to the
|
||||
// original single-group output (no transitions, no pause).
|
||||
Write(
|
||||
new[]
|
||||
{
|
||||
new GravographPass
|
||||
{
|
||||
Polylines = polylines,
|
||||
FeedMmPerSec = Options.FeedMmPerSec,
|
||||
DepthInches = Options.DepthInches,
|
||||
PauseBefore = false,
|
||||
PauseMessage = "",
|
||||
},
|
||||
},
|
||||
output
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the full byte stream for an ordered list of tool passes. The preamble
|
||||
/// carries the first pass's feed/depth; each later pass emits an inline feed
|
||||
/// (and depth, if changed) and, when <see cref="GravographPass.PauseBefore"/> is
|
||||
/// set, parks to the operator origin and emits an operator pause before cutting.
|
||||
/// </summary>
|
||||
public void Write(IReadOnlyList<GravographPass> passes, Stream output)
|
||||
{
|
||||
if (passes == null)
|
||||
throw new ArgumentNullException(nameof(passes));
|
||||
if (output == null)
|
||||
throw new ArgumentNullException(nameof(output));
|
||||
|
||||
var firstFeed = passes.Count > 0 ? passes[0].FeedMmPerSec : Options.FeedMmPerSec;
|
||||
var firstDepth = passes.Count > 0 ? passes[0].DepthInches : Options.DepthInches;
|
||||
|
||||
var preamble = (byte[])PreambleTemplate.Clone();
|
||||
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
|
||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
|
||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
|
||||
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;
|
||||
var currentFeed = firstFeed;
|
||||
var currentDepth = firstDepth;
|
||||
|
||||
for (var p = 0; p < passes.Count; p++)
|
||||
{
|
||||
var pass = passes[p];
|
||||
|
||||
if (p > 0)
|
||||
{
|
||||
if (pass.PauseBefore)
|
||||
{
|
||||
// Park: lift Z, then rapid (pen-up) back to the operator origin so
|
||||
// the head is clear of the work while the tool is swapped.
|
||||
WriteLiftOnly(output);
|
||||
WriteTravel(
|
||||
output,
|
||||
(byte)'P',
|
||||
(byte)'U',
|
||||
checked(-headX),
|
||||
checked(-headY),
|
||||
ref headX,
|
||||
ref headY,
|
||||
envelopeXSteps,
|
||||
envelopeYSteps,
|
||||
polyIndex
|
||||
);
|
||||
WritePauseCore(output, pass.PauseMessage);
|
||||
}
|
||||
|
||||
if (pass.FeedMmPerSec != currentFeed)
|
||||
{
|
||||
WriteCommand(output, (byte)'V', (byte)'S', (short)pass.FeedMmPerSec);
|
||||
WriteCommand(output, (byte)'V', (byte)'Z', (short)pass.FeedMmPerSec);
|
||||
currentFeed = pass.FeedMmPerSec;
|
||||
}
|
||||
|
||||
if (pass.DepthInches != currentDepth)
|
||||
{
|
||||
WriteCommand(
|
||||
output,
|
||||
(byte)'D',
|
||||
(byte)'Z',
|
||||
DepthInStepsAsInt16(pass.DepthInches)
|
||||
);
|
||||
currentDepth = pass.DepthInches;
|
||||
}
|
||||
}
|
||||
|
||||
if (pass.Polylines == null)
|
||||
continue;
|
||||
|
||||
foreach (var poly in pass.Polylines)
|
||||
{
|
||||
polyIndex++;
|
||||
if (poly == null || poly.Count < 2)
|
||||
continue;
|
||||
|
||||
WritePolyline(
|
||||
output,
|
||||
poly,
|
||||
ref firstPolyline,
|
||||
ref headX,
|
||||
ref headY,
|
||||
envelopeXSteps,
|
||||
envelopeYSteps,
|
||||
polyIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 void WritePolyline(
|
||||
Stream output,
|
||||
IReadOnlyList<Vector> poly,
|
||||
ref bool firstPolyline,
|
||||
ref int headX,
|
||||
ref int headY,
|
||||
int envelopeXSteps,
|
||||
int envelopeYSteps,
|
||||
int polyIndex
|
||||
)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// The operator pause, minus the leading lift/park which the caller emits.
|
||||
// Stops the spindle (MC off), turns off aux, writes the console message, then
|
||||
// restarts the spindle (MC on) so the job resumes when the operator presses start.
|
||||
private static void WritePauseCore(Stream s, string message)
|
||||
{
|
||||
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x00); // motor off
|
||||
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off
|
||||
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFA); // aux off
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x00); // begin message
|
||||
WriteMessagePackets(s, message);
|
||||
WriteCommandRaw(s, (byte)'N', (byte)'R', 0x00, 0x01); // line terminator
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x01); // end message
|
||||
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x01); // motor on
|
||||
}
|
||||
|
||||
// Console label packets carry two ASCII chars each; an odd-length message is
|
||||
// space-padded to a whole number of packets so widths stay 2 bytes.
|
||||
private static void WriteMessagePackets(Stream s, string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message))
|
||||
return;
|
||||
|
||||
var chars = Encoding.ASCII.GetBytes(message);
|
||||
for (var i = 0; i < chars.Length; i += 2)
|
||||
{
|
||||
var c0 = chars[i];
|
||||
var c1 = (i + 1 < chars.Length) ? chars[i + 1] : (byte)0x20;
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', c0, c1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCommand(Stream s, byte c0, byte c1, short value)
|
||||
{
|
||||
WriteCommandRaw(s, c0, c1, (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF));
|
||||
}
|
||||
|
||||
private static void WriteCommandRaw(Stream s, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
s.WriteByte(0xFF);
|
||||
s.WriteByte(0xFD);
|
||||
s.WriteByte(c0);
|
||||
s.WriteByte(c1);
|
||||
s.WriteByte(hi);
|
||||
s.WriteByte(lo);
|
||||
}
|
||||
|
||||
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 static short DepthInStepsAsInt16(double depthInches)
|
||||
{
|
||||
var steps = (long)
|
||||
System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||
if (steps < short.MinValue || steps > short.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(depthInches),
|
||||
$"Depth {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,259 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// A polyline together with the <see cref="LayerType"/> of the moves that
|
||||
/// produced it. The Gravograph post groups by layer to emit separate engrave
|
||||
/// and cut passes (with a tool-change pause between them).
|
||||
/// </summary>
|
||||
public sealed class LayeredPolyline
|
||||
{
|
||||
public LayeredPolyline(List<Vector> points, LayerType layer)
|
||||
{
|
||||
Points = points;
|
||||
Layer = layer;
|
||||
}
|
||||
|
||||
public List<Vector> Points { get; }
|
||||
|
||||
public LayerType Layer { get; }
|
||||
}
|
||||
|
||||
/// <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 at <see cref="LayerType"/> changes, 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). Layer information is dropped;
|
||||
/// use <see cref="ExtractLayered(Nest)"/> to keep it.
|
||||
/// </summary>
|
||||
public List<List<Vector>> Extract(Nest nest)
|
||||
{
|
||||
return ExtractLayered(nest).Select(p => p.Points).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines for a single part without layer information.
|
||||
/// </summary>
|
||||
public List<List<Vector>> ExtractPart(Part part)
|
||||
{
|
||||
return ExtractPartLayered(part).Select(p => p.Points).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts layer-tagged polylines from every non-cutoff part in every plate,
|
||||
/// in plate coordinates (inches). Each polyline is layer-uniform.
|
||||
/// </summary>
|
||||
public List<LayeredPolyline> ExtractLayered(Nest nest)
|
||||
{
|
||||
if (nest == null)
|
||||
throw new ArgumentNullException(nameof(nest));
|
||||
|
||||
var result = new List<LayeredPolyline>();
|
||||
|
||||
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 layer-tagged 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<LayeredPolyline> ExtractPartLayered(Part part)
|
||||
{
|
||||
var list = new List<LayeredPolyline>();
|
||||
ExtractPart(part, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private void ExtractPart(Part part, List<LayeredPolyline> 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;
|
||||
var currentLayer = LayerType.Cut;
|
||||
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code is Motion m && m.Suppressed)
|
||||
continue;
|
||||
|
||||
switch (code)
|
||||
{
|
||||
case RapidMove rapid:
|
||||
{
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
pos = rapid.EndPoint;
|
||||
break;
|
||||
}
|
||||
|
||||
case LinearMove linear:
|
||||
{
|
||||
StartOrSplit(
|
||||
sink,
|
||||
ref current,
|
||||
ref currentLayer,
|
||||
linear.Layer,
|
||||
pos + offset
|
||||
);
|
||||
var end = linear.EndPoint;
|
||||
current.Add(end + offset);
|
||||
pos = end;
|
||||
break;
|
||||
}
|
||||
|
||||
case ArcMove arc:
|
||||
{
|
||||
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
|
||||
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
|
||||
pos = arc.EndPoint;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
}
|
||||
|
||||
// Ensures `current` is an open polyline whose layer matches `moveLayer`,
|
||||
// seeded at `seed` (the current pen position). When the layer changes
|
||||
// mid-chain the previous polyline is flushed and a new one begins at the
|
||||
// shared seam vertex so engrave and cut passes stay geometrically continuous.
|
||||
private static void StartOrSplit(
|
||||
List<LayeredPolyline> sink,
|
||||
ref List<Vector> current,
|
||||
ref LayerType currentLayer,
|
||||
LayerType moveLayer,
|
||||
Vector seed
|
||||
)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { seed };
|
||||
currentLayer = moveLayer;
|
||||
}
|
||||
else if (moveLayer != currentLayer)
|
||||
{
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
current = new List<Vector> { seed };
|
||||
currentLayer = moveLayer;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FlushCurrent(
|
||||
List<LayeredPolyline> sink,
|
||||
ref List<Vector> current,
|
||||
LayerType layer
|
||||
)
|
||||
{
|
||||
if (current != null && current.Count >= 2)
|
||||
sink.Add(new LayeredPolyline(current, layer));
|
||||
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</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)\net8.0-windows\Posts\</PostsDir>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PostsDir)" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,211 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user