docs: add plate processor design spec for per-part lead-in assignment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
325
docs/superpowers/specs/2026-03-15-plate-processor-design.md
Normal file
325
docs/superpowers/specs/2026-03-15-plate-processor-design.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# 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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
// 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`:
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Engine
|
||||
{
|
||||
public interface IPartSequencer
|
||||
{
|
||||
List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SequencedPart
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
1. Start from the plate exit point.
|
||||
2. Nearest-neighbor greedy: pick the unvisited part whose bounding box center is closest to the current position.
|
||||
3. 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 iterations
|
||||
- `AdvancedSequencer(SequenceParameters parameters)` — row/column grouping config
|
||||
- Directional sequencers and `EdgeStartSequencer` need no configuration
|
||||
|
||||
## Stage 2: ContourCuttingStrategy
|
||||
|
||||
Already exists in `OpenNest.Core/CNC/CuttingStrategy/`. Only the signature and return type change:
|
||||
|
||||
1. `Apply(Program partProgram, Plate plate)` → `Apply(Program partProgram, Vector approachPoint)`
|
||||
2. Return `CuttingResult` instead of `Program`
|
||||
3. Remove `GetExitPoint(Plate)` — replaced by the `approachPoint` parameter
|
||||
4. Set `CuttingResult.LastCutPoint` to 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
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Engine
|
||||
{
|
||||
public interface IRapidPlanner
|
||||
{
|
||||
RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All coordinates are in plate space.
|
||||
|
||||
### RapidPath
|
||||
|
||||
```csharp
|
||||
public struct RapidPath
|
||||
{
|
||||
public bool HeadUp { get; init; }
|
||||
public List<Vector> Waypoints { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
- `HeadUp = true`: the post-processor should raise Z before traversing. `Waypoints` is empty (direct move).
|
||||
- `HeadUp = false`: head-down rapid. `Waypoints` contains 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`.
|
||||
|
||||
```csharp
|
||||
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.Location`
|
||||
- `ToPlateSpace(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 from `ContourCuttingStrategy` — returns the plate corner opposite the quadrant origin.
|
||||
- `GetProgramStartPoint(Program)`: first `RapidMove` position 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, builds `ShapeProfile`, returns the perimeter `Shape` offset by `part.Location` (translation only — rotation is already baked in).
|
||||
|
||||
### PlateResult
|
||||
|
||||
```csharp
|
||||
public class PlateResult
|
||||
{
|
||||
public List<ProcessedPart> Parts { get; init; }
|
||||
}
|
||||
|
||||
public 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
|
||||
|
||||
- `DirectRapidPlanner` checks 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.
|
||||
- `LeastCodeSequencer` uses 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: `PlateResult` is returned from `Process()` and the caller bridges it to the existing `IPostProcessor` interface
|
||||
- `RightSideAlt` sequencer (unclear how it differs from `RightSide` — defer until behavior is defined; `PlateProcessor` should throw `NotSupportedException` if selected)
|
||||
- Serialization of `PlateResult`
|
||||
Reference in New Issue
Block a user