Files
OpenNest/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
T
ajandClaude Opus 5.5 5a627e5cc3 Merge branch 'claude/shared-post-contours'
Master added head-raise markers, common-line shared cuts and remainders to
the CI Fiber's own contour extractor, which this branch replaced with Core's
shared PostContours. Those now live in the shared model: PostContour carries
Raised, IsCommonLine and RemainderWinding, and Classify gives remainders the
perimeter macro on their perimeter's side and shared cuts no kerf side.
ContourWinding uses PortableMath for trig, as master's Core guard requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:10:16 -05:00

281 lines
11 KiB
C#

using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// A posted part sub-program and where one part's program frame puts its origin.
/// </summary>
/// <param name="Number">Posted sub-program number.</param>
/// <param name="Program">The normalized, absolute program the sub-program is written from.</param>
/// <param name="Corner">The sub-program origin in the part's program frame.</param>
public sealed record PartSubprogram(int Number, Program Program, Vector Corner);
/// <summary>
/// Writes a Cincinnati-format part sub-program definition.
/// Each sub-program contains the complete cutting sequence for one unique posted part
/// toolpath, 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 IReadOnlyDictionary<Program, int> _holeSubprograms;
public CincinnatiPartSubprogramWriter(
CincinnatiPostConfig config,
IReadOnlyDictionary<Program, 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 must be in absolute mode with coordinates normalized to origin (0,0).
/// With <paramref name="holes"/> every contour is a hole, as in a hole sub-program.
/// </summary>
public void Write(
TextWriter w,
Program normalizedProgram,
string drawingName,
int subNumber,
string cutLibrary,
string etchLibrary,
double sheetDiagonal,
bool holes = false
)
{
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);
var exterior = FeatureUtils.FindExterior(ordered);
var kerf = FeatureUtils.KerfSides(ordered, holes);
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 (FeatureUtils.HoleCall(codes) is { } holeCall)
{
CincinnatiHoleCallWriter.Write(
w, holeCall, Vector.Zero, i, isLastFeature, _config, _fmt, _holeSubprograms
);
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,
IsExteriorFeature = exterior[i],
Kerf = kerf[i],
IsEtch = isEtch,
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal,
RapidDistance = isLastFeature
? null
: FeatureUtils.EndPoint(codes).DistanceTo(FeatureUtils.StartPoint(ordered[i + 1].codes)),
NextRapidRaised = !isLastFeature && FeatureUtils.IsRaised(ordered[i + 1].codes),
};
_featureWriter.Write(w, ctx);
}
w.WriteLine($"M99 (END OF {drawingName})");
}
/// <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) => FeatureUtils.InsertLeadingRapid(pgm.Codes);
/// <summary>
/// Scans all plates and assigns each non-cutoff part the sub-program for its posted
/// toolpath: parts share a sub-program only when their normalized programs, lead-ins
/// included, post identically under the same drawing name. Part programs are copied,
/// never modified.
/// </summary>
internal static (
Dictionary<Part, PartSubprogram> mapping,
List<(int subNum, string name, Program program)> entries
) BuildRegistry(IEnumerable<Plate> plates, int startNumber)
{
var mapping = new Dictionary<Part, PartSubprogram>(ReferenceEqualityComparer.Instance);
var entries = new List<(int, string, Program)>();
var contentIndex = new Dictionary<string, (int subNum, Program program)>();
var nextSubNum = startNumber;
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff)
continue;
var pgm = PostedCopy(FeatureUtils.PostedProgram(part));
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);
var name = part.BaseDrawing.Name;
var key = name + "\n" + ProgramToCanonical(pgm);
if (!contentIndex.TryGetValue(key, out var entry))
{
entry = (nextSubNum++, pgm);
contentIndex[key] = entry;
entries.Add((entry.subNum, name, pgm));
}
mapping[part] = new PartSubprogram(entry.subNum, entry.program, bbox.Location);
}
}
return (mapping, entries);
}
/// <summary>
/// The absolute copy a hole sub-program is written from: suppressed moves left uncut and
/// a leading rapid ensured. <see cref="Write"/> then puts its etch features first.
/// </summary>
internal static Program PostedHole(Program hole)
{
var absolute = PostedCopy(hole);
EnsureLeadingRapid(absolute);
return absolute;
}
// An absolute copy with suppressed moves left uncut, as the post writes it.
private static Program PostedCopy(Program program)
{
var copy = FeatureUtils.ToAbsoluteCopy(program);
var codes = FeatureUtils.RemoveSuppressed(copy.Codes);
copy.Codes.Clear();
copy.Codes.AddRange(codes);
return copy;
}
/// <summary>
/// Builds a nest-level registry of unique hole sub-programs from every hole call in
/// the plates' parts and in the part sub-program copies. Calls are keyed by their
/// program reference, because hole call ids are only unique within one drawing's
/// program; identical hole geometry shares one sub-program. Entries are absolute-mode
/// copies, so the caller's (incremental) hole programs are never modified.
/// </summary>
internal static (
Dictionary<Program, int> mapping,
List<(int subNum, Program program)> entries
) BuildHoleRegistry(
IEnumerable<Plate> plates,
IEnumerable<Program> partSubprograms,
int startNumber
)
{
var mapping = new Dictionary<Program, int>(ReferenceEqualityComparer.Instance);
var entries = new List<(int, Program)>();
var contentIndex = new Dictionary<string, int>();
var nextSubNum = startNumber;
void Register(Program owner)
{
foreach (var code in owner.Codes)
{
if (code is not SubProgramCall { Program: not null } call)
continue;
if (mapping.ContainsKey(call.Program) || !FeatureUtils.HasCut(call.Program))
continue;
var absolute = PostedHole(call.Program);
var canonical = ProgramToCanonical(absolute);
if (!contentIndex.TryGetValue(canonical, out var subNum))
{
subNum = nextSubNum++;
contentIndex[canonical] = subNum;
entries.Add((subNum, absolute));
}
mapping[call.Program] = subNum;
}
}
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (!part.BaseDrawing.IsCutOff)
Register(FeatureUtils.PostedProgram(part));
}
}
if (partSubprograms != null)
{
foreach (var program in partSubprograms)
Register(program);
}
return (mapping, entries);
}
private static string ProgramToCanonical(Program pgm)
{
var sb = new StringBuilder();
foreach (var code in pgm.Codes)
{
if (code is LinearMove lm)
sb.Append(
CultureInfo.InvariantCulture,
$"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}"
);
else if (code is ArcMove am)
sb.Append(
CultureInfo.InvariantCulture,
$"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(CultureInfo.InvariantCulture, $"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
else if (HeadRaise.IsMarker(code))
sb.Append('H');
else if (code is SubProgramCall { Program: not null } call)
sb.Append(
CultureInfo.InvariantCulture,
$"S{call.Offset.X:F6},{call.Offset.Y:F6}[{ProgramToCanonical(PostedCopy(call.Program))}]"
);
}
return sb.ToString();
}
}