Fix coordinate transforms (translate-only, no rotation), make orchestrator non-destructive (ProcessedPart holds result instead of mutating Part.Program), use readonly structs consistently, add factory mapping and known limitations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
Plate Processor Design — Per-Part Lead-In Assignment & Cut Sequencing
Overview
Add a plate-level orchestrator (PlateProcessor) to OpenNest.Engine that sequences parts across a plate, assigns lead-ins per-part based on approach direction, and plans safe rapid paths between parts. This replaces the current ContourCuttingStrategy usage model where the exit point is derived from the plate corner alone — instead, each part's lead-in pierce point is computed from the actual approach direction (the previous part's last cut point).
The motivation is laser head safety: on a CL-980 fiber laser, head-down rapids are significantly faster than raising the head, but traversing over already-cut areas risks collision with tipped-up slugs. The orchestrator must track cut areas and choose safe rapid paths.
Architecture
Three pipeline stages, wired by a thin orchestrator:
IPartSequencer → ContourCuttingStrategy → IRapidPlanner
↓ ↓ ↓
ordered parts lead-ins applied safe rapid paths
└──────────── PlateProcessor ─────────────┘
All new code lives in OpenNest.Engine/ except the ContourCuttingStrategy signature change and Part.HasManualLeadIns flag which are in OpenNest.Core.
Model Changes
Part (OpenNest.Core)
Add a flag to indicate the user has manually assigned lead-ins to this part:
public bool HasManualLeadIns { get; set; }
When true, the orchestrator skips ContourCuttingStrategy.Apply() for this part and uses the program as-is.
ContourCuttingStrategy (OpenNest.Core)
Change the Apply signature to accept an approach point instead of a plate:
// Before
public Program Apply(Program partProgram, Plate plate)
// After
public CuttingResult Apply(Program partProgram, Vector approachPoint)
Remove GetExitPoint(Plate) — the caller provides the approach point in part-local coordinates.
CuttingResult (OpenNest.Core, namespace OpenNest.CNC.CuttingStrategy)
New readonly struct returned by ContourCuttingStrategy.Apply(). Lives in CNC/CuttingStrategy/CuttingResult.cs:
public readonly struct CuttingResult
{
public Program Program { get; init; }
public Vector LastCutPoint { get; init; }
}
Program: the program with lead-ins/lead-outs applied.LastCutPoint: where the last contour cut ends, in part-local coordinates. The orchestrator transforms this to plate coordinates to compute the approach point for the next part.
Stage 1: IPartSequencer
Interface
namespace OpenNest.Engine
{
public interface IPartSequencer
{
List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate);
}
}
SequencedPart
public readonly struct SequencedPart
{
public Part Part { get; init; }
}
The sequencer only determines cut order. Approach points are computed by the orchestrator as it loops, since each part's approach point depends on the previous part's CuttingResult.LastCutPoint.
Implementations
One class per SequenceMethod. All live in OpenNest.Engine/Sequencing/.
| Class | SequenceMethod | Algorithm |
|---|---|---|
RightSideSequencer |
RightSide | Sort parts by X descending (rightmost first) |
LeftSideSequencer |
LeftSide | Sort parts by X ascending (leftmost first) |
BottomSideSequencer |
BottomSide | Sort parts by Y ascending (bottom first) |
LeastCodeSequencer |
LeastCode | Nearest-neighbor from exit point, then 2-opt improvement |
AdvancedSequencer |
Advanced | Nearest-neighbor with row/column grouping from SequenceParameters |
EdgeStartSequencer |
EdgeStart | Sort by distance from nearest plate edge, closest first |
Directional sequencers (RightSide, LeftSide, BottomSide)
Sort parts by their bounding box center along the relevant axis. Ties broken by the perpendicular axis. These are simple positional sorts — no TSP involved.
LeastCodeSequencer
- Start from the plate exit point.
- Nearest-neighbor greedy: pick the unvisited part whose bounding box center is closest to the current position.
- 2-opt improvement: iterate over the sequence, try swapping pairs. If total travel distance decreases, keep the swap. Repeat until no improvement found (or max iterations).
AdvancedSequencer
Uses SequenceParameters to group parts into rows/columns based on MinDistanceBetweenRowsColumns, then sequences within each group. AlternateRowsColumns and AlternateCutoutsWithinRowColumn control serpentine vs. unidirectional ordering within rows.
EdgeStartSequencer
Sort parts by distance from the nearest plate edge (minimum of distances to all four edges). Parts closest to an edge cut first. Ties broken by nearest-neighbor.
Parameter Flow
Sequencers that need configuration accept it through their constructor:
LeastCodeSequencer(int maxIterations = 100)— max 2-opt iterationsAdvancedSequencer(SequenceParameters parameters)— row/column grouping config- Directional sequencers and
EdgeStartSequencerneed no configuration
Factory
A static PartSequencerFactory.Create(SequenceParameters parameters) method in OpenNest.Engine/Sequencing/ maps parameters.Method to the correct IPartSequencer implementation, passing constructor args as needed. Throws NotSupportedException for RightSideAlt.
Stage 2: ContourCuttingStrategy
Already exists in OpenNest.Core/CNC/CuttingStrategy/. Only the signature and return type change:
Apply(Program partProgram, Plate plate)→Apply(Program partProgram, Vector approachPoint)- Return
CuttingResultinstead ofProgram - Remove
GetExitPoint(Plate)— replaced by theapproachPointparameter - Set
CuttingResult.LastCutPointto the end point of the last contour (perimeter), which is the same as the perimeter's reindexed start point for closed contours
The internal logic (cutout sequencing, contour type detection, normal computation, lead-in/out selection) remains unchanged — only the source of the approach direction changes.
Stage 3: IRapidPlanner
Interface
namespace OpenNest.Engine
{
public interface IRapidPlanner
{
RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas);
}
}
All coordinates are in plate space.
RapidPath
public readonly struct RapidPath
{
public bool HeadUp { get; init; }
public List<Vector> Waypoints { get; init; }
}
HeadUp = true: the post-processor should raise Z before traversing.Waypointsis empty (direct move).HeadUp = false: head-down rapid.Waypointscontains the path (may be empty for a direct move, or contain intermediate points for obstacle avoidance in future implementations).
Implementations
Both live in OpenNest.Engine/RapidPlanning/.
SafeHeightRapidPlanner
Always returns HeadUp = true with empty waypoints. Guaranteed safe, simplest possible implementation.
DirectRapidPlanner
Checks if the straight line from from to to intersects any shape in cutAreas:
- If clear: returns
HeadUp = false, empty waypoints (direct head-down rapid). - If blocked: returns
HeadUp = true, empty waypoints (fall back to safe height).
Uses existing Intersect class from OpenNest.Geometry for line-shape intersection checks.
Future enhancement: obstacle-avoidance pathfinding that routes around cut areas with head down. This is a 2D pathfinding problem (visibility graph or similar) and is out of scope for the initial implementation.
PlateProcessor (Orchestrator)
Lives in OpenNest.Engine/PlateProcessor.cs.
public class PlateProcessor
{
public IPartSequencer Sequencer { get; set; }
public ContourCuttingStrategy CuttingStrategy { get; set; }
public IRapidPlanner RapidPlanner { get; set; }
public PlateResult Process(Plate plate)
{
// 1. Sequence parts
var ordered = Sequencer.Sequence(plate.Parts, plate);
var results = new List<ProcessedPart>();
var cutAreas = new List<Shape>();
var currentPoint = GetExitPoint(plate); // plate-space starting point
foreach (var sequenced in ordered)
{
var part = sequenced.Part;
// 2. Transform approach point from plate space to part-local space
var localApproach = ToPartLocal(currentPoint, part);
// 3. Apply lead-ins (or skip if manual)
CuttingResult cutResult;
if (!part.HasManualLeadIns && CuttingStrategy != null)
{
cutResult = CuttingStrategy.Apply(part.Program, localApproach);
}
else
{
cutResult = new CuttingResult
{
Program = part.Program,
LastCutPoint = GetProgramEndPoint(part.Program)
};
}
// 4. Get pierce point in plate space for rapid planning
var piercePoint = ToPlateSpace(GetProgramStartPoint(cutResult.Program), part);
// 5. Plan rapid from current position to this part's pierce point
var rapid = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
results.Add(new ProcessedPart
{
Part = part,
ProcessedProgram = cutResult.Program,
RapidPath = rapid
});
// 6. Track cut area (part perimeter in plate space) for future rapid planning
cutAreas.Add(GetPartPerimeter(part));
// 7. Update current position to this part's last cut point (plate space)
currentPoint = ToPlateSpace(cutResult.LastCutPoint, part);
}
return new PlateResult { Parts = results };
}
}
Coordinate Transforms
Part programs already have rotation baked in (the Part constructor calls Program.Rotate()). Part.Location is a pure translation offset. Therefore, coordinate transforms are simple vector addition/subtraction — no rotation involved:
ToPartLocal(Vector platePoint, Part part):platePoint - part.LocationToPlateSpace(Vector localPoint, Part part):localPoint + part.Location
This matches how Part.Intersects converts to plate space (offset by Location only).
Helper Methods
GetExitPoint(Plate): moved fromContourCuttingStrategy— returns the plate corner opposite the quadrant origin.GetProgramStartPoint(Program): firstRapidMoveposition in the program (the pierce point).GetProgramEndPoint(Program): last move's end position in the program.GetPartPerimeter(Part): converts the part's program to geometry, buildsShapeProfile, returns the perimeterShapeoffset bypart.Location(translation only — rotation is already baked in).
PlateResult
public class PlateResult
{
public List<ProcessedPart> Parts { get; init; }
}
public readonly struct ProcessedPart
{
public Part Part { get; init; }
public Program ProcessedProgram { get; init; } // with lead-ins applied (original Part.Program unchanged)
public RapidPath RapidPath { get; init; }
}
The orchestrator is non-destructive — it does not mutate Part.Program (which has a private set). Instead, the processed program with lead-ins is stored in ProcessedPart.ProcessedProgram. The post-processor consumes PlateResult to generate machine-specific G-code, using ProcessedProgram for cut paths and RapidPath.HeadUp for Z-axis commands.
Note: the caller is responsible for configuring CuttingStrategy.Parameters (the CuttingParameters instance with lead-in/lead-out settings) before calling Process(). Parameters typically vary by material/thickness.
File Structure
OpenNest.Core/
├── Part.cs # add HasManualLeadIns property
└── CNC/CuttingStrategy/
├── ContourCuttingStrategy.cs # signature change + CuttingResult return
└── CuttingResult.cs # new struct
OpenNest.Engine/
├── PlateProcessor.cs # orchestrator
├── Sequencing/
│ ├── IPartSequencer.cs
│ ├── SequencedPart.cs # removed ApproachPoint (orchestrator tracks it)
│ ├── RightSideSequencer.cs
│ ├── LeftSideSequencer.cs
│ ├── BottomSideSequencer.cs
│ ├── LeastCodeSequencer.cs
│ ├── AdvancedSequencer.cs
│ └── EdgeStartSequencer.cs
└── RapidPlanning/
├── IRapidPlanner.cs
├── RapidPath.cs
├── SafeHeightRapidPlanner.cs
└── DirectRapidPlanner.cs
Known Limitations
DirectRapidPlannerchecks edge intersection only — a rapid that passes entirely through the interior of a concave cut part without crossing a perimeter edge would not be detected. Unlikely in practice (parts have material around them) but worth noting.LeastCodeSequenceruses bounding box centers for nearest-neighbor distance. For highly irregular parts, closest-point-on-perimeter could yield better results, but the simpler approach is sufficient for the initial implementation.
Out of Scope
- Obstacle-avoidance pathfinding for head-down rapids (future enhancement to
DirectRapidPlanner) - UI integration (selecting sequencing method, configuring rapid planner)
- Post-processor changes to consume
PlateResult— interim state:PlateResultis returned fromProcess()and the caller bridges it to the existingIPostProcessorinterface RightSideAltsequencer (unclear how it differs fromRightSide— defer until behavior is defined;PlateProcessorshould throwNotSupportedExceptionif selected)- Serialization of
PlateResult