Compare commits

...

72 Commits

Author SHA1 Message Date
aj 48d4220199 docs: add lead-in assignment UI design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:29:32 -04:00
aj ff496e4efe fix(engine): track multiple free rectangles in strip remnant filling
ComputeRemainderWithin only returned the larger of two possible free
rectangles, permanently losing usable area on the other axis after each
remainder item was placed. Replace the single shrinking box with a list
of free rectangles using guillotine cuts so both sub-areas remain
available for subsequent items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:28:25 -04:00
aj 1a3e18795b fix(ui): reverse sequence order so cutting starts near origin
The sequencer returns parts ordered from exit point inward. Reverse
so part 1 is nearest the origin and cutting works outward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:58:35 -04:00
aj 507dff95b8 fix(ui): use LeastCode sequencer and fix AddRange double-enumeration
Advanced sequencer with default 0.25 MinDistanceBetweenRowsColumns
puts every part in its own row, degenerating to a Y-sort. Switch to
LeastCode (nearest-neighbor + 2-opt) for visible results.

Also replace AddRange(linq) with foreach+Add to avoid ObservableList
AddRange re-enumerating a deferred LINQ query for event firing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:54:35 -04:00
aj a1f32eda79 feat(ui): wire Plate→Sequence menu to PartSequencerFactory
Replace old SequenceByNearest with PartSequencerFactory using default
SequenceParameters (Advanced method with serpentine row grouping).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:50:03 -04:00
aj 545d031ee7 feat: add PlateProcessor for per-part lead-in assignment and cut sequencing
Three-stage pipeline: IPartSequencer → ContourCuttingStrategy → IRapidPlanner
wired by PlateProcessor. 6 sequencing strategies, 2 rapid planners, 31 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:47:16 -04:00
aj 7d198f837c feat: add PlateProcessor orchestrator 2026-03-16 00:44:25 -04:00
aj 5948dc9cae feat: add PlateResult and ProcessedPart 2026-03-16 00:43:17 -04:00
aj 6dffd8f5ad feat: add DirectRapidPlanner with line-shape intersection check 2026-03-16 00:43:06 -04:00
aj 29b2572f9a feat: add IRapidPlanner, RapidPath, and SafeHeightRapidPlanner 2026-03-16 00:39:34 -04:00
aj c1e21abd45 feat: add PartSequencerFactory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:36:41 -04:00
aj edc81ae45e feat: add AdvancedSequencer with row grouping and serpentine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:36:07 -04:00
aj 7edf6ee843 feat: add LeastCodeSequencer with nearest-neighbor and 2-opt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:35:19 -04:00
aj f568308d1a feat: add EdgeStartSequencer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:32:35 -04:00
aj d0351ab765 feat: add directional part sequencers (RightSide, LeftSide, BottomSide)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:27:57 -04:00
aj 4f8febde23 feat: add IPartSequencer interface and SequencedPart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:27:02 -04:00
aj 00940d1b6e chore: rename test project to OpenNest.Tests
Renamed OpenNest.Engine.Tests → OpenNest.Tests (directory, .csproj,
namespaces in all .cs files). Added OpenNest.IO project reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:25:22 -04:00
aj 1757e9e01d refactor: change ContourCuttingStrategy.Apply to accept approachPoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:23:53 -04:00
aj 62140789a7 feat: add Part.HasManualLeadIns flag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:23:21 -04:00
aj ad877383ce feat: add CuttingResult struct
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:22:50 -04:00
aj b49cdc3e55 chore: add shared test helpers for Engine tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:21:26 -04:00
aj 1e093a8413 chore: add OpenNest.Engine.Tests xUnit project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:21:07 -04:00
aj 79c6ec340c docs: add plate processor implementation plan
16 tasks covering test infrastructure, core model changes, part sequencing
(6 strategies + factory), rapid planning (2 strategies), and the PlateProcessor
orchestrator. TDD approach with xUnit tests for each component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:17:37 -04:00
aj 37c76a720d docs: address spec review feedback for plate processor design
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>
2026-03-15 23:55:16 -04:00
aj c06758a2bd docs: add plate processor design spec for per-part lead-in assignment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:52:42 -04:00
aj 026227848b docs: add plans for ML angle pruning, fill-exact, and helper decomposition
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:06:12 -04:00
aj 5cd2875b35 chore(ui): regenerate MainForm designer file
Visual Studio re-serialized the designer — removes `this.` prefixes,
modernizes event handler syntax, trims trailing whitespace in resx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:06:04 -04:00
aj 5e346270c6 fix: delegate Fill(groupParts) and PackArea to DefaultNestEngine
StripNestEngine only overrode Fill(NestItem), so ActionClone.Fill
and Pack operations fell through to the empty base class defaults.
Now all virtual methods delegate to DefaultNestEngine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:43:42 -04:00
aj 5c79fbe73d feat(ui): add Auto Nest button to toolstrip next to engine selector
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:36:54 -04:00
aj 4e747a8e6c fix: show strip + remnant parts together during progress updates
Wrap IProgress with AccumulatingProgress so remnant fills prepend
previously placed strip parts to each report. The UI now shows the
full picture (red + purple) instead of replacing strip parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:31:11 -04:00
aj 310165db02 fix: add quantity deduction and progress reporting to StripNestEngine
Nest() now deducts placed counts from input NestItem.Quantity so the
UI loop doesn't create extra plates. All inner DefaultNestEngine.Fill
calls forward the IProgress parameter for live progress updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:26:29 -04:00
aj 48be4d5d46 feat: add virtual Nest method to NestEngineBase for polymorphic auto-nest
The auto-nest code paths (MainForm, MCP, Console) now call
engine.Nest(items, progress, token) instead of manually orchestrating
sequential fill+pack. The default implementation in NestEngineBase
does sequential FillExact+PackArea. StripNestEngine overrides with
its strip strategy. This makes the engine dropdown actually work.

Also consolidates ComputeRemainderWithin into NestEngineBase,
removing duplicates from MainForm and StripNestEngine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:16:08 -04:00
aj bb703ef8eb feat(ui): wire strip engine into UI auto-nest flow
When Strip is selected in the engine dropdown, RunAutoNest_Click
calls StripNestEngine.Nest() instead of sequential FillExact+Pack.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:09:29 -04:00
aj 7462d1bdca feat(ui): add engine selector dropdown to main toolstrip
ToolStripComboBox populated from NestEngineRegistry.AvailableEngines.
Changing selection sets NestEngineRegistry.ActiveEngineName globally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:01:06 -04:00
aj cd85857816 feat: integrate StripNestEngine into autonest_plate MCP tool
Runs strip and sequential strategies in competition, picks the
denser result. Reports scores for both strategies in output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:40:21 -04:00
aj 4d80710b48 feat: register StripNestEngine in NestEngineRegistry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:39:25 -04:00
aj 42d404577b feat: add StripNestEngine with strip-based multi-drawing nesting
New NestEngineBase subclass that dedicates a tight strip to the
largest-area drawing and fills the remnant with remaining drawings.
Tries both bottom and left orientations, uses a shrink loop to find
the tightest strip, and picks the denser result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:39:01 -04:00
aj 81a57dc470 docs: update CLAUDE.md for abstract nest engine architecture
Document NestEngineBase hierarchy, NestEngineRegistry, and plugin
loading in the Engine section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:12:53 -04:00
aj bd3984037c refactor: migrate WinForms callsites to NestEngineRegistry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:11:11 -04:00
aj 01283d2b18 refactor: migrate Console Program to NestEngineRegistry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:10:10 -04:00
aj a26ab2ba28 refactor: migrate NestingTools to NestEngineRegistry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:09:46 -04:00
aj 4baeb57e84 feat: add NestEngineInfo and NestEngineRegistry with plugin loading
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:07:59 -04:00
aj 1bcfe5d031 feat: add NestEngineBase abstract class, rename NestEngine to DefaultNestEngine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:06:28 -04:00
aj 1d1cf41ba0 docs: update strip nester plan for abstract engine architecture
StripNester becomes StripNestEngine extending NestEngineBase.
Uses DefaultNestEngine internally via composition.
Registered in NestEngineRegistry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:00:42 -04:00
aj 9bd262dec0 docs: add abstract nest engine implementation plan
9 tasks across 4 chunks: NestEngineBase + DefaultNestEngine,
NestEngineRegistry + NestEngineInfo, callsite migration (16 sites),
verification and docs update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:59:51 -04:00
aj 068de63e83 docs: address spec review feedback for abstract nest engine
Fix MainForm callsite descriptions, clarify default implementations
return empty lists, make FillExact non-virtual, document PackArea
signature refactor, add AutoNester scope note, specify error handling
for plugin loading, document thread safety and instance lifetime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:32:35 -04:00
aj 6c2810ef80 docs: add abstract nest engine design spec
Pluggable engine architecture with NestEngineBase, DefaultNestEngine,
registry with plugin loading, and global engine switching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:29:29 -04:00
aj 7b01524934 docs: add strip nester implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:53:11 -04:00
aj 69da8c4632 docs: add strip nester design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:40:09 -04:00
aj 56a298d95c docs: update CLAUDE.md with current architecture
Reflects ~120 commits of changes: new projects (Console, Gpu, Training),
NFP best-fit pipeline, ML angle prediction, Compactor, CuttingStrategy,
JSON nest format, async progress/cancellation, and Helper decomposition
into focused classes (Intersect, ShapeBuilder, GeometryOptimizer,
SpatialQuery, PartGeometry, Rounding).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:56:35 -04:00
aj 93bf15c27f chore: remove redundant using in Rounding.cs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:49:53 -04:00
aj e017723318 refactor: remove empty Helper class
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:46:36 -04:00
aj 13b01240b1 refactor: extract SpatialQuery from Helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:46:14 -04:00
aj 2881815c7a refactor: extract PartGeometry from Helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:44:17 -04:00
aj 84d3f90549 refactor: extract Intersect from Helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:43:12 -04:00
aj 7c4eac5460 refactor: extract ShapeBuilder from Helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:41:40 -04:00
aj be318bc1c1 refactor: extract GeometryOptimizer from Helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:39:52 -04:00
aj 09cdb98dfc refactor: extract Rounding from Helper to OpenNest.Math
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:38:23 -04:00
aj 823320e982 fix(engine): use reverse-gap check in Compactor to handle irregular shapes
The forward bounding-box gap check (gap < 0) incorrectly skipped obstacles
for irregular shapes like SULLYS-003 whose narrow handle extends past an
adjacent part's BB edge while the wide body still needs contact detection.
Replaced with a reverse-direction gap check that only skips obstacles the
moving part has entirely cleared. Also fixed edge distance check to prevent
overshooting the work area boundary when already at the limit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:29:29 -04:00
aj 289a2044a6 fix(ui): mark LayoutParts dirty after PushSelected so paths rebuild
Moving BasePart locations via Compactor.Push bypassed LayoutPart.Offset
which sets IsDirty. Without it, graphics paths were stale until a zoom
triggered a full rebuild.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:20:18 -04:00
aj 7508fbf715 refactor(engine): delegate PlateView.PushSelected to Compactor and add iterative compaction
PushSelected now calls Compactor.Push instead of duplicating the push
logic. Compactor.Push moves parts as a group (single min distance) to
preserve grid layouts. Compact tries both left-first and down-first
orderings, iterating up to 20 times until movement drops below
threshold, and keeps whichever ordering traveled further.

Also includes a cancellation check in FillWithProgress to avoid
accepting parts after the user stops a nest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:16:35 -04:00
aj 4525be302c fix(engine): compute remainder from just-placed parts within current work area
ComputeRemainderStrip used the bounding box of ALL plate parts against
the full plate, missing large interior gaps between drawing groups.
Now computes remainder within the current work area based on only the
parts that were just placed. This lets subsequent drawings fill the
gap between previous drawing groups instead of being forced into a
tiny strip at the plate edge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:43:33 -04:00
aj 2042c7d3f2 perf(engine): cap strip-mode pair candidates at 100 (sorted by utilization)
Strip mode was adding thousands of candidates (7600+) when the work area
was narrow. Now caps at 100 total, sorted by utilization descending so
the best candidates are tried first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:35:30 -04:00
aj 2af02096e0 fix(engine): pass progress through FillExact binary search iterations
The binary search was passing null for progress, so the NestProgressForm
showed all dashes during the entire search (potentially minutes). Now
each iteration reports progress — the user sees phases, part counts, and
density updating as the search runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:30:39 -04:00
aj 744062152e feat(engine): optimize FillExact with angle pruning and tight search range
- Track productive angles across Fill calls; subsequent fills skip
  angles that never produced results (knownGoodAngles)
- Binary search uses utilization-based range estimates (70%-25%)
  instead of starting from the full work area dimension
- Quick bounding-box capacity check skips binary search entirely
  when the plate can't fit more than the requested quantity
- Use full Fill (not rect-only) for binary search iterations so
  the search benefits from pairs/linear strategies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:21:28 -04:00
aj ef12cf2966 fix(engine): Compactor treats pushed parts as obstacles for subsequent pushes
Previously each moving part only checked against the original stationary
set. Parts pushed earlier in the loop were invisible to later parts,
causing overlaps (utilization > 100%). Now each pushed part is added to
the obstacle set so subsequent parts collide correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:54:25 -04:00
aj 56592f909a feat(mcp): use FillExact + Compactor in autonest_plate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:43:41 -04:00
aj 521ada17cc feat(console): use FillExact + Compactor in --autonest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:43:38 -04:00
aj 2bde2545f4 feat(ui): use FillExact + Compactor in AutoNest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:43:32 -04:00
aj 00ee205b44 feat(engine): add Compactor for post-fill gravity compaction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:41:29 -04:00
aj 28fb1a1a67 feat(engine): add FillExact method for exact-quantity nesting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:41:21 -04:00
aj 9a17fe97d3 feat(engine): add BinarySearchFill helper for exact-quantity search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:40:56 -04:00
89 changed files with 10807 additions and 2709 deletions
+36 -17
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines.
OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using NFP-based (No Fit Polygon) and rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines.
## Build
@@ -14,41 +14,57 @@ This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-wind
dotnet build OpenNest.sln
```
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp).
No test projects exist in this solution.
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
## Architecture
Five projects form a layered architecture:
Eight projects form a layered architecture:
### OpenNest.Core (class library)
Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `Helper`, `Align`, `Sequence`, `Timing`.
- **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning.
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion.
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, and `RotatingCalipers`.
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`. Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
### OpenNest.Engine (class library, depends on Core)
Nesting algorithms. `NestEngine` orchestrates filling plates with parts.
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine. `AutoNester` handles mixed-part NFP-based nesting with simulated annealing (not yet integrated into the registry).
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
- **BestFit/**: NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
- **RectanglePacking/**: `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
- **CirclePacking/**: Alternative packing for circular parts.
- **ML/**: `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
- `FillLinear`: Grid-based fill with directional sliding.
- `Compactor`: Post-fill gravity compaction — pushes parts toward a plate edge to close gaps.
- `FillScore`: Lexicographic comparison struct for fill results (count > utilization > compactness).
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
- `BestCombination`: Finds optimal mix of normal/rotated columns for grid fills.
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
- `RotationAnalysis`: Analyzes part geometry to determine valid rotation angles.
### OpenNest.IO (class library, depends on Core)
File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp.
- `NestReader`/`NestWriter` — custom ZIP-based nest format (XML metadata + G-code programs).
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
- `ProgramReader` — G-code text parser.
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
### OpenNest.Console (console app, depends on Core + Engine + IO)
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
### OpenNest.Gpu (class library, depends on Core + Engine)
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
### OpenNest.Training (console app, depends on Core + Engine)
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
### OpenNest.Mcp (console app, depends on Core + Engine + IO)
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
@@ -62,16 +78,16 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
The UI application with MDI interface.
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionAddPart`, `ActionClone`, `ActionFillArea`, `ActionZoomWindow`, `ActionSetSequence`.
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`.
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime.
## File Format
Nest files (`.zip`) contain:
- `info` XML with nest metadata and plate defaults
- `drawing-info` XML with drawing metadata (name, material, quantities, colors)
- `plate-info` XML with plate metadata (size, material, spacing)
Nest files (`.nest`, ZIP-based) use v2 JSON format:
- `info.json` — nest metadata and plate defaults
- `drawing-info.json` — drawing metadata (name, material, quantities, colors)
- `plate-info.json` — plate metadata (size, material, spacing)
- `program-NNN` — G-code text for each drawing's cut program
- `plate-NNN` — G-code text encoding part placements (G00 for position, G65 for sub-program call with rotation)
@@ -89,3 +105,6 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
+4 -2
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using OpenNest;
using OpenNest.Converters;
using OpenNest.Geometry;
@@ -328,13 +329,14 @@ static class NestConsole
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts");
var nestParts = AutoNester.Nest(nestItems, plate);
var engine = NestEngineRegistry.Create(plate);
var nestParts = engine.Nest(nestItems, null, CancellationToken.None);
plate.Parts.AddRange(nestParts);
success = nestParts.Count > 0;
}
else
{
var engine = new NestEngine(plate);
var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
success = engine.Fill(item);
}
@@ -7,9 +7,9 @@ namespace OpenNest.CNC.CuttingStrategy
{
public CuttingParameters Parameters { get; set; }
public Program Apply(Program partProgram, Plate plate)
public CuttingResult Apply(Program partProgram, Vector approachPoint)
{
var exitPoint = GetExitPoint(plate);
var exitPoint = approachPoint;
var entities = partProgram.ToGeometry();
var profile = new ShapeProfile(entities);
@@ -44,9 +44,12 @@ namespace OpenNest.CNC.CuttingStrategy
currentPoint = closestPt;
}
var lastCutPoint = exitPoint;
// Perimeter last
{
var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity);
lastCutPoint = perimeterPt;
var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External);
var winding = DetermineWinding(profile.Perimeter);
@@ -60,21 +63,10 @@ namespace OpenNest.CNC.CuttingStrategy
result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding));
}
return result;
}
private Vector GetExitPoint(Plate plate)
{
var w = plate.Size.Width;
var l = plate.Size.Length;
return plate.Quadrant switch
return new CuttingResult
{
1 => new Vector(w, l), // Q1 origin BottomLeft -> exit TopRight
2 => new Vector(0, l), // Q2 origin BottomRight -> exit TopLeft
3 => new Vector(0, 0), // Q3 origin TopRight -> exit BottomLeft
4 => new Vector(w, 0), // Q4 origin TopLeft -> exit BottomRight
_ => new Vector(w, l)
Program = result,
LastCutPoint = lastCutPoint
};
}
@@ -0,0 +1,11 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
public readonly struct CuttingResult
{
public Program Program { get; init; }
public Vector LastCutPoint { get; init; }
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ namespace OpenNest.Converters
{
public static Program ToProgram(IList<Entity> geometry)
{
var shapes = Helper.GetShapes(geometry);
var shapes = ShapeBuilder.GetShapes(geometry);
if (shapes.Count == 0)
return null;
+1 -1
View File
@@ -65,7 +65,7 @@ namespace OpenNest
public void UpdateArea()
{
var geometry = ConvertProgram.ToGeometry(Program).Where(entity => entity.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(geometry);
var shapes = ShapeBuilder.GetShapes(geometry);
if (shapes.Count == 0)
return;
+10 -10
View File
@@ -465,7 +465,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc)
{
List<Vector> pts;
return Helper.Intersects(this, arc, out pts);
return Intersect.Intersects(this, arc, out pts);
}
/// <summary>
@@ -476,7 +476,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts)
{
return Helper.Intersects(this, arc, out pts); ;
return Intersect.Intersects(this, arc, out pts); ;
}
/// <summary>
@@ -487,7 +487,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle)
{
List<Vector> pts;
return Helper.Intersects(this, circle, out pts);
return Intersect.Intersects(this, circle, out pts);
}
/// <summary>
@@ -498,7 +498,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts)
{
return Helper.Intersects(this, circle, out pts);
return Intersect.Intersects(this, circle, out pts);
}
/// <summary>
@@ -509,7 +509,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line)
{
List<Vector> pts;
return Helper.Intersects(this, line, out pts);
return Intersect.Intersects(this, line, out pts);
}
/// <summary>
@@ -520,7 +520,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Line line, out List<Vector> pts)
{
return Helper.Intersects(this, line, out pts);
return Intersect.Intersects(this, line, out pts);
}
/// <summary>
@@ -531,7 +531,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon)
{
List<Vector> pts;
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -542,7 +542,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts)
{
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -553,7 +553,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape)
{
List<Vector> pts;
return Helper.Intersects(this, shape, out pts);
return Intersect.Intersects(this, shape, out pts);
}
/// <summary>
@@ -564,7 +564,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts)
{
return Helper.Intersects(this, shape, out pts);
return Intersect.Intersects(this, shape, out pts);
}
/// <summary>
+9 -9
View File
@@ -320,7 +320,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc)
{
List<Vector> pts;
return Helper.Intersects(arc, this, out pts);
return Intersect.Intersects(arc, this, out pts);
}
/// <summary>
@@ -331,7 +331,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts)
{
return Helper.Intersects(arc, this, out pts);
return Intersect.Intersects(arc, this, out pts);
}
/// <summary>
@@ -353,7 +353,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts)
{
return Helper.Intersects(this, circle, out pts);
return Intersect.Intersects(this, circle, out pts);
}
/// <summary>
@@ -364,7 +364,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line)
{
List<Vector> pts;
return Helper.Intersects(this, line, out pts);
return Intersect.Intersects(this, line, out pts);
}
/// <summary>
@@ -375,7 +375,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Line line, out List<Vector> pts)
{
return Helper.Intersects(this, line, out pts);
return Intersect.Intersects(this, line, out pts);
}
/// <summary>
@@ -386,7 +386,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon)
{
List<Vector> pts;
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -397,7 +397,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts)
{
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -408,7 +408,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape)
{
List<Vector> pts;
return Helper.Intersects(this, shape, out pts);
return Intersect.Intersects(this, shape, out pts);
}
/// <summary>
@@ -419,7 +419,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts)
{
return Helper.Intersects(this, shape, out pts);
return Intersect.Intersects(this, shape, out pts);
}
/// <summary>
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public static class GeometryOptimizer
{
public static void Optimize(IList<Arc> arcs)
{
for (int i = 0; i < arcs.Count; ++i)
{
var arc = arcs[i];
var coradialArcs = arcs.GetCoradialArs(arc, i);
int index = 0;
while (index < coradialArcs.Count)
{
Arc arc2 = coradialArcs[index];
Arc joinArc;
if (!TryJoinArcs(arc, arc2, out joinArc))
{
index++;
continue;
}
coradialArcs.Remove(arc2);
arcs.Remove(arc2);
arc = joinArc;
index = 0;
}
arcs[i] = arc;
}
}
public static void Optimize(IList<Line> lines)
{
for (int i = 0; i < lines.Count; ++i)
{
var line = lines[i];
var collinearLines = lines.GetCollinearLines(line, i);
var index = 0;
while (index < collinearLines.Count)
{
Line line2 = collinearLines[index];
Line joinLine;
if (!TryJoinLines(line, line2, out joinLine))
{
index++;
continue;
}
collinearLines.Remove(line2);
lines.Remove(line2);
line = joinLine;
index = 0;
}
lines[i] = line;
}
}
public static bool TryJoinLines(Line line1, Line line2, out Line lineOut)
{
lineOut = null;
if (line1 == line2)
return false;
if (!line1.IsCollinearTo(line2))
return false;
bool onPoint = false;
if (line1.StartPoint == line2.StartPoint)
onPoint = true;
else if (line1.StartPoint == line2.EndPoint)
onPoint = true;
else if (line1.EndPoint == line2.StartPoint)
onPoint = true;
else if (line1.EndPoint == line2.EndPoint)
onPoint = true;
var t1 = line1.StartPoint.Y > line1.EndPoint.Y ? line1.StartPoint.Y : line1.EndPoint.Y;
var t2 = line2.StartPoint.Y > line2.EndPoint.Y ? line2.StartPoint.Y : line2.EndPoint.Y;
var b1 = line1.StartPoint.Y < line1.EndPoint.Y ? line1.StartPoint.Y : line1.EndPoint.Y;
var b2 = line2.StartPoint.Y < line2.EndPoint.Y ? line2.StartPoint.Y : line2.EndPoint.Y;
var l1 = line1.StartPoint.X < line1.EndPoint.X ? line1.StartPoint.X : line1.EndPoint.X;
var l2 = line2.StartPoint.X < line2.EndPoint.X ? line2.StartPoint.X : line2.EndPoint.X;
var r1 = line1.StartPoint.X > line1.EndPoint.X ? line1.StartPoint.X : line1.EndPoint.X;
var r2 = line2.StartPoint.X > line2.EndPoint.X ? line2.StartPoint.X : line2.EndPoint.X;
if (!onPoint)
{
if (t1 < b2 - Tolerance.Epsilon) return false;
if (b1 > t2 + Tolerance.Epsilon) return false;
if (l1 > r2 + Tolerance.Epsilon) return false;
if (r1 < l2 - Tolerance.Epsilon) return false;
}
var l = l1 < l2 ? l1 : l2;
var r = r1 > r2 ? r1 : r2;
var t = t1 > t2 ? t1 : t2;
var b = b1 < b2 ? b1 : b2;
if (!line1.IsVertical() && line1.Slope() < 0)
lineOut = new Line(new Vector(l, t), new Vector(r, b));
else
lineOut = new Line(new Vector(l, b), new Vector(r, t));
return true;
}
public static bool TryJoinArcs(Arc arc1, Arc arc2, out Arc arcOut)
{
arcOut = null;
if (arc1 == arc2)
return false;
if (arc1.Center != arc2.Center)
return false;
if (!arc1.Radius.IsEqualTo(arc2.Radius))
return false;
if (arc1.StartAngle > arc1.EndAngle)
arc1.StartAngle -= Angle.TwoPI;
if (arc2.StartAngle > arc2.EndAngle)
arc2.StartAngle -= Angle.TwoPI;
if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle)
return false;
var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle;
var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle;
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle);
return true;
}
private static List<Line> GetCollinearLines(this IList<Line> lines, Line line, int startIndex)
{
var collinearLines = new List<Line>();
Parallel.For(startIndex, lines.Count, index =>
{
var compareLine = lines[index];
if (Object.ReferenceEquals(line, compareLine))
return;
if (!line.IsCollinearTo(compareLine))
return;
lock (collinearLines)
{
collinearLines.Add(compareLine);
}
});
return collinearLines;
}
private static List<Arc> GetCoradialArs(this IList<Arc> arcs, Arc arc, int startIndex)
{
var coradialArcs = new List<Arc>();
Parallel.For(startIndex, arcs.Count, index =>
{
var compareArc = arcs[index];
if (Object.ReferenceEquals(arc, compareArc))
return;
if (!arc.IsCoradialTo(compareArc))
return;
lock (coradialArcs)
{
coradialArcs.Add(compareArc);
}
});
return coradialArcs;
}
}
}
+373
View File
@@ -0,0 +1,373 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public static class Intersect
{
internal static bool Intersects(Arc arc1, Arc arc2, out List<Vector> pts)
{
var c1 = new Circle(arc1.Center, arc1.Radius);
var c2 = new Circle(arc2.Center, arc2.Radius);
if (!Intersects(c1, c2, out pts))
{
pts = new List<Vector>();
return false;
}
pts = pts.Where(pt =>
Angle.IsBetweenRad(arc1.Center.AngleTo(pt), arc1.StartAngle, arc1.EndAngle, arc1.IsReversed) &&
Angle.IsBetweenRad(arc2.Center.AngleTo(pt), arc2.StartAngle, arc2.EndAngle, arc2.IsReversed))
.ToList();
return pts.Count > 0;
}
internal static bool Intersects(Arc arc, Circle circle, out List<Vector> pts)
{
var c1 = new Circle(arc.Center, arc.Radius);
if (!Intersects(c1, circle, out pts))
{
pts = new List<Vector>();
return false;
}
pts = pts.Where(pt => Angle.IsBetweenRad(
arc.Center.AngleTo(pt),
arc.StartAngle,
arc.EndAngle,
arc.IsReversed)).ToList();
return pts.Count > 0;
}
internal static bool Intersects(Arc arc, Line line, out List<Vector> pts)
{
var c1 = new Circle(arc.Center, arc.Radius);
if (!Intersects(c1, line, out pts))
{
pts = new List<Vector>();
return false;
}
pts = pts.Where(pt => Angle.IsBetweenRad(
arc.Center.AngleTo(pt),
arc.StartAngle,
arc.EndAngle,
arc.IsReversed)).ToList();
return pts.Count > 0;
}
internal static bool Intersects(Arc arc, Shape shape, out List<Vector> pts)
{
var pts2 = new List<Vector>();
foreach (var geo in shape.Entities)
{
List<Vector> pts3;
geo.Intersects(arc, out pts3);
pts2.AddRange(pts3);
}
pts = pts2.Where(pt => Angle.IsBetweenRad(
arc.Center.AngleTo(pt),
arc.StartAngle,
arc.EndAngle,
arc.IsReversed)).ToList();
return pts.Count > 0;
}
internal static bool Intersects(Arc arc, Polygon polygon, out List<Vector> pts)
{
var pts2 = new List<Vector>();
var lines = polygon.ToLines();
foreach (var line in lines)
{
List<Vector> pts3;
Intersects(arc, line, out pts3);
pts2.AddRange(pts3);
}
pts = pts2.Where(pt => Angle.IsBetweenRad(
arc.Center.AngleTo(pt),
arc.StartAngle,
arc.EndAngle,
arc.IsReversed)).ToList();
return pts.Count > 0;
}
internal static bool Intersects(Circle circle1, Circle circle2, out List<Vector> pts)
{
var distance = circle1.Center.DistanceTo(circle2.Center);
// check if circles are too far apart
if (distance > circle1.Radius + circle2.Radius)
{
pts = new List<Vector>();
return false;
}
// check if one circle contains the other
if (distance < System.Math.Abs(circle1.Radius - circle2.Radius))
{
pts = new List<Vector>();
return false;
}
var d = circle2.Center - circle1.Center;
var a = (circle1.Radius * circle1.Radius - circle2.Radius * circle2.Radius + distance * distance) / (2.0 * distance);
var h = System.Math.Sqrt(circle1.Radius * circle1.Radius - a * a);
var pt = new Vector(
circle1.Center.X + (a * d.X) / distance,
circle1.Center.Y + (a * d.Y) / distance);
var i1 = new Vector(
pt.X + (h * d.Y) / distance,
pt.Y - (h * d.X) / distance);
var i2 = new Vector(
pt.X - (h * d.Y) / distance,
pt.Y + (h * d.X) / distance);
pts = i1 != i2 ? new List<Vector> { i1, i2 } : new List<Vector> { i1 };
return true;
}
internal static bool Intersects(Circle circle, Line line, out List<Vector> pts)
{
var d1 = line.EndPoint - line.StartPoint;
var d2 = line.StartPoint - circle.Center;
var a = d1.X * d1.X + d1.Y * d1.Y;
var b = (d1.X * d2.X + d1.Y * d2.Y) * 2;
var c = (d2.X * d2.X + d2.Y * d2.Y) - circle.Radius * circle.Radius;
var det = b * b - 4 * a * c;
if ((a <= Tolerance.Epsilon) || (det < 0))
{
pts = new List<Vector>();
return false;
}
double t;
pts = new List<Vector>();
if (det.IsEqualTo(0))
{
t = -b / (2 * a);
var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
if (line.BoundingBox.Contains(pt1))
pts.Add(pt1);
return true;
}
t = (-b + System.Math.Sqrt(det)) / (2 * a);
var pt2 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
if (line.BoundingBox.Contains(pt2))
pts.Add(pt2);
t = (-b - System.Math.Sqrt(det)) / (2 * a);
var pt3 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
if (line.BoundingBox.Contains(pt3))
pts.Add(pt3);
return true;
}
internal static bool Intersects(Circle circle, Shape shape, out List<Vector> pts)
{
pts = new List<Vector>();
foreach (var geo in shape.Entities)
{
List<Vector> pts3;
geo.Intersects(circle, out pts3);
pts.AddRange(pts3);
}
return pts.Count > 0;
}
internal static bool Intersects(Circle circle, Polygon polygon, out List<Vector> pts)
{
pts = new List<Vector>();
var lines = polygon.ToLines();
foreach (var line in lines)
{
List<Vector> pts3;
Intersects(circle, line, out pts3);
pts.AddRange(pts3);
}
return pts.Count > 0;
}
internal static bool Intersects(Line line1, Line line2, out Vector pt)
{
var a1 = line1.EndPoint.Y - line1.StartPoint.Y;
var b1 = line1.StartPoint.X - line1.EndPoint.X;
var c1 = a1 * line1.StartPoint.X + b1 * line1.StartPoint.Y;
var a2 = line2.EndPoint.Y - line2.StartPoint.Y;
var b2 = line2.StartPoint.X - line2.EndPoint.X;
var c2 = a2 * line2.StartPoint.X + b2 * line2.StartPoint.Y;
var d = a1 * b2 - a2 * b1;
if (d.IsEqualTo(0.0))
{
pt = Vector.Zero;
return false;
}
var x = (b2 * c1 - b1 * c2) / d;
var y = (a1 * c2 - a2 * c1) / d;
pt = new Vector(x, y);
return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt);
}
internal static bool Intersects(Line line, Shape shape, out List<Vector> pts)
{
pts = new List<Vector>();
foreach (var geo in shape.Entities)
{
List<Vector> pts3;
geo.Intersects(line, out pts3);
pts.AddRange(pts3);
}
return pts.Count > 0;
}
internal static bool Intersects(Line line, Polygon polygon, out List<Vector> pts)
{
pts = new List<Vector>();
var lines = polygon.ToLines();
foreach (var line2 in lines)
{
Vector pt;
if (Intersects(line, line2, out pt))
pts.Add(pt);
}
return pts.Count > 0;
}
internal static bool Intersects(Shape shape1, Shape shape2, out List<Vector> pts)
{
pts = new List<Vector>();
for (int i = 0; i < shape1.Entities.Count; i++)
{
var geo1 = shape1.Entities[i];
for (int j = 0; j < shape2.Entities.Count; j++)
{
List<Vector> pts2;
bool success = false;
var geo2 = shape2.Entities[j];
switch (geo2.Type)
{
case EntityType.Arc:
success = geo1.Intersects((Arc)geo2, out pts2);
break;
case EntityType.Circle:
success = geo1.Intersects((Circle)geo2, out pts2);
break;
case EntityType.Line:
success = geo1.Intersects((Line)geo2, out pts2);
break;
case EntityType.Shape:
success = geo1.Intersects((Shape)geo2, out pts2);
break;
case EntityType.Polygon:
success = geo1.Intersects((Polygon)geo2, out pts2);
break;
default:
continue;
}
if (success)
pts.AddRange(pts2);
}
}
return pts.Count > 0;
}
internal static bool Intersects(Shape shape, Polygon polygon, out List<Vector> pts)
{
pts = new List<Vector>();
var lines = polygon.ToLines();
for (int i = 0; i < shape.Entities.Count; i++)
{
var geo = shape.Entities[i];
for (int j = 0; j < lines.Count; j++)
{
var line = lines[j];
List<Vector> pts2;
if (geo.Intersects(line, out pts2))
pts.AddRange(pts2);
}
}
return pts.Count > 0;
}
internal static bool Intersects(Polygon polygon1, Polygon polygon2, out List<Vector> pts)
{
pts = new List<Vector>();
var lines1 = polygon1.ToLines();
var lines2 = polygon2.ToLines();
for (int i = 0; i < lines1.Count; i++)
{
var line1 = lines1[i];
for (int j = 0; j < lines2.Count; j++)
{
var line2 = lines2[j];
Vector pt;
if (Intersects(line1, line2, out pt))
pts.Add(pt);
}
}
return pts.Count > 0;
}
}
}
+9 -9
View File
@@ -456,7 +456,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc)
{
List<Vector> pts;
return Helper.Intersects(arc, this, out pts);
return Intersect.Intersects(arc, this, out pts);
}
/// <summary>
@@ -467,7 +467,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts)
{
return Helper.Intersects(arc, this, out pts);
return Intersect.Intersects(arc, this, out pts);
}
/// <summary>
@@ -478,7 +478,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle)
{
List<Vector> pts;
return Helper.Intersects(circle, this, out pts);
return Intersect.Intersects(circle, this, out pts);
}
/// <summary>
@@ -489,7 +489,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts)
{
return Helper.Intersects(circle, this, out pts);
return Intersect.Intersects(circle, this, out pts);
}
/// <summary>
@@ -512,7 +512,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line, out List<Vector> pts)
{
Vector pt;
var success = Helper.Intersects(this, line, out pt);
var success = Intersect.Intersects(this, line, out pt);
pts = new List<Vector>(new[] { pt });
return success;
}
@@ -525,7 +525,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon)
{
List<Vector> pts;
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -536,7 +536,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts)
{
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -547,7 +547,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape)
{
List<Vector> pts;
return Helper.Intersects(this, shape, out pts);
return Intersect.Intersects(this, shape, out pts);
}
/// <summary>
@@ -558,7 +558,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts)
{
return Helper.Intersects(this, shape, out pts);
return Intersect.Intersects(this, shape, out pts);
}
/// <summary>
+10 -10
View File
@@ -364,7 +364,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc)
{
List<Vector> pts;
return Helper.Intersects(arc, this, out pts);
return Intersect.Intersects(arc, this, out pts);
}
/// <summary>
@@ -375,7 +375,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts)
{
return Helper.Intersects(arc, this, out pts);
return Intersect.Intersects(arc, this, out pts);
}
/// <summary>
@@ -386,7 +386,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle)
{
List<Vector> pts;
return Helper.Intersects(circle, this, out pts);
return Intersect.Intersects(circle, this, out pts);
}
/// <summary>
@@ -397,7 +397,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts)
{
return Helper.Intersects(circle, this, out pts);
return Intersect.Intersects(circle, this, out pts);
}
/// <summary>
@@ -408,7 +408,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line)
{
List<Vector> pts;
return Helper.Intersects(line, this, out pts);
return Intersect.Intersects(line, this, out pts);
}
/// <summary>
@@ -419,7 +419,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Line line, out List<Vector> pts)
{
return Helper.Intersects(line, this, out pts);
return Intersect.Intersects(line, this, out pts);
}
/// <summary>
@@ -430,7 +430,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon)
{
List<Vector> pts;
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -441,7 +441,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts)
{
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -452,7 +452,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape)
{
List<Vector> pts;
return Helper.Intersects(shape, this, out pts);
return Intersect.Intersects(shape, this, out pts);
}
/// <summary>
@@ -463,7 +463,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts)
{
return Helper.Intersects(shape, this, out pts);
return Intersect.Intersects(shape, this, out pts);
}
/// <summary>
+13 -13
View File
@@ -159,8 +159,8 @@ namespace OpenNest.Geometry
}
}
Helper.Optimize(lines);
Helper.Optimize(arcs);
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
}
/// <summary>
@@ -534,7 +534,7 @@ namespace OpenNest.Geometry
{
Vector intersection;
if (Helper.Intersects(offsetLine, lastOffsetLine, out intersection))
if (Intersect.Intersects(offsetLine, lastOffsetLine, out intersection))
{
offsetLine.StartPoint = intersection;
lastOffsetLine.EndPoint = intersection;
@@ -577,7 +577,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc)
{
List<Vector> pts;
return Helper.Intersects(arc, this, out pts);
return Intersect.Intersects(arc, this, out pts);
}
/// <summary>
@@ -588,7 +588,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts)
{
return Helper.Intersects(arc, this, out pts);
return Intersect.Intersects(arc, this, out pts);
}
/// <summary>
@@ -599,7 +599,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle)
{
List<Vector> pts;
return Helper.Intersects(circle, this, out pts);
return Intersect.Intersects(circle, this, out pts);
}
/// <summary>
@@ -610,7 +610,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts)
{
return Helper.Intersects(circle, this, out pts);
return Intersect.Intersects(circle, this, out pts);
}
/// <summary>
@@ -621,7 +621,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line)
{
List<Vector> pts;
return Helper.Intersects(line, this, out pts);
return Intersect.Intersects(line, this, out pts);
}
/// <summary>
@@ -632,7 +632,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Line line, out List<Vector> pts)
{
return Helper.Intersects(line, this, out pts);
return Intersect.Intersects(line, this, out pts);
}
/// <summary>
@@ -643,7 +643,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon)
{
List<Vector> pts;
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -654,7 +654,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts)
{
return Helper.Intersects(this, polygon, out pts);
return Intersect.Intersects(this, polygon, out pts);
}
/// <summary>
@@ -665,7 +665,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape)
{
List<Vector> pts;
return Helper.Intersects(this, shape, out pts);
return Intersect.Intersects(this, shape, out pts);
}
/// <summary>
@@ -676,7 +676,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts)
{
return Helper.Intersects(this, shape, out pts);
return Intersect.Intersects(this, shape, out pts);
}
/// <summary>
+150
View File
@@ -0,0 +1,150 @@
using System.Collections.Generic;
using System.Diagnostics;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public static class ShapeBuilder
{
public static List<Shape> GetShapes(IEnumerable<Entity> entities)
{
var lines = new List<Line>();
var arcs = new List<Arc>();
var circles = new List<Circle>();
var shapes = new List<Shape>();
var entities2 = new Queue<Entity>(entities);
while (entities2.Count > 0)
{
var entity = entities2.Dequeue();
switch (entity.Type)
{
case EntityType.Arc:
arcs.Add((Arc)entity);
break;
case EntityType.Circle:
circles.Add((Circle)entity);
break;
case EntityType.Line:
lines.Add((Line)entity);
break;
case EntityType.Shape:
var shape = (Shape)entity;
shape.Entities.ForEach(e => entities2.Enqueue(e));
break;
default:
Debug.Fail("Unhandled geometry type");
break;
}
}
foreach (var circle in circles)
{
var shape = new Shape();
shape.Entities.Add(circle);
shape.UpdateBounds();
shapes.Add(shape);
}
var entityList = new List<Entity>();
entityList.AddRange(lines);
entityList.AddRange(arcs);
while (entityList.Count > 0)
{
var next = entityList[0];
var shape = new Shape();
shape.Entities.Add(next);
entityList.RemoveAt(0);
Vector startPoint = new Vector();
Entity connected;
switch (next.Type)
{
case EntityType.Arc:
var arc = (Arc)next;
startPoint = arc.EndPoint();
break;
case EntityType.Line:
var line = (Line)next;
startPoint = line.EndPoint;
break;
}
while ((connected = GetConnected(startPoint, entityList)) != null)
{
shape.Entities.Add(connected);
entityList.Remove(connected);
switch (connected.Type)
{
case EntityType.Arc:
var arc = (Arc)connected;
startPoint = arc.EndPoint();
break;
case EntityType.Line:
var line = (Line)connected;
startPoint = line.EndPoint;
break;
}
}
shape.UpdateBounds();
shapes.Add(shape);
}
return shapes;
}
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
{
var tol = Tolerance.ChainTolerance;
foreach (var geo in geometry)
{
switch (geo.Type)
{
case EntityType.Arc:
var arc = (Arc)geo;
if (arc.StartPoint().DistanceTo(pt) <= tol)
return arc;
if (arc.EndPoint().DistanceTo(pt) <= tol)
{
arc.Reverse();
return arc;
}
break;
case EntityType.Line:
var line = (Line)geo;
if (line.StartPoint.DistanceTo(pt) <= tol)
return line;
if (line.EndPoint.DistanceTo(pt) <= tol)
{
line.Reverse();
return line;
}
break;
}
}
return null;
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ namespace OpenNest.Geometry
private void Update(List<Entity> entities)
{
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
Perimeter = shapes[0];
Cutouts = new List<Shape>();
+614
View File
@@ -0,0 +1,614 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public static class SpatialQuery
{
/// <summary>
/// Finds the distance from a vertex to a line segment along a push axis.
/// Returns double.MaxValue if the ray does not hit the segment.
/// </summary>
private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction)
{
return RayEdgeDistance(
vertex.X, vertex.Y,
edge.pt1.X, edge.pt1.Y, edge.pt2.X, edge.pt2.Y,
direction);
}
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
private static double RayEdgeDistance(
double vx, double vy,
double p1x, double p1y, double p2x, double p2y,
PushDirection direction)
{
switch (direction)
{
case PushDirection.Left:
case PushDirection.Right:
{
var dy = p2y - p1y;
if (System.Math.Abs(dy) < Tolerance.Epsilon)
return double.MaxValue;
var t = (vy - p1y) / dy;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var ix = p1x + t * (p2x - p1x);
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
case PushDirection.Down:
case PushDirection.Up:
{
var dx = p2x - p1x;
if (System.Math.Abs(dx) < Tolerance.Epsilon)
return double.MaxValue;
var t = (vx - p1x) / dx;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var iy = p1y + t * (p2y - p1y);
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
default:
return double.MaxValue;
}
}
/// <summary>
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
/// Returns double.MaxValue if no collision path exists.
/// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
{
var minDist = double.MaxValue;
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
// Sort edges for pruning if not already sorted (usually they aren't here)
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite);
if (d < minDist) minDist = d;
}
return minDist;
}
/// <summary>
/// Computes the minimum directional distance with the moving lines translated
/// by (movingDx, movingDy) without creating new Line objects.
/// </summary>
public static double DirectionalDistance(
List<Line> movingLines, double movingDx, double movingDy,
List<Line> stationaryLines, PushDirection direction)
{
var minDist = double.MaxValue;
var movingOffset = new Vector(movingDx, movingDy);
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1 + movingOffset);
movingVertices.Add(movingLines[i].pt2 + movingOffset);
}
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
}
return minDist;
}
/// <summary>
/// Packs line segments into a flat double array [x1,y1,x2,y2, ...] for GPU transfer.
/// </summary>
public static double[] FlattenLines(List<Line> lines)
{
var result = new double[lines.Count * 4];
for (int i = 0; i < lines.Count; i++)
{
var line = lines[i];
result[i * 4] = line.pt1.X;
result[i * 4 + 1] = line.pt1.Y;
result[i * 4 + 2] = line.pt2.X;
result[i * 4 + 3] = line.pt2.Y;
}
return result;
}
/// <summary>
/// Computes the minimum directional distance using raw edge arrays and location offsets
/// to avoid all intermediate object allocations.
/// </summary>
public static double DirectionalDistance(
(Vector start, Vector end)[] movingEdges, Vector movingOffset,
(Vector start, Vector end)[] stationaryEdges, Vector stationaryOffset,
PushDirection direction)
{
var minDist = double.MaxValue;
// Extract unique vertices from moving edges.
var movingVertices = new HashSet<Vector>();
for (var i = 0; i < movingEdges.Length; i++)
{
movingVertices.Add(movingEdges[i].start + movingOffset);
movingVertices.Add(movingEdges[i].end + movingOffset);
}
// Case 1: Each moving vertex -> each stationary edge
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (var i = 0; i < stationaryEdges.Length; i++)
{
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
}
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
}
return minDist;
}
public static double OneWayDistance(
Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset,
PushDirection direction)
{
var minDist = double.MaxValue;
var vx = vertex.X;
var vy = vertex.Y;
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
if (direction == PushDirection.Left || direction == PushDirection.Right)
{
for (var i = 0; i < edges.Length; i++)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
if (vy < minY - Tolerance.Epsilon)
break;
if (vy > maxY + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
}
}
else // Up/Down
{
for (var i = 0; i < edges.Length; i++)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minX = e1.X < e2.X ? e1.X : e2.X;
var maxX = e1.X > e2.X ? e1.X : e2.X;
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
if (vx < minX - Tolerance.Epsilon)
break;
if (vx > maxX + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
}
}
return minDist;
}
public static PushDirection OppositeDirection(PushDirection direction)
{
switch (direction)
{
case PushDirection.Left: return PushDirection.Right;
case PushDirection.Right: return PushDirection.Left;
case PushDirection.Up: return PushDirection.Down;
case PushDirection.Down: return PushDirection.Up;
default: return direction;
}
}
public static bool IsHorizontalDirection(PushDirection direction)
{
return direction is PushDirection.Left or PushDirection.Right;
}
public static double EdgeDistance(Box box, Box boundary, PushDirection direction)
{
switch (direction)
{
case PushDirection.Left: return box.Left - boundary.Left;
case PushDirection.Right: return boundary.Right - box.Right;
case PushDirection.Up: return boundary.Top - box.Top;
case PushDirection.Down: return box.Bottom - boundary.Bottom;
default: return double.MaxValue;
}
}
public static Vector DirectionToOffset(PushDirection direction, double distance)
{
switch (direction)
{
case PushDirection.Left: return new Vector(-distance, 0);
case PushDirection.Right: return new Vector(distance, 0);
case PushDirection.Up: return new Vector(0, distance);
case PushDirection.Down: return new Vector(0, -distance);
default: return new Vector();
}
}
public static double DirectionalGap(Box from, Box to, PushDirection direction)
{
switch (direction)
{
case PushDirection.Left: return from.Left - to.Right;
case PushDirection.Right: return to.Left - from.Right;
case PushDirection.Up: return to.Bottom - from.Top;
case PushDirection.Down: return from.Bottom - to.Top;
default: return double.MaxValue;
}
}
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsHorizontalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Right)
continue;
var distance = box.Left - compareBox.Right;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceRight(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsHorizontalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Left)
continue;
var distance = compareBox.Left - box.Right;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceUp(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsVerticalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Bottom)
continue;
var distance = compareBox.Bottom - box.Top;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceDown(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsVerticalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Top)
continue;
var distance = box.Bottom - compareBox.Top;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable<Box> boxes)
{
var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList();
#region Find Top/Bottom Limits
var top = double.MaxValue;
var btm = double.MinValue;
foreach (var box in verticalBoxes)
{
var boxBtm = box.Bottom;
var boxTop = box.Top;
if (boxBtm > pt.Y && boxBtm < top)
top = boxBtm;
else if (box.Top < pt.Y && boxTop > btm)
btm = boxTop;
}
if (top == double.MaxValue)
{
if (bounds.Top > pt.Y)
top = bounds.Top;
else return Box.Empty;
}
if (btm == double.MinValue)
{
if (bounds.Bottom < pt.Y)
btm = bounds.Bottom;
else return Box.Empty;
}
#endregion
var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList();
#region Find Left/Right Limits
var lft = double.MinValue;
var rgt = double.MaxValue;
foreach (var box in horizontalBoxes)
{
var boxLft = box.Left;
var boxRgt = box.Right;
if (boxLft > pt.X && boxLft < rgt)
rgt = boxLft;
else if (boxRgt < pt.X && boxRgt > lft)
lft = boxRgt;
}
if (rgt == double.MaxValue)
{
if (bounds.Right > pt.X)
rgt = bounds.Right;
else return Box.Empty;
}
if (lft == double.MinValue)
{
if (bounds.Left < pt.X)
lft = bounds.Left;
else return Box.Empty;
}
#endregion
return new Box(lft, btm, rgt - lft, top - btm);
}
public static Box GetLargestBoxHorizontally(Vector pt, Box bounds, IEnumerable<Box> boxes)
{
var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList();
#region Find Left/Right Limits
var lft = double.MinValue;
var rgt = double.MaxValue;
foreach (var box in horizontalBoxes)
{
var boxLft = box.Left;
var boxRgt = box.Right;
if (boxLft > pt.X && boxLft < rgt)
rgt = boxLft;
else if (boxRgt < pt.X && boxRgt > lft)
lft = boxRgt;
}
if (rgt == double.MaxValue)
{
if (bounds.Right > pt.X)
rgt = bounds.Right;
else return Box.Empty;
}
if (lft == double.MinValue)
{
if (bounds.Left < pt.X)
lft = bounds.Left;
else return Box.Empty;
}
#endregion
var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList();
#region Find Top/Bottom Limits
var top = double.MaxValue;
var btm = double.MinValue;
foreach (var box in verticalBoxes)
{
var boxBtm = box.Bottom;
var boxTop = box.Top;
if (boxBtm > pt.Y && boxBtm < top)
top = boxBtm;
else if (box.Top < pt.Y && boxTop > btm)
btm = boxTop;
}
if (top == double.MaxValue)
{
if (bounds.Top > pt.Y)
top = bounds.Top;
else return Box.Empty;
}
if (btm == double.MinValue)
{
if (bounds.Bottom < pt.Y)
btm = bounds.Bottom;
else return Box.Empty;
}
#endregion
return new Box(lft, btm, rgt - lft, top - btm);
}
}
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
namespace OpenNest.Math
{
public static class Rounding
{
/// <summary>
/// Rounds a number down to the nearest factor.
/// </summary>
/// <param name="num"></param>
/// <param name="factor"></param>
/// <returns></returns>
public static double RoundDownToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor;
}
/// <summary>
/// Rounds a number up to the nearest factor.
/// </summary>
/// <param name="num"></param>
/// <param name="factor"></param>
/// <returns></returns>
public static double RoundUpToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor;
}
/// <summary>
/// Rounds a number to the nearest factor using midpoint rounding convention.
/// </summary>
/// <param name="num"></param>
/// <param name="factor"></param>
/// <returns></returns>
public static double RoundToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor;
}
}
}
+2
View File
@@ -51,6 +51,8 @@ namespace OpenNest
public Program Program { get; private set; }
public bool HasManualLeadIns { get; set; }
/// <summary>
/// Gets the rotation of the part in radians.
/// </summary>
+126
View File
@@ -0,0 +1,126 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest
{
public static class PartGeometry
{
public static List<Line> GetPartLines(Part part, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(polygon.ToLines());
}
return lines;
}
public static List<Line> GetPartLines(Part part, PushDirection facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
// Add chord tolerance to compensate for inscribed polygon chords
// being inside the actual offset arcs.
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(polygon.ToLines());
}
return lines;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines;
}
/// <summary>
/// Returns only polygon edges whose outward normal faces the specified direction.
/// </summary>
private static List<Line> GetDirectionalLines(Polygon polygon, PushDirection facingDirection)
{
if (polygon.Vertices.Count < 3)
return polygon.ToLines();
var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0;
var lines = new List<Line>();
var last = polygon.Vertices[0];
for (int i = 1; i < polygon.Vertices.Count; i++)
{
var current = polygon.Vertices[i];
var dx = current.X - last.X;
var dy = current.Y - last.Y;
bool keep;
switch (facingDirection)
{
case PushDirection.Left: keep = -sign * dy > 0; break;
case PushDirection.Right: keep = sign * dy > 0; break;
case PushDirection.Up: keep = -sign * dx > 0; break;
case PushDirection.Down: keep = sign * dx > 0; break;
default: keep = true; break;
}
if (keep)
lines.Add(new Line(last, current));
last = current;
}
return lines;
}
}
}
+2 -2
View File
@@ -412,8 +412,8 @@ namespace OpenNest
}
Size = new Size(
Helper.RoundUpToNearest(width, roundingFactor),
Helper.RoundUpToNearest(length, roundingFactor));
Rounding.RoundUpToNearest(width, roundingFactor),
Rounding.RoundUpToNearest(length, roundingFactor));
}
/// <summary>
+1 -1
View File
@@ -11,7 +11,7 @@ namespace OpenNest
public static TimingInfo GetTimingInfo(Program pgm)
{
var entities = ConvertProgram.ToGeometry(pgm);
var shapes = Helper.GetShapes(entities.Where(entity => entity.Layer != SpecialLayers.Rapid));
var shapes = ShapeBuilder.GetShapes(entities.Where(entity => entity.Layer != SpecialLayers.Rapid));
var info = new TimingInfo { PierceCount = shapes.Count };
var last = entities[0];
+1 -1
View File
@@ -116,7 +116,7 @@ namespace OpenNest.Engine.BestFit
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>();
+2 -2
View File
@@ -103,7 +103,7 @@ namespace OpenNest.Engine.BestFit
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
shapes.ForEach(s => s.Offset(part.Location));
return shapes;
}
@@ -112,7 +112,7 @@ namespace OpenNest.Engine.BestFit
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>();
foreach (var shape in shapes)
@@ -34,8 +34,8 @@ namespace OpenNest.Engine.BestFit
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
var halfSpacing = spacing / 2;
var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing);
var part2TemplateLines = Helper.GetOffsetPartLines(part2Template, halfSpacing);
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
@@ -128,8 +128,8 @@ namespace OpenNest.Engine.BestFit
if (_slideComputer != null)
{
var stationarySegments = Helper.FlattenLines(part1Lines);
var movingSegments = Helper.FlattenLines(part2TemplateLines);
var stationarySegments = SpatialQuery.FlattenLines(part1Lines);
var movingSegments = SpatialQuery.FlattenLines(part2TemplateLines);
var offsets = new double[count * 2];
var directions = new int[count];
@@ -182,7 +182,7 @@ namespace OpenNest.Engine.BestFit
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
stationaryEdgesByDir[dir] = sEdges;
var opposite = Helper.OppositeDirection(dir);
var opposite = SpatialQuery.OppositeDirection(dir);
var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count];
for (var i = 0; i < part2TemplateLines.Count; i++)
mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint);
@@ -204,21 +204,21 @@ namespace OpenNest.Engine.BestFit
var sEdges = stationaryEdgesByDir[dir];
var mEdges = movingEdgesByDir[dir];
var opposite = Helper.OppositeDirection(dir);
var opposite = SpatialQuery.OppositeDirection(dir);
var minDist = double.MaxValue;
// Case 1: Moving vertices -> Stationary edges
foreach (var mv in movingVerticesArray)
{
var d = Helper.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
var d = SpatialQuery.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
if (d < minDist) minDist = d;
}
// Case 2: Stationary vertices -> Moving edges (translated)
foreach (var sv in stationaryVerticesArray)
{
var d = Helper.OneWayDistance(sv, mEdges, movingOffset, opposite);
var d = SpatialQuery.OneWayDistance(sv, mEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
}
+156
View File
@@ -0,0 +1,156 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// Pushes a group of parts left and down to close gaps after placement.
/// Uses the same directional-distance logic as PlateView.PushSelected
/// but operates on Part objects directly.
/// </summary>
public static class Compactor
{
private const double ChordTolerance = 0.001;
/// <summary>
/// Compacts movingParts toward the bottom-left of the plate work area.
/// Everything already on the plate (excluding movingParts) is treated
/// as stationary obstacles.
/// </summary>
private const double RepeatThreshold = 0.01;
private const int MaxIterations = 20;
public static void Compact(List<Part> movingParts, Plate plate)
{
if (movingParts == null || movingParts.Count == 0)
return;
var savedPositions = SavePositions(movingParts);
// Try left-first.
var leftFirst = CompactLoop(movingParts, plate, PushDirection.Left, PushDirection.Down);
// Restore and try down-first.
RestorePositions(movingParts, savedPositions);
var downFirst = CompactLoop(movingParts, plate, PushDirection.Down, PushDirection.Left);
// Keep left-first if it traveled further.
if (leftFirst > downFirst)
{
RestorePositions(movingParts, savedPositions);
CompactLoop(movingParts, plate, PushDirection.Left, PushDirection.Down);
}
}
private static double CompactLoop(List<Part> parts, Plate plate,
PushDirection first, PushDirection second)
{
var total = 0.0;
for (var i = 0; i < MaxIterations; i++)
{
var a = Push(parts, plate, first);
var b = Push(parts, plate, second);
total += a + b;
if (a <= RepeatThreshold && b <= RepeatThreshold)
break;
}
return total;
}
private static Vector[] SavePositions(List<Part> parts)
{
var positions = new Vector[parts.Count];
for (var i = 0; i < parts.Count; i++)
positions[i] = parts[i].Location;
return positions;
}
private static void RestorePositions(List<Part> parts, Vector[] positions)
{
for (var i = 0; i < parts.Count; i++)
parts[i].Location = positions[i];
}
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p))
.ToList();
var obstacleBoxes = new Box[obstacleParts.Count];
var obstacleLines = new List<Line>[obstacleParts.Count];
for (var i = 0; i < obstacleParts.Count; i++)
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
var opposite = SpatialQuery.OppositeDirection(direction);
var halfSpacing = plate.PartSpacing / 2;
var isHorizontal = SpatialQuery.IsHorizontalDirection(direction);
var workArea = plate.WorkArea();
var distance = double.MaxValue;
// BB gap at which offset geometries are expected to be touching.
var contactGap = (halfSpacing + ChordTolerance) * 2;
foreach (var moving in movingParts)
{
var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction);
if (edgeDist <= 0)
distance = 0;
else if (edgeDist < distance)
distance = edgeDist;
var movingBox = moving.BoundingBox;
List<Line> movingLines = null;
for (var i = 0; i < obstacleBoxes.Length; i++)
{
// Use the reverse-direction gap to check if the obstacle is entirely
// behind the moving part. The forward gap (gap < 0) is unreliable for
// irregular shapes whose bounding boxes overlap even when the actual
// geometry still has a valid contact in the push direction.
var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite);
if (reverseGap > 0)
continue;
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
if (gap >= distance)
continue;
var perpOverlap = isHorizontal
? movingBox.IsHorizontalTo(obstacleBoxes[i], out _)
: movingBox.IsVerticalTo(obstacleBoxes[i], out _);
if (!perpOverlap)
continue;
movingLines ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
obstacleLines[i] ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
if (d < distance)
distance = d;
}
}
if (distance < double.MaxValue && distance > 0)
{
var offset = SpatialQuery.DirectionToOffset(direction, distance);
foreach (var moving in movingParts)
moving.Offset(offset);
return distance;
}
return 0;
}
}
}
@@ -12,67 +12,44 @@ using OpenNest.RectanglePacking;
namespace OpenNest
{
public class NestEngine
public class DefaultNestEngine : NestEngineBase
{
public NestEngine(Plate plate)
{
Plate = plate;
}
public DefaultNestEngine(Plate plate) : base(plate) { }
public Plate Plate { get; set; }
public override string Name => "Default";
public NestDirection NestDirection { get; set; }
public int PlateNumber { get; set; }
public NestPhase WinnerPhase { get; private set; }
public List<PhaseResult> PhaseResults { get; } = new();
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
public bool ForceFullAngleSweep { get; set; }
public List<AngleResult> AngleResults { get; } = new();
// Angles that have produced results across multiple Fill calls.
// Populated after each Fill; used to prune subsequent fills.
private readonly HashSet<double> knownGoodAngles = new();
// --- Public Fill API ---
public bool Fill(NestItem item)
{
return Fill(item, Plate.WorkArea());
}
public bool Fill(NestItem item, Box workArea)
{
var parts = Fill(item, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
public List<Part> Fill(NestItem item, Box workArea,
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
PhaseResults.Clear();
AngleResults.Clear();
var best = FindBestFill(item, workArea, progress, token);
if (token.IsCancellationRequested)
return best ?? new List<Part>();
// Try improving by filling the remainder strip separately.
var remainderSw = Stopwatch.StartNew();
var improved = TryRemainderImprovement(item, workArea, best);
remainderSw.Stop();
if (IsBetterFill(improved, best, workArea))
if (!token.IsCancellationRequested)
{
Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
best = improved;
WinnerPhase = NestPhase.Remainder;
PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds));
ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary());
// Try improving by filling the remainder strip separately.
var remainderSw = Stopwatch.StartNew();
var improved = TryRemainderImprovement(item, workArea, best);
remainderSw.Stop();
if (IsBetterFill(improved, best, workArea))
{
Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
best = improved;
WinnerPhase = NestPhase.Remainder;
PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds));
ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary());
}
}
if (best == null || best.Count == 0)
@@ -84,23 +61,61 @@ namespace OpenNest
return best;
}
public bool Fill(List<Part> groupParts)
/// <summary>
/// Fast fill count using linear fill with two angles plus the top cached
/// pair candidates. Used by binary search to estimate capacity at a given
/// box size without running the full Fill pipeline.
/// </summary>
private int QuickFillCount(Drawing drawing, Box testBox, double bestRotation)
{
return Fill(groupParts, Plate.WorkArea());
var engine = new FillLinear(testBox, Plate.PartSpacing);
var bestCount = 0;
// Single-part linear fills.
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
foreach (var angle in angles)
{
var h = engine.Fill(drawing, angle, NestDirection.Horizontal);
if (h != null && h.Count > bestCount)
bestCount = h.Count;
var v = engine.Fill(drawing, angle, NestDirection.Vertical);
if (v != null && v.Count > bestCount)
bestCount = v.Count;
}
// Top pair candidates — check if pairs tile better in this box.
var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Width, Plate.Size.Length, Plate.PartSpacing);
var topPairs = bestFits.Where(r => r.Keep).Take(3);
foreach (var pair in topPairs)
{
var pairParts = pair.BuildParts(drawing);
var pairAngles = pair.HullAngles ?? new List<double> { 0 };
var pairEngine = new FillLinear(testBox, Plate.PartSpacing);
foreach (var angle in pairAngles)
{
var pattern = BuildRotatedPattern(pairParts, angle);
if (pattern.Parts.Count == 0)
continue;
var h = pairEngine.Fill(pattern, NestDirection.Horizontal);
if (h != null && h.Count > bestCount)
bestCount = h.Count;
var v = pairEngine.Fill(pattern, NestDirection.Vertical);
if (v != null && v.Count > bestCount)
bestCount = v.Count;
}
}
return bestCount;
}
public bool Fill(List<Part> groupParts, Box workArea)
{
var parts = Fill(groupParts, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
public List<Part> Fill(List<Part> groupParts, Box workArea,
public override List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
if (groupParts == null || groupParts.Count == 0)
@@ -169,13 +184,8 @@ namespace OpenNest
// --- Pack API ---
public bool Pack(List<NestItem> items)
{
var workArea = Plate.WorkArea();
return PackArea(workArea, items);
}
public bool PackArea(Box box, List<NestItem> items)
public override List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
@@ -183,10 +193,7 @@ namespace OpenNest
var engine = new PackBottomLeft(bin);
engine.Pack(binItems);
var parts = BinConverter.ToParts(bin, items);
Plate.Parts.AddRange(parts);
return parts.Count > 0;
return BinConverter.ToParts(bin, items);
}
// --- FindBestFill: core orchestration ---
@@ -260,6 +267,13 @@ namespace OpenNest
linearSw.Stop();
PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds));
// Record productive angles for future fills.
foreach (var ar in AngleResults)
{
if (ar.PartCount > 0)
knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
}
Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary());
@@ -340,6 +354,23 @@ namespace OpenNest
}
}
// If we have known-good angles from previous fills, use only those
// plus the defaults (bestRotation + 90°). This prunes the expensive
// angle sweep after the first fill.
if (knownGoodAngles.Count > 0 && !ForceFullAngleSweep)
{
var pruned = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
foreach (var a in knownGoodAngles)
{
if (!pruned.Any(existing => existing.IsEqualTo(a)))
pruned.Add(a);
}
Debug.WriteLine($"[BuildCandidateAngles] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
return pruned;
}
return angles;
}
@@ -373,6 +404,7 @@ namespace OpenNest
List<Part> best = null;
var bestScore = default(FillScore);
var sinceImproved = 0;
try
{
@@ -393,11 +425,27 @@ namespace OpenNest
{
best = filled;
bestScore = score;
sinceImproved = 0;
}
else
{
sinceImproved++;
}
}
else
{
sinceImproved++;
}
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea,
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
// Early exit: stop if we've tried enough candidates without improvement.
if (i >= 9 && sinceImproved >= 10)
{
Debug.WriteLine($"[FillWithPairs] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
break;
}
}
}
catch (OperationCanceledException)
@@ -433,12 +481,16 @@ namespace OpenNest
{
var stripCandidates = bestFits
.Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
&& r.Utilization >= 0.3);
&& r.Utilization >= 0.3)
.OrderByDescending(r => r.Utilization);
var existing = new HashSet<BestFitResult>(top);
foreach (var r in stripCandidates)
{
if (top.Count >= 100)
break;
if (existing.Add(r))
top.Add(r);
}
@@ -641,129 +693,5 @@ namespace OpenNest
return clusters;
}
// --- Scoring / comparison ---
private bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
}
private bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
{
Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})");
return false;
}
return IsBetterFill(candidate, current, workArea);
}
private bool HasOverlaps(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)
return false;
for (var i = 0; i < parts.Count; i++)
{
var box1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var box2 = parts[j].BoundingBox;
// Fast bounding box rejection.
if (box1.Right < box2.Left || box2.Right < box1.Left ||
box1.Top < box2.Bottom || box2.Top < box1.Bottom)
continue;
List<Vector> pts;
if (parts[i].Intersects(parts[j], out pts))
{
var b1 = parts[i].BoundingBox;
var b2 = parts[j].BoundingBox;
Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" +
$" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" +
$" intersections={pts?.Count ?? 0}");
return true;
}
}
}
return false;
}
// --- Progress reporting ---
private static void ReportProgress(
IProgress<NestProgress> progress,
NestPhase phase,
int plateNumber,
List<Part> best,
Box workArea,
string description)
{
if (progress == null || best == null || best.Count == 0)
return;
var score = FillScore.Compute(best, workArea);
var clonedParts = new List<Part>(best.Count);
var totalPartArea = 0.0;
foreach (var part in best)
{
clonedParts.Add((Part)part.Clone());
totalPartArea += part.BaseDrawing.Area;
}
var bounds = best.GetBoundingBox();
progress.Report(new NestProgress
{
Phase = phase,
PlateNumber = plateNumber,
BestPartCount = score.Count,
BestDensity = score.Density,
NestedWidth = bounds.Width,
NestedLength = bounds.Length,
NestedArea = totalPartArea,
UsableRemnantArea = workArea.Area() - totalPartArea,
BestParts = clonedParts,
Description = description
});
}
private string BuildProgressSummary()
{
if (PhaseResults.Count == 0)
return null;
var parts = new List<string>(PhaseResults.Count);
foreach (var r in PhaseResults)
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
return string.Join(" | ", parts);
}
private static string FormatPhaseName(NestPhase phase)
{
switch (phase)
{
case NestPhase.Pairs: return "Pairs";
case NestPhase.Linear: return "Linear";
case NestPhase.RectBestFit: return "BestFit";
case NestPhase.Remainder: return "Remainder";
default: return phase.ToString();
}
}
}
}
+4 -4
View File
@@ -82,9 +82,9 @@ namespace OpenNest
var locationBOffset = MakeOffset(direction, bboxDim);
// Use the most efficient array-based overload to avoid all allocations.
var slideDistance = Helper.DirectionalDistance(
var slideDistance = SpatialQuery.DirectionalDistance(
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
boundary.GetEdges(Helper.OppositeDirection(pushDir)), partA.Location,
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
pushDir);
return ComputeCopyDistance(bboxDim, slideDistance);
@@ -103,7 +103,7 @@ namespace OpenNest
var bboxDim = GetDimension(patternA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var opposite = Helper.OppositeDirection(pushDir);
var opposite = SpatialQuery.OppositeDirection(pushDir);
// Compute a starting offset large enough that every part-pair in
// patternB has its offset geometry beyond patternA's offset geometry.
@@ -143,7 +143,7 @@ namespace OpenNest
for (var i = 0; i < patternA.Parts.Count; i++)
{
var slideDistance = Helper.DirectionalDistance(
var slideDistance = SpatialQuery.DirectionalDistance(
movingEdges[j], locationB,
stationaryEdges[i], patternA.Parts[i].Location,
pushDir);
+1 -1
View File
@@ -27,7 +27,7 @@ namespace OpenNest.Engine.ML
{
public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false)
{
var engine = new NestEngine(plate);
var engine = new DefaultNestEngine(plate);
engine.ForceFullAngleSweep = forceFullAngleSweep;
var item = new NestItem { Drawing = drawing };
+319
View File
@@ -0,0 +1,319 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest
{
public abstract class NestEngineBase
{
protected NestEngineBase(Plate plate)
{
Plate = plate;
}
public Plate Plate { get; set; }
public int PlateNumber { get; set; }
public NestDirection NestDirection { get; set; }
public NestPhase WinnerPhase { get; protected set; }
public List<PhaseResult> PhaseResults { get; } = new();
public List<AngleResult> AngleResults { get; } = new();
public abstract string Name { get; }
public abstract string Description { get; }
// --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
public virtual List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
public virtual List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
// --- Nest: multi-item strategy (virtual, side-effect-free) ---
public virtual List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var workArea = Plate.WorkArea();
var allParts = new List<Part>();
var fillItems = items
.Where(i => i.Quantity != 1)
.OrderBy(i => i.Priority)
.ThenByDescending(i => i.Drawing.Area)
.ToList();
var packItems = items
.Where(i => i.Quantity == 1)
.ToList();
// Phase 1: Fill multi-quantity drawings sequentially.
foreach (var item in fillItems)
{
if (token.IsCancellationRequested)
break;
if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0)
continue;
var parts = FillExact(
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
workArea, progress, token);
if (parts.Count > 0)
{
allParts.AddRange(parts);
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing);
}
}
// Phase 2: Pack single-quantity items into remaining space.
packItems = packItems.Where(i => i.Quantity > 0).ToList();
if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0
&& !token.IsCancellationRequested)
{
var packParts = PackArea(workArea, packItems, progress, token);
if (packParts.Count > 0)
{
allParts.AddRange(packParts);
foreach (var item in packItems)
{
var placed = packParts.Count(p =>
p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
}
}
return allParts;
}
protected static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing)
{
var hWidth = workArea.Right - usedBox.Right - spacing;
var hStrip = hWidth > 0
? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length)
: Box.Empty;
var vHeight = workArea.Top - usedBox.Top - spacing;
var vStrip = vHeight > 0
? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight)
: Box.Empty;
return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip;
}
// --- FillExact (non-virtual, delegates to virtual Fill) ---
public List<Part> FillExact(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return Fill(item, workArea, progress, token);
}
// --- Convenience overloads (mutate plate, return bool) ---
public bool Fill(NestItem item)
{
return Fill(item, Plate.WorkArea());
}
public bool Fill(NestItem item, Box workArea)
{
var parts = Fill(item, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
public bool Fill(List<Part> groupParts)
{
return Fill(groupParts, Plate.WorkArea());
}
public bool Fill(List<Part> groupParts, Box workArea)
{
var parts = Fill(groupParts, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
public bool Pack(List<NestItem> items)
{
var workArea = Plate.WorkArea();
var parts = PackArea(workArea, items, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
// --- Protected utilities ---
protected static void ReportProgress(
IProgress<NestProgress> progress,
NestPhase phase,
int plateNumber,
List<Part> best,
Box workArea,
string description)
{
if (progress == null || best == null || best.Count == 0)
return;
var score = FillScore.Compute(best, workArea);
var clonedParts = new List<Part>(best.Count);
var totalPartArea = 0.0;
foreach (var part in best)
{
clonedParts.Add((Part)part.Clone());
totalPartArea += part.BaseDrawing.Area;
}
var bounds = best.GetBoundingBox();
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
Debug.WriteLine(msg);
try { System.IO.File.AppendAllText(
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n"); } catch { }
progress.Report(new NestProgress
{
Phase = phase,
PlateNumber = plateNumber,
BestPartCount = score.Count,
BestDensity = score.Density,
NestedWidth = bounds.Width,
NestedLength = bounds.Length,
NestedArea = totalPartArea,
UsableRemnantArea = workArea.Area() - totalPartArea,
BestParts = clonedParts,
Description = description
});
}
protected string BuildProgressSummary()
{
if (PhaseResults.Count == 0)
return null;
var parts = new List<string>(PhaseResults.Count);
foreach (var r in PhaseResults)
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
return string.Join(" | ", parts);
}
protected bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
}
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
{
Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})");
return false;
}
return IsBetterFill(candidate, current, workArea);
}
protected static bool HasOverlaps(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)
return false;
for (var i = 0; i < parts.Count; i++)
{
var box1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var box2 = parts[j].BoundingBox;
if (box1.Right < box2.Left || box2.Right < box1.Left ||
box1.Top < box2.Bottom || box2.Top < box1.Bottom)
continue;
List<Vector> pts;
if (parts[i].Intersects(parts[j], out pts))
{
var b1 = parts[i].BoundingBox;
var b2 = parts[j].BoundingBox;
Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" +
$" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" +
$" intersections={pts?.Count ?? 0}");
return true;
}
}
}
return false;
}
protected static string FormatPhaseName(NestPhase phase)
{
switch (phase)
{
case NestPhase.Pairs: return "Pairs";
case NestPhase.Linear: return "Linear";
case NestPhase.RectBestFit: return "BestFit";
case NestPhase.Remainder: return "Remainder";
default: return phase.ToString();
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using System;
namespace OpenNest
{
public class NestEngineInfo
{
public NestEngineInfo(string name, string description, Func<Plate, NestEngineBase> factory)
{
Name = name;
Description = description;
Factory = factory;
}
public string Name { get; }
public string Description { get; }
public Func<Plate, NestEngineBase> Factory { get; }
}
}
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
namespace OpenNest
{
public static class NestEngineRegistry
{
private static readonly List<NestEngineInfo> engines = new();
static NestEngineRegistry()
{
Register("Default",
"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
plate => new DefaultNestEngine(plate));
Register("Strip",
"Strip-based nesting for mixed-drawing layouts",
plate => new StripNestEngine(plate));
}
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;
public static string ActiveEngineName { get; set; } = "Default";
public static NestEngineBase Create(Plate plate)
{
var info = engines.FirstOrDefault(e =>
e.Name.Equals(ActiveEngineName, StringComparison.OrdinalIgnoreCase));
if (info == null)
{
Debug.WriteLine($"[NestEngineRegistry] Engine '{ActiveEngineName}' not found, falling back to Default");
info = engines[0];
}
return info.Factory(plate);
}
public static void Register(string name, string description, Func<Plate, NestEngineBase> factory)
{
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
Debug.WriteLine($"[NestEngineRegistry] Duplicate engine '{name}' skipped");
return;
}
engines.Add(new NestEngineInfo(name, description, factory));
}
public static void LoadPlugins(string directory)
{
if (!Directory.Exists(directory))
return;
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
{
try
{
var assembly = Assembly.LoadFrom(dll);
foreach (var type in assembly.GetTypes())
{
if (type.IsAbstract || !typeof(NestEngineBase).IsAssignableFrom(type))
continue;
var ctor = type.GetConstructor(new[] { typeof(Plate) });
if (ctor == null)
{
Debug.WriteLine($"[NestEngineRegistry] Skipping {type.Name}: no Plate constructor");
continue;
}
// Create a temporary instance to read Name and Description.
try
{
var tempPlate = new Plate();
var instance = (NestEngineBase)ctor.Invoke(new object[] { tempPlate });
Register(instance.Name, instance.Description,
plate => (NestEngineBase)ctor.Invoke(new object[] { plate }));
Debug.WriteLine($"[NestEngineRegistry] Loaded plugin engine: {instance.Name}");
}
catch (Exception ex)
{
Debug.WriteLine($"[NestEngineRegistry] Failed to instantiate {type.Name}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Debug.WriteLine($"[NestEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}");
}
}
}
}
}
+120
View File
@@ -0,0 +1,120 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
namespace OpenNest.Engine
{
public class PlateProcessor
{
public IPartSequencer Sequencer { get; set; }
public ContourCuttingStrategy CuttingStrategy { get; set; }
public IRapidPlanner RapidPlanner { get; set; }
public PlateResult Process(Plate plate)
{
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
var results = new List<ProcessedPart>(sequenced.Count);
var cutAreas = new List<Shape>();
var currentPoint = PlateHelper.GetExitPoint(plate);
foreach (var sp in sequenced)
{
var part = sp.Part;
// Compute approach point in part-local space
var localApproach = ToPartLocal(currentPoint, part);
Program processedProgram;
Vector lastCutLocal;
if (!part.HasManualLeadIns && CuttingStrategy != null)
{
var cuttingResult = CuttingStrategy.Apply(part.Program, localApproach);
processedProgram = cuttingResult.Program;
lastCutLocal = cuttingResult.LastCutPoint;
}
else
{
processedProgram = part.Program;
lastCutLocal = GetProgramEndPoint(part.Program);
}
// Pierce point: program start point in plate space
var pierceLocal = GetProgramStartPoint(part.Program);
var piercePoint = ToPlateSpace(pierceLocal, part);
// Plan rapid from currentPoint to pierce point
var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
results.Add(new ProcessedPart
{
Part = part,
ProcessedProgram = processedProgram,
RapidPath = rapidPath
});
// Update cut areas with part perimeter
var perimeter = GetPartPerimeter(part);
if (perimeter != null)
cutAreas.Add(perimeter);
// Update current point to last cut point in plate space
currentPoint = ToPlateSpace(lastCutLocal, part);
}
return new PlateResult { Parts = results };
}
private static Vector ToPartLocal(Vector platePoint, Part part)
{
return platePoint - part.Location;
}
private static Vector ToPlateSpace(Vector localPoint, Part part)
{
return localPoint + part.Location;
}
private static Vector GetProgramStartPoint(Program program)
{
if (program.Codes.Count == 0)
return Vector.Zero;
var first = program.Codes[0];
if (first is Motion motion)
return motion.EndPoint;
return Vector.Zero;
}
private static Vector GetProgramEndPoint(Program program)
{
for (var i = program.Codes.Count - 1; i >= 0; i--)
{
if (program.Codes[i] is Motion motion)
return motion.EndPoint;
}
return Vector.Zero;
}
private static Shape GetPartPerimeter(Part part)
{
var entities = part.Program.ToGeometry();
if (entities == null || entities.Count == 0)
return null;
var profile = new ShapeProfile(entities);
var perimeter = profile.Perimeter;
if (perimeter == null || perimeter.Entities.Count == 0)
return null;
perimeter.Offset(part.Location);
return perimeter;
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.RapidPlanning;
namespace OpenNest.Engine
{
public class PlateResult
{
public List<ProcessedPart> Parts { get; init; }
}
public readonly struct ProcessedPart
{
public Part Part { get; init; }
public Program ProcessedProgram { get; init; }
public RapidPath RapidPath { get; init; }
}
}
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.RapidPlanning
{
public class DirectRapidPlanner : IRapidPlanner
{
public RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas)
{
var travelLine = new Line(from, to);
foreach (var cutArea in cutAreas)
{
if (TravelLineIntersectsShape(travelLine, cutArea))
{
return new RapidPath
{
HeadUp = true,
Waypoints = new List<Vector>()
};
}
}
return new RapidPath
{
HeadUp = false,
Waypoints = new List<Vector>()
};
}
private static bool TravelLineIntersectsShape(Line travelLine, Shape shape)
{
foreach (var entity in shape.Entities)
{
if (entity is Line edge)
{
if (travelLine.Intersects(edge, out _))
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.RapidPlanning
{
public interface IRapidPlanner
{
RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas);
}
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.RapidPlanning
{
public readonly struct RapidPath
{
public bool HeadUp { get; init; }
public List<Vector> Waypoints { get; init; }
}
}
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.RapidPlanning
{
public class SafeHeightRapidPlanner : IRapidPlanner
{
public RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas)
{
return new RapidPath
{
HeadUp = true,
Waypoints = new List<Vector>()
};
}
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ namespace OpenNest
var entities = ConvertProgram.ToGeometry(item.Drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0)
return 0;
@@ -65,7 +65,7 @@ namespace OpenNest
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
foreach (var shape in shapes)
{
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Math;
namespace OpenNest.Engine.Sequencing
{
public class AdvancedSequencer : IPartSequencer
{
private readonly SequenceParameters _parameters;
public AdvancedSequencer(SequenceParameters parameters)
{
_parameters = parameters;
}
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
if (parts.Count == 0)
return new List<SequencedPart>();
var exit = PlateHelper.GetExitPoint(plate);
// Group parts into rows by Y proximity
var rows = GroupIntoRows(parts, _parameters.MinDistanceBetweenRowsColumns);
// Sort rows bottom-to-top (ascending Y)
rows.Sort((a, b) => a.RowY.CompareTo(b.RowY));
// Determine initial direction based on exit point
var leftToRight = exit.X > plate.Size.Width * 0.5;
var result = new List<SequencedPart>(parts.Count);
foreach (var row in rows)
{
var sorted = leftToRight
? row.Parts.OrderBy(p => p.BoundingBox.Center.X).ToList()
: row.Parts.OrderByDescending(p => p.BoundingBox.Center.X).ToList();
foreach (var p in sorted)
result.Add(new SequencedPart { Part = p });
if (_parameters.AlternateRowsColumns)
leftToRight = !leftToRight;
}
return result;
}
private static List<PartRow> GroupIntoRows(IReadOnlyList<Part> parts, double minDistance)
{
// Sort parts by Y center
var sorted = parts
.OrderBy(p => p.BoundingBox.Center.Y)
.ToList();
var rows = new List<PartRow>();
foreach (var part in sorted)
{
var y = part.BoundingBox.Center.Y;
var placed = false;
foreach (var row in rows)
{
if (System.Math.Abs(y - row.RowY) <= minDistance + Tolerance.Epsilon)
{
row.Parts.Add(part);
placed = true;
break;
}
}
if (!placed)
{
var row = new PartRow(y);
row.Parts.Add(part);
rows.Add(row);
}
}
return rows;
}
private class PartRow
{
public double RowY { get; }
public List<Part> Parts { get; } = new List<Part>();
public PartRow(double rowY)
{
RowY = rowY;
}
}
}
}
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class BottomSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderBy(p => p.Location.Y)
.ThenBy(p => p.Location.X)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}
@@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class EdgeStartSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
// Plate(width, length) stores Size with Width/Length swapped internally.
// Reconstruct the logical plate box using the BoundingBox origin and the
// corrected extents: Size.Length = X-extent, Size.Width = Y-extent.
var origin = plate.BoundingBox(false);
var plateBox = new OpenNest.Geometry.Box(
origin.X, origin.Y,
plate.Size.Length,
plate.Size.Width);
return parts
.OrderBy(p => MinEdgeDistance(p.BoundingBox.Center, plateBox))
.ThenBy(p => p.Location.X)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
private static double MinEdgeDistance(OpenNest.Geometry.Vector center, OpenNest.Geometry.Box plateBox)
{
var distLeft = center.X - plateBox.Left;
var distRight = plateBox.Right - center.X;
var distBottom = center.Y - plateBox.Bottom;
var distTop = plateBox.Top - center.Y;
return System.Math.Min(System.Math.Min(distLeft, distRight), System.Math.Min(distBottom, distTop));
}
}
}
@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace OpenNest.Engine.Sequencing
{
public readonly struct SequencedPart
{
public Part Part { get; init; }
}
public interface IPartSequencer
{
List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate);
}
}
@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Engine.Sequencing
{
public class LeastCodeSequencer : IPartSequencer
{
private readonly int _maxIterations;
public LeastCodeSequencer(int maxIterations = 100)
{
_maxIterations = maxIterations;
}
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
if (parts.Count == 0)
return new List<SequencedPart>();
var exit = PlateHelper.GetExitPoint(plate);
var ordered = NearestNeighbor(parts, exit);
TwoOpt(ordered, exit);
var result = new List<SequencedPart>(ordered.Count);
foreach (var p in ordered)
result.Add(new SequencedPart { Part = p });
return result;
}
private static List<Part> NearestNeighbor(IReadOnlyList<Part> parts, OpenNest.Geometry.Vector exit)
{
var remaining = new List<Part>(parts);
var ordered = new List<Part>(parts.Count);
var current = exit;
while (remaining.Count > 0)
{
var bestIdx = 0;
var bestDist = Distance(current, Center(remaining[0]));
for (var i = 1; i < remaining.Count; i++)
{
var d = Distance(current, Center(remaining[i]));
if (d < bestDist - Tolerance.Epsilon)
{
bestDist = d;
bestIdx = i;
}
}
var next = remaining[bestIdx];
ordered.Add(next);
remaining.RemoveAt(bestIdx);
current = Center(next);
}
return ordered;
}
private void TwoOpt(List<Part> ordered, OpenNest.Geometry.Vector exit)
{
var n = ordered.Count;
if (n < 3)
return;
for (var iter = 0; iter < _maxIterations; iter++)
{
var improved = false;
for (var i = 0; i < n - 1; i++)
{
for (var j = i + 1; j < n; j++)
{
var before = RouteDistance(ordered, exit, i, j);
Reverse(ordered, i, j);
var after = RouteDistance(ordered, exit, i, j);
if (after < before - Tolerance.Epsilon)
{
improved = true;
}
else
{
// Revert
Reverse(ordered, i, j);
}
}
}
if (!improved)
break;
}
}
/// <summary>
/// Computes the total distance of the route starting from exit through all parts.
/// Only the segment around the reversed segment [i..j] needs to be checked,
/// but here we compute the full route cost for correctness.
/// </summary>
private static double RouteDistance(List<Part> ordered, OpenNest.Geometry.Vector exit, int i, int j)
{
// Full route distance: exit -> ordered[0] -> ... -> ordered[n-1]
var total = 0.0;
var prev = exit;
foreach (var p in ordered)
{
var c = Center(p);
total += Distance(prev, c);
prev = c;
}
return total;
}
private static void Reverse(List<Part> list, int i, int j)
{
while (i < j)
{
var tmp = list[i];
list[i] = list[j];
list[j] = tmp;
i++;
j--;
}
}
private static OpenNest.Geometry.Vector Center(Part part)
{
return part.BoundingBox.Center;
}
private static double Distance(OpenNest.Geometry.Vector a, OpenNest.Geometry.Vector b)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
return System.Math.Sqrt(dx * dx + dy * dy);
}
}
}
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class LeftSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderBy(p => p.Location.X)
.ThenBy(p => p.Location.Y)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}
@@ -0,0 +1,23 @@
using System;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.Engine.Sequencing
{
public static class PartSequencerFactory
{
public static IPartSequencer Create(SequenceParameters parameters)
{
return parameters.Method switch
{
SequenceMethod.RightSide => new RightSideSequencer(),
SequenceMethod.LeftSide => new LeftSideSequencer(),
SequenceMethod.BottomSide => new BottomSideSequencer(),
SequenceMethod.EdgeStart => new EdgeStartSequencer(),
SequenceMethod.LeastCode => new LeastCodeSequencer(),
SequenceMethod.Advanced => new AdvancedSequencer(parameters),
_ => throw new NotSupportedException(
$"Sequence method '{parameters.Method}' is not supported.")
};
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using OpenNest.Geometry;
namespace OpenNest.Engine.Sequencing
{
internal static class PlateHelper
{
public static Vector GetExitPoint(Plate plate)
{
var w = plate.Size.Width;
var l = plate.Size.Length;
return plate.Quadrant switch
{
1 => new Vector(w, l),
2 => new Vector(0, l),
3 => new Vector(0, 0),
4 => new Vector(w, 0),
_ => new Vector(w, l)
};
}
}
}
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class RightSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderByDescending(p => p.Location.X)
.ThenBy(p => p.Location.Y)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace OpenNest
{
public enum StripDirection
{
Bottom,
Left
}
}
+375
View File
@@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
public class StripNestEngine : NestEngineBase
{
private const int MaxShrinkIterations = 20;
public StripNestEngine(Plate plate) : base(plate)
{
}
public override string Name => "Strip";
public override string Description => "Strip-based nesting for mixed-drawing layouts";
/// <summary>
/// Single-item fill delegates to DefaultNestEngine.
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
/// </summary>
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(item, workArea, progress, token);
}
/// <summary>
/// Group-parts fill delegates to DefaultNestEngine.
/// </summary>
public override List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(groupParts, workArea, progress, token);
}
/// <summary>
/// Pack delegates to DefaultNestEngine.
/// </summary>
public override List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.PackArea(box, items, progress, token);
}
/// <summary>
/// Selects the item that consumes the most plate area (bounding box area x quantity).
/// Returns the index into the items list.
/// </summary>
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
{
var bestIndex = 0;
var bestArea = 0.0;
for (var i = 0; i < items.Count; i++)
{
var bbox = items[i].Drawing.Program.BoundingBox();
var qty = items[i].Quantity > 0
? items[i].Quantity
: (int)(workArea.Area() / bbox.Area());
var totalArea = bbox.Area() * qty;
if (totalArea > bestArea)
{
bestArea = totalArea;
bestIndex = i;
}
}
return bestIndex;
}
/// <summary>
/// Estimates the strip dimension (height for bottom, width for left) needed
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
/// This is only an estimate for the shrink loop starting point — the actual fill
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
/// </summary>
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
{
var bbox = item.Drawing.Program.BoundingBox();
var qty = item.Quantity > 0
? item.Quantity
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
var perRow0 = (int)(stripLength / bbox.Width);
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
var dim0 = rows0 * bbox.Length;
// At 90 deg: rotated bounding box (Width and Length swap)
var perRow90 = (int)(stripLength / bbox.Length);
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
var dim90 = rows90 * bbox.Width;
var estimate = System.Math.Min(dim0, dim90);
// Clamp to available dimension
return System.Math.Min(estimate, maxDimension);
}
/// <summary>
/// Multi-drawing strip nesting strategy.
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
/// in both bottom and left orientations, fills remnants with remaining drawings,
/// and returns the denser result.
/// </summary>
public override List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var workArea = Plate.WorkArea();
// Select which item gets the strip treatment.
var stripIndex = SelectStripItemIndex(items, workArea);
var stripItem = items[stripIndex];
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
// Try both orientations.
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, progress, token);
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, progress, token);
// Pick the better result.
var winner = bottomResult.Score >= leftResult.Score
? bottomResult.Parts
: leftResult.Parts;
// Deduct placed quantities from the original items.
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
var placed = winner.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
return winner;
}
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
List<NestItem> remainderItems, Box workArea, IProgress<NestProgress> progress, CancellationToken token)
{
var result = new StripNestResult { Direction = direction };
if (token.IsCancellationRequested)
return result;
// Estimate initial strip dimension.
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
// Create the initial strip box.
var stripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
// Initial fill using DefaultNestEngine (composition, not inheritance).
var inner = new DefaultNestEngine(Plate);
var stripParts = inner.Fill(
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
stripBox, progress, token);
if (stripParts == null || stripParts.Count == 0)
return result;
// Measure actual strip dimension from placed parts.
var placedBox = stripParts.Cast<IBoundable>().GetBoundingBox();
var actualDim = direction == StripDirection.Bottom
? placedBox.Top - workArea.Y
: placedBox.Right - workArea.X;
var bestParts = stripParts;
var bestDim = actualDim;
var targetCount = stripParts.Count;
// Shrink loop: reduce strip dimension by PartSpacing until count drops.
for (var i = 0; i < MaxShrinkIterations; i++)
{
if (token.IsCancellationRequested)
break;
var trialDim = bestDim - Plate.PartSpacing;
if (trialDim <= 0)
break;
var trialBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, trialDim)
: new Box(workArea.X, workArea.Y, trialDim, workArea.Length);
var trialInner = new DefaultNestEngine(Plate);
var trialParts = trialInner.Fill(
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
trialBox, progress, token);
if (trialParts == null || trialParts.Count < targetCount)
break;
// Same count in a tighter strip — keep going.
bestParts = trialParts;
var trialPlacedBox = trialParts.Cast<IBoundable>().GetBoundingBox();
bestDim = direction == StripDirection.Bottom
? trialPlacedBox.Top - workArea.Y
: trialPlacedBox.Right - workArea.X;
}
// Build remnant box with spacing gap.
var spacing = Plate.PartSpacing;
var remnantBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y + bestDim + spacing,
workArea.Width, workArea.Length - bestDim - spacing)
: new Box(workArea.X + bestDim + spacing, workArea.Y,
workArea.Width - bestDim - spacing, workArea.Length);
// Collect all parts.
var allParts = new List<Part>(bestParts);
// If strip item was only partially placed, add leftovers to remainder.
var placed = bestParts.Count;
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
var effectiveRemainder = new List<NestItem>(remainderItems);
if (leftover > 0)
{
effectiveRemainder.Add(new NestItem
{
Drawing = stripItem.Drawing,
Quantity = leftover
});
}
// Sort remainder by descending bounding box area x quantity.
effectiveRemainder = effectiveRemainder
.OrderByDescending(i =>
{
var bb = i.Drawing.Program.BoundingBox();
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
})
.ToList();
// Fill remnant with remainder items using free-rectangle tracking.
// After each fill, the consumed box is split into two non-overlapping
// sub-rectangles (guillotine cut) so no usable area is lost.
if (remnantBox.Width > 0 && remnantBox.Length > 0)
{
var freeBoxes = new List<Box> { remnantBox };
var remnantProgress = progress != null
? new AccumulatingProgress(progress, allParts)
: null;
foreach (var item in effectiveRemainder)
{
if (token.IsCancellationRequested || freeBoxes.Count == 0)
break;
var itemBbox = item.Drawing.Program.BoundingBox();
var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
// Try free boxes from largest to smallest.
freeBoxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
for (var i = 0; i < freeBoxes.Count; i++)
{
var box = freeBoxes[i];
if (System.Math.Min(box.Width, box.Length) < minItemDim)
continue;
var remnantInner = new DefaultNestEngine(Plate);
var remnantParts = remnantInner.Fill(
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
box, remnantProgress, token);
if (remnantParts != null && remnantParts.Count > 0)
{
allParts.AddRange(remnantParts);
freeBoxes.RemoveAt(i);
var usedBox = remnantParts.Cast<IBoundable>().GetBoundingBox();
SplitFreeBox(box, usedBox, spacing, freeBoxes);
break;
}
}
}
}
result.Parts = allParts;
result.StripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
result.RemnantBox = remnantBox;
result.Score = FillScore.Compute(allParts, workArea);
return result;
}
private static void SplitFreeBox(Box parent, Box used, double spacing, List<Box> freeBoxes)
{
var hWidth = parent.Right - used.Right - spacing;
var vHeight = parent.Top - used.Top - spacing;
if (hWidth > spacing && vHeight > spacing)
{
// Guillotine split: give the overlapping corner to the larger strip.
var hFullArea = hWidth * parent.Length;
var vFullArea = parent.Width * vHeight;
if (hFullArea >= vFullArea)
{
// hStrip gets full height; vStrip truncated to left of split line.
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length));
var vWidth = used.Right + spacing - parent.X;
if (vWidth > spacing)
freeBoxes.Add(new Box(parent.X, used.Top + spacing, vWidth, vHeight));
}
else
{
// vStrip gets full width; hStrip truncated below split line.
freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight));
var hHeight = used.Top + spacing - parent.Y;
if (hHeight > spacing)
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, hHeight));
}
}
else if (hWidth > spacing)
{
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length));
}
else if (vHeight > spacing)
{
freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight));
}
}
/// <summary>
/// Wraps an IProgress to prepend previously placed parts to each report,
/// so the UI shows the full picture (strip + remnant) during remnant fills.
/// </summary>
private class AccumulatingProgress : IProgress<NestProgress>
{
private readonly IProgress<NestProgress> inner;
private readonly List<Part> previousParts;
public AccumulatingProgress(IProgress<NestProgress> inner, List<Part> previousParts)
{
this.inner = inner;
this.previousParts = previousParts;
}
public void Report(NestProgress value)
{
if (value.BestParts != null && previousParts.Count > 0)
{
var combined = new List<Part>(previousParts.Count + value.BestParts.Count);
combined.AddRange(previousParts);
combined.AddRange(value.BestParts);
value.BestParts = combined;
value.BestPartCount = combined.Count;
}
inner.Report(value);
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest
{
internal class StripNestResult
{
public List<Part> Parts { get; set; } = new();
public Box StripBox { get; set; }
public Box RemnantBox { get; set; }
public FillScore Score { get; set; }
public StripDirection Direction { get; set; }
}
}
+1 -1
View File
@@ -258,7 +258,7 @@ namespace OpenNest.Gpu
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>();
foreach (var shape in shapes)
+2 -2
View File
@@ -47,7 +47,7 @@ namespace OpenNest.Gpu
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
var polygons = new List<Polygon>();
@@ -137,7 +137,7 @@ namespace OpenNest.Gpu
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
var polygons = new List<Polygon>();
+2 -2
View File
@@ -56,8 +56,8 @@ namespace OpenNest.IO
}
}
Helper.Optimize(lines);
Helper.Optimize(arcs);
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
entities.AddRange(lines);
entities.AddRange(arcs);
+12 -10
View File
@@ -34,7 +34,7 @@ namespace OpenNest.Mcp.Tools
return $"Error: drawing '{drawingName}' not found";
var countBefore = plate.Parts.Count;
var engine = new NestEngine(plate);
var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing, Quantity = quantity };
var success = engine.Fill(item);
@@ -70,7 +70,7 @@ namespace OpenNest.Mcp.Tools
return $"Error: drawing '{drawingName}' not found";
var countBefore = plate.Parts.Count;
var engine = new NestEngine(plate);
var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing, Quantity = quantity };
var area = new Box(x, y, width, height);
var success = engine.Fill(item, area);
@@ -111,7 +111,7 @@ namespace OpenNest.Mcp.Tools
sb.AppendLine($"Found {remnants.Count} remnant area(s) on plate {plateIndex}");
var totalAdded = 0;
var engine = new NestEngine(plate);
var engine = NestEngineRegistry.Create(plate);
for (var i = 0; i < remnants.Count; i++)
{
@@ -173,7 +173,7 @@ namespace OpenNest.Mcp.Tools
}
var countBefore = plate.Parts.Count;
var engine = new NestEngine(plate);
var engine = NestEngineRegistry.Create(plate);
var success = engine.Pack(items);
var countAfter = plate.Parts.Count;
var added = countAfter - countBefore;
@@ -193,7 +193,7 @@ namespace OpenNest.Mcp.Tools
}
[McpServerTool(Name = "autonest_plate")]
[Description("NFP-based mixed-part autonesting. Places multiple different drawings on a plate with geometry-aware collision avoidance and simulated annealing optimization. Produces tighter layouts than pack_plate by allowing parts to interlock.")]
[Description("Mixed-part autonesting. Fills the plate with multiple different drawings using iterative per-drawing fills with remainder-strip packing.")]
public string AutoNestPlate(
[Description("Index of the plate")] int plateIndex,
[Description("Comma-separated drawing names")] string drawingNames,
@@ -233,16 +233,18 @@ namespace OpenNest.Mcp.Tools
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
}
var parts = AutoNester.Nest(items, plate);
plate.Parts.AddRange(parts);
var engine = NestEngineRegistry.Create(plate);
var nestParts = engine.Nest(items, null, CancellationToken.None);
plate.Parts.AddRange(nestParts);
var totalPlaced = nestParts.Count;
var sb = new StringBuilder();
sb.AppendLine($"AutoNest plate {plateIndex}: {(parts.Count > 0 ? "success" : "no parts placed")}");
sb.AppendLine($" Parts placed: {parts.Count}");
sb.AppendLine($"AutoNest plate {plateIndex} ({engine.Name} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}");
sb.AppendLine($" Parts placed: {totalPlaced}");
sb.AppendLine($" Total parts: {plate.Parts.Count}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
var groups = parts.GroupBy(p => p.BaseDrawing.Name);
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
foreach (var group in groups)
sb.AppendLine($" {group.Key}: {group.Count()}");
+23
View File
@@ -0,0 +1,23 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests;
public class CuttingResultTests
{
[Fact]
public void CuttingResult_StoresValues()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(1, 2)));
var point = new Vector(3, 4);
var result = new CuttingResult { Program = pgm, LastCutPoint = point };
Assert.Same(pgm, result.Program);
Assert.Equal(3, result.LastCutPoint.X);
Assert.Equal(4, result.LastCutPoint.Y);
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+32
View File
@@ -0,0 +1,32 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests;
public class PartFlagTests
{
[Fact]
public void HasManualLeadIns_DefaultsFalse()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
var drawing = new Drawing("test", pgm);
var part = new Part(drawing);
Assert.False(part.HasManualLeadIns);
}
[Fact]
public void HasManualLeadIns_CanBeSet()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
var drawing = new Drawing("test", pgm);
var part = new Part(drawing);
part.HasManualLeadIns = true;
Assert.True(part.HasManualLeadIns);
}
}
+132
View File
@@ -0,0 +1,132 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests;
public class PlateProcessorTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y, size: 2);
[Fact]
public void Process_ReturnsAllParts()
{
var plate = new Plate(60, 120);
plate.Parts.Add(MakePartAt(10, 10));
plate.Parts.Add(MakePartAt(30, 30));
plate.Parts.Add(MakePartAt(50, 50));
var processor = new PlateProcessor
{
Sequencer = new RightSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Equal(3, result.Parts.Count);
}
[Fact]
public void Process_PreservesSequenceOrder()
{
var plate = new Plate(60, 120);
var left = MakePartAt(5, 10);
var right = MakePartAt(50, 10);
plate.Parts.Add(left);
plate.Parts.Add(right);
var processor = new PlateProcessor
{
Sequencer = new RightSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Same(right, result.Parts[0].Part);
Assert.Same(left, result.Parts[1].Part);
}
[Fact]
public void Process_SkipsCuttingStrategy_WhenManualLeadIns()
{
var plate = new Plate(60, 120);
var part = MakePartAt(10, 10);
part.HasManualLeadIns = true;
plate.Parts.Add(part);
var processor = new PlateProcessor
{
Sequencer = new LeftSideSequencer(),
CuttingStrategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters()
},
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Same(part.Program, result.Parts[0].ProcessedProgram);
}
[Fact]
public void Process_DoesNotMutatePart()
{
var plate = new Plate(60, 120);
var part = MakePartAt(10, 10);
var originalProgram = part.Program;
plate.Parts.Add(part);
var processor = new PlateProcessor
{
Sequencer = new LeftSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Same(originalProgram, part.Program);
}
[Fact]
public void Process_NoCuttingStrategy_PassesProgramThrough()
{
var plate = new Plate(60, 120);
var part = MakePartAt(10, 10);
plate.Parts.Add(part);
var processor = new PlateProcessor
{
Sequencer = new LeftSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Same(part.Program, result.Parts[0].ProcessedProgram);
}
[Fact]
public void Process_EmptyPlate_ReturnsEmptyResult()
{
var plate = new Plate(60, 120);
var processor = new PlateProcessor
{
Sequencer = new LeftSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Empty(result.Parts);
}
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.RapidPlanning;
public class DirectRapidPlannerTests
{
[Fact]
public void NoCutAreas_ReturnsHeadDown()
{
var planner = new DirectRapidPlanner();
var result = planner.Plan(new Vector(0, 0), new Vector(10, 10), new List<Shape>());
Assert.False(result.HeadUp);
Assert.Empty(result.Waypoints);
}
[Fact]
public void ClearPath_ReturnsHeadDown()
{
var planner = new DirectRapidPlanner();
var cutArea = new Shape();
cutArea.Entities.Add(new Line(new Vector(50, 0), new Vector(50, 10)));
cutArea.Entities.Add(new Line(new Vector(50, 10), new Vector(60, 10)));
cutArea.Entities.Add(new Line(new Vector(60, 10), new Vector(60, 0)));
cutArea.Entities.Add(new Line(new Vector(60, 0), new Vector(50, 0)));
var result = planner.Plan(
new Vector(0, 0), new Vector(10, 10),
new List<Shape> { cutArea });
Assert.False(result.HeadUp);
}
[Fact]
public void BlockedPath_ReturnsHeadUp()
{
var planner = new DirectRapidPlanner();
var cutArea = new Shape();
cutArea.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20)));
cutArea.Entities.Add(new Line(new Vector(5, 20), new Vector(6, 20)));
cutArea.Entities.Add(new Line(new Vector(6, 20), new Vector(6, 0)));
cutArea.Entities.Add(new Line(new Vector(6, 0), new Vector(5, 0)));
var result = planner.Plan(
new Vector(0, 10), new Vector(10, 10),
new List<Shape> { cutArea });
Assert.True(result.HeadUp);
Assert.Empty(result.Waypoints);
}
}
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.RapidPlanning;
public class SafeHeightRapidPlannerTests
{
[Fact]
public void AlwaysReturnsHeadUp()
{
var planner = new SafeHeightRapidPlanner();
var from = new Vector(10, 10);
var to = new Vector(50, 50);
var cutAreas = new List<Shape>();
var result = planner.Plan(from, to, cutAreas);
Assert.True(result.HeadUp);
Assert.Empty(result.Waypoints);
}
[Fact]
public void ReturnsHeadUp_EvenWithCutAreas()
{
var planner = new SafeHeightRapidPlanner();
var from = new Vector(0, 0);
var to = new Vector(10, 10);
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20)));
var cutAreas = new List<Shape> { shape };
var result = planner.Plan(from, to, cutAreas);
Assert.True(result.HeadUp);
}
}
@@ -0,0 +1,69 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class AdvancedSequencerTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y);
[Fact]
public void GroupsIntoRows_NoAlternate()
{
var plate = new Plate(100, 100);
var row1a = MakePartAt(10, 10);
var row1b = MakePartAt(30, 10);
var row2a = MakePartAt(10, 50);
var row2b = MakePartAt(30, 50);
plate.Parts.Add(row1a);
plate.Parts.Add(row1b);
plate.Parts.Add(row2a);
plate.Parts.Add(row2b);
var parameters = new SequenceParameters
{
Method = SequenceMethod.Advanced,
MinDistanceBetweenRowsColumns = 5.0,
AlternateRowsColumns = false
};
var sequencer = new AdvancedSequencer(parameters);
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(row1a, result[0].Part);
Assert.Same(row1b, result[1].Part);
Assert.Same(row2a, result[2].Part);
Assert.Same(row2b, result[3].Part);
}
[Fact]
public void SerpentineAlternatesDirection()
{
var plate = new Plate(100, 100);
var r1Left = MakePartAt(10, 10);
var r1Right = MakePartAt(30, 10);
var r2Left = MakePartAt(10, 50);
var r2Right = MakePartAt(30, 50);
plate.Parts.Add(r1Left);
plate.Parts.Add(r1Right);
plate.Parts.Add(r2Left);
plate.Parts.Add(r2Right);
var parameters = new SequenceParameters
{
Method = SequenceMethod.Advanced,
MinDistanceBetweenRowsColumns = 5.0,
AlternateRowsColumns = true
};
var sequencer = new AdvancedSequencer(parameters);
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(r1Left, result[0].Part);
Assert.Same(r1Right, result[1].Part);
Assert.Same(r2Right, result[2].Part);
Assert.Same(r2Left, result[3].Part);
}
}
@@ -0,0 +1,75 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class DirectionalSequencerTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y);
private static Plate MakePlate(params Part[] parts) => TestHelpers.MakePlate(60, 120, parts);
[Fact]
public void RightSide_SortsXDescending()
{
var a = MakePartAt(10, 5);
var b = MakePartAt(30, 5);
var c = MakePartAt(20, 5);
var plate = MakePlate(a, b, c);
var sequencer = new RightSideSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(b, result[0].Part);
Assert.Same(c, result[1].Part);
Assert.Same(a, result[2].Part);
}
[Fact]
public void LeftSide_SortsXAscending()
{
var a = MakePartAt(10, 5);
var b = MakePartAt(30, 5);
var c = MakePartAt(20, 5);
var plate = MakePlate(a, b, c);
var sequencer = new LeftSideSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(a, result[0].Part);
Assert.Same(c, result[1].Part);
Assert.Same(b, result[2].Part);
}
[Fact]
public void BottomSide_SortsYAscending()
{
var a = MakePartAt(5, 20);
var b = MakePartAt(5, 5);
var c = MakePartAt(5, 10);
var plate = MakePlate(a, b, c);
var sequencer = new BottomSideSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(b, result[0].Part);
Assert.Same(c, result[1].Part);
Assert.Same(a, result[2].Part);
}
[Fact]
public void RightSide_TiesBrokenByPerpendicularAxis()
{
var a = MakePartAt(10, 20);
var b = MakePartAt(10, 5);
var plate = MakePlate(a, b);
var sequencer = new RightSideSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(b, result[0].Part);
Assert.Same(a, result[1].Part);
}
}
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class EdgeStartSequencerTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y);
[Fact]
public void SortsByDistanceFromNearestEdge()
{
var plate = new Plate(60, 120);
var edgePart = MakePartAt(1, 1);
var centerPart = MakePartAt(25, 55);
var midPart = MakePartAt(10, 10);
plate.Parts.Add(edgePart);
plate.Parts.Add(centerPart);
plate.Parts.Add(midPart);
var sequencer = new EdgeStartSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(edgePart, result[0].Part);
Assert.Same(midPart, result[1].Part);
Assert.Same(centerPart, result[2].Part);
}
}
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class LeastCodeSequencerTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y);
[Fact]
public void NearestNeighbor_FromExitPoint()
{
var plate = new Plate(60, 120);
var farPart = MakePartAt(5, 5);
var nearPart = MakePartAt(55, 115);
plate.Parts.Add(farPart);
plate.Parts.Add(nearPart);
var sequencer = new LeastCodeSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
// nearPart is closer to exit point, should come first
Assert.Same(nearPart, result[0].Part);
Assert.Same(farPart, result[1].Part);
}
[Fact]
public void PreservesAllParts()
{
var plate = new Plate(60, 120);
for (var i = 0; i < 10; i++)
plate.Parts.Add(MakePartAt(i * 5, i * 10));
var sequencer = new LeastCodeSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Equal(10, result.Count);
}
[Fact]
public void TwoOpt_ImprovesSolution()
{
var plate = new Plate(100, 100);
var a = MakePartAt(90, 90);
var b = MakePartAt(10, 80);
var c = MakePartAt(80, 10);
var d = MakePartAt(5, 5);
plate.Parts.Add(a);
plate.Parts.Add(b);
plate.Parts.Add(c);
plate.Parts.Add(d);
var sequencer = new LeastCodeSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Equal(4, result.Count);
}
}
@@ -0,0 +1,30 @@
using System;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Sequencing;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class PartSequencerFactoryTests
{
[Theory]
[InlineData(SequenceMethod.RightSide, typeof(RightSideSequencer))]
[InlineData(SequenceMethod.LeftSide, typeof(LeftSideSequencer))]
[InlineData(SequenceMethod.BottomSide, typeof(BottomSideSequencer))]
[InlineData(SequenceMethod.EdgeStart, typeof(EdgeStartSequencer))]
[InlineData(SequenceMethod.LeastCode, typeof(LeastCodeSequencer))]
[InlineData(SequenceMethod.Advanced, typeof(AdvancedSequencer))]
public void Create_ReturnsCorrectType(SequenceMethod method, Type expectedType)
{
var parameters = new SequenceParameters { Method = method };
var sequencer = PartSequencerFactory.Create(parameters);
Assert.IsType(expectedType, sequencer);
}
[Fact]
public void Create_RightSideAlt_Throws()
{
var parameters = new SequenceParameters { Method = SequenceMethod.RightSideAlt };
Assert.Throws<NotSupportedException>(() => PartSequencerFactory.Create(parameters));
}
}
+27
View File
@@ -0,0 +1,27 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests;
internal static class TestHelpers
{
public static Part MakePartAt(double x, double y, double size = 1)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
pgm.Codes.Add(new LinearMove(new Vector(0, size)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("test", pgm);
return new Part(drawing, new Vector(x, y));
}
public static Plate MakePlate(double width = 60, double length = 120, params Part[] parts)
{
var plate = new Plate(width, length);
foreach (var p in parts)
plate.Parts.Add(p);
return plate;
}
}
+14
View File
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNest.Training\OpenNest.Training.csproj", "{249BF728-25DD-4863-8266-207ACD26E964}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.Tests\OpenNest.Tests.csproj", "{03539EB7-9DB2-4634-A6FD-F094B9603596}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -125,6 +127,18 @@ Global
{249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.Build.0 = Release|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.ActiveCfg = Release|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.Build.0 = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x64.ActiveCfg = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x64.Build.0 = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x86.ActiveCfg = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x86.Build.0 = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|Any CPU.Build.0 = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.ActiveCfg = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.Build.0 = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.ActiveCfg = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+2 -2
View File
@@ -186,8 +186,8 @@ namespace OpenNest.Actions
boxes.Add(part.BoundingBox.Offset(plate.PartSpacing));
var pt = plateView.CurrentPoint;
var vertical = Helper.GetLargestBoxVertically(pt, bounds, boxes);
var horizontal = Helper.GetLargestBoxHorizontally(pt, bounds, boxes);
var vertical = SpatialQuery.GetLargestBoxVertically(pt, bounds, boxes);
var horizontal = SpatialQuery.GetLargestBoxHorizontally(pt, bounds, boxes);
var bestArea = vertical;
if (horizontal.Area() > vertical.Area())
+2 -2
View File
@@ -47,7 +47,7 @@ namespace OpenNest.Actions
{
try
{
var engine = new NestEngine(plateView.Plate);
var engine = NestEngineRegistry.Create(plateView.Plate);
var parts = await Task.Run(() =>
engine.Fill(new NestItem { Drawing = drawing },
SelectedArea, progress, cts.Token));
@@ -61,7 +61,7 @@ namespace OpenNest.Actions
}
else
{
var engine = new NestEngine(plateView.Plate);
var engine = NestEngineRegistry.Create(plateView.Plate);
engine.Fill(new NestItem { Drawing = drawing }, SelectedArea);
plateView.Invalidate();
}
+2 -2
View File
@@ -150,8 +150,8 @@ namespace OpenNest.Actions
private void UpdateSelectedArea()
{
SelectedArea = altSelect
? Helper.GetLargestBoxHorizontally(plateView.CurrentPoint, Bounds, boxes)
: Helper.GetLargestBoxVertically(plateView.CurrentPoint, Bounds, boxes);
? SpatialQuery.GetLargestBoxHorizontally(plateView.CurrentPoint, Bounds, boxes)
: SpatialQuery.GetLargestBoxVertically(plateView.CurrentPoint, Bounds, boxes);
plateView.Invalidate();
}
+1 -1
View File
@@ -52,7 +52,7 @@ namespace OpenNest.Actions
{
var entities = ConvertProgram.ToGeometry(part.Program).Where(e => e.Layer == SpecialLayers.Cut).ToList();
entities.ForEach(entity => entity.Offset(part.Location));
var shapes = Helper.GetShapes(entities);
var shapes = ShapeBuilder.GetShapes(entities);
var shape = new Shape();
shape.Entities.AddRange(shapes);
ShapePartPairs.Add(new Pair() { Part = part, Shape = shape });
+6 -61
View File
@@ -833,11 +833,11 @@ namespace OpenNest.Controls
try
{
var engine = new NestEngine(Plate);
var engine = NestEngineRegistry.Create(Plate);
var parts = await Task.Run(() =>
engine.Fill(groupParts, workArea, progress, cts.Token));
if (parts.Count > 0)
if (parts.Count > 0 && !cts.IsCancellationRequested)
{
AcceptTemporaryParts();
sw.Stop();
@@ -937,65 +937,10 @@ namespace OpenNest.Controls
public void PushSelected(PushDirection direction)
{
var stationaryParts = parts.Where(p => !p.IsSelected && !SelectedParts.Contains(p)).ToList();
var stationaryBoxes = new Box[stationaryParts.Count];
var stationaryLines = new List<Line>[stationaryParts.Count];
var opposite = Helper.OppositeDirection(direction);
var halfSpacing = Plate.PartSpacing / 2;
var isHorizontal = Helper.IsHorizontalDirection(direction);
for (var i = 0; i < stationaryParts.Count; i++)
stationaryBoxes[i] = stationaryParts[i].BoundingBox;
var workArea = Plate.WorkArea();
var distance = double.MaxValue;
foreach (var selected in SelectedParts)
{
// Check plate edge first to tighten the upper bound.
var edgeDist = Helper.EdgeDistance(selected.BoundingBox, workArea, direction);
if (edgeDist > 0 && edgeDist < distance)
distance = edgeDist;
var movingBox = selected.BoundingBox;
List<Line> movingLines = null;
for (var i = 0; i < stationaryBoxes.Length; i++)
{
// Skip parts not ahead in the push direction or further than current best.
var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction);
if (gap < 0 || gap >= distance)
continue;
var perpOverlap = isHorizontal
? movingBox.IsHorizontalTo(stationaryBoxes[i], out _)
: movingBox.IsVerticalTo(stationaryBoxes[i], out _);
if (!perpOverlap)
continue;
// Compute lines lazily — only for parts that survive bounding box checks.
movingLines ??= halfSpacing > 0
? Helper.GetOffsetPartLines(selected.BasePart, halfSpacing, direction, OffsetTolerance)
: Helper.GetPartLines(selected.BasePart, direction, OffsetTolerance);
stationaryLines[i] ??= halfSpacing > 0
? Helper.GetOffsetPartLines(stationaryParts[i].BasePart, halfSpacing, opposite, OffsetTolerance)
: Helper.GetPartLines(stationaryParts[i].BasePart, opposite, OffsetTolerance);
var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction);
if (d < distance)
distance = d;
}
}
if (distance < double.MaxValue && distance > 0)
{
var offset = Helper.DirectionToOffset(direction, distance);
SelectedParts.ForEach(p => p.Offset(offset));
Invalidate();
}
var movingParts = SelectedParts.Select(p => p.BasePart).ToList();
Compactor.Push(movingParts, Plate, direction);
SelectedParts.ForEach(p => p.IsDirty = true);
Invalidate();
}
private string GetDisplayName(Type type)
+18 -12
View File
@@ -6,8 +6,12 @@ using System.IO;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Controls;
using OpenNest.Engine;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Engine.Sequencing;
using OpenNest.IO;
using OpenNest.Math;
using OpenNest.Properties;
@@ -438,27 +442,29 @@ namespace OpenNest.Forms
public void AutoSequenceCurrentPlate()
{
var seq = new SequenceByNearest();
var parts = seq.SequenceParts(PlateView.Plate.Parts);
PlateView.Plate.Parts.Clear();
PlateView.Plate.Parts.AddRange(parts);
SequencePlate(PlateView.Plate);
PlateView.Invalidate();
}
public void AutoSequenceAllPlates()
{
var seq = new SequenceByNearest();
foreach (var plate in Nest.Plates)
{
var parts = seq.SequenceParts(plate.Parts);
plate.Parts.Clear();
plate.Parts.AddRange(parts);
}
SequencePlate(plate);
PlateView.Invalidate();
}
private static void SequencePlate(Plate plate)
{
var parameters = new SequenceParameters { Method = SequenceMethod.LeastCode };
var sequencer = PartSequencerFactory.Create(parameters);
var ordered = sequencer.Sequence(plate.Parts.ToList(), plate);
plate.Parts.Clear();
for (var i = ordered.Count - 1; i >= 0; i--)
plate.Parts.Add(ordered[i].Part);
}
public void CalculateCurrentPlateCutTime()
{
var cutParamsForm = new CutParametersForm();
+729 -806
View File
File diff suppressed because it is too large Load Diff
+42 -11
View File
@@ -53,6 +53,15 @@ namespace OpenNest.Forms
if (GpuEvaluatorFactory.GpuAvailable)
BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer();
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
NestEngineRegistry.LoadPlugins(enginesDir);
foreach (var engine in NestEngineRegistry.AvailableEngines)
engineComboBox.Items.Add(engine.Name);
engineComboBox.SelectedItem = NestEngineRegistry.ActiveEngineName;
engineComboBox.SelectedIndexChanged += EngineComboBox_SelectedIndexChanged;
}
private Nest CreateDefaultNest()
@@ -249,6 +258,12 @@ namespace OpenNest.Forms
}
}
private void EngineComboBox_SelectedIndexChanged(object sender, EventArgs e)
{
if (engineComboBox.SelectedItem is string name)
NestEngineRegistry.ActiveEngineName = name;
}
private void UpdateLocationMode()
{
if (activeForm == null)
@@ -738,6 +753,15 @@ namespace OpenNest.Forms
nestingCts = new CancellationTokenSource();
var token = nestingCts.Token;
var progressForm = new NestProgressForm(nestingCts, showPlateRow: true);
var progress = new Progress<NestProgress>(p =>
{
progressForm.UpdateProgress(p);
activeForm.PlateView.SetTemporaryParts(p.BestParts);
});
progressForm.Show(this);
SetNestingLockout(true);
try
@@ -761,32 +785,39 @@ namespace OpenNest.Forms
if (plate != activeForm.PlateView.Plate)
activeForm.LoadLastPlate();
var parts = await Task.Run(() =>
AutoNester.Nest(remaining, plate, token));
var anyPlaced = false;
if (parts.Count == 0)
break;
var engine = NestEngineRegistry.Create(plate);
engine.PlateNumber = plateCount;
plate.Parts.AddRange(parts);
activeForm.PlateView.Invalidate();
var nestParts = await Task.Run(() =>
engine.Nest(remaining, progress, token));
// Deduct placed quantities using Drawing.Name to avoid reference issues.
foreach (var item in remaining)
activeForm.PlateView.ClearTemporaryParts();
if (nestParts.Count > 0 && !token.IsCancellationRequested)
{
var placed = parts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
plate.Parts.AddRange(nestParts);
activeForm.PlateView.Invalidate();
anyPlaced = true;
}
if (!anyPlaced)
break;
}
activeForm.Nest.UpdateDrawingQuantities();
progressForm.ShowCompleted();
}
catch (Exception ex)
{
activeForm.PlateView.ClearTemporaryParts();
MessageBox.Show($"Nesting error: {ex.Message}", "Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
progressForm.Close();
SetNestingLockout(false);
nestingCts.Dispose();
nestingCts = null;
@@ -871,7 +902,7 @@ namespace OpenNest.Forms
try
{
var plate = activeForm.PlateView.Plate;
var engine = new NestEngine(plate);
var engine = NestEngineRegistry.Create(plate);
var parts = await Task.Run(() =>
engine.Fill(new NestItem { Drawing = drawing },
+27 -27
View File
@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
+1 -1
View File
@@ -134,7 +134,7 @@ namespace OpenNest
{
var result = new List<PointF[]>();
var entities = ConvertProgram.ToGeometry(BasePart.Program);
var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
foreach (var shape in shapes)
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,867 @@
# Abstract Nest Engine Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor the concrete `NestEngine` into an abstract `NestEngineBase` with pluggable implementations, a registry for engine discovery/selection, and plugin loading from DLLs.
**Architecture:** Extract shared state and utilities into `NestEngineBase` (abstract). Current logic becomes `DefaultNestEngine`. `NestEngineRegistry` provides factory creation, built-in registration, and DLL plugin discovery. All callsites migrate from `new NestEngine(plate)` to `NestEngineRegistry.Create(plate)`.
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Mcp, OpenNest.Console
**Spec:** `docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md`
**Deferred:** `StripNester.cs``StripNestEngine.cs` conversion is deferred to the strip nester implementation plan (`docs/superpowers/plans/2026-03-15-strip-nester.md`). That plan should be updated to create `StripNestEngine` as a `NestEngineBase` subclass and register it in `NestEngineRegistry`. The UI engine selector combobox is also deferred — it can be added once there are multiple engines to choose from.
---
## Chunk 1: NestEngineBase and DefaultNestEngine
### Task 1: Create NestEngineBase abstract class
**Files:**
- Create: `OpenNest.Engine/NestEngineBase.cs`
This is the abstract base class. It holds shared properties, abstract `Name`/`Description`, virtual methods that return empty lists by default, convenience overloads that mutate the plate, `FillExact` (non-virtual), and protected utility methods extracted from the current `NestEngine`.
- [ ] **Step 1: Create NestEngineBase.cs**
```csharp
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest
{
public abstract class NestEngineBase
{
protected NestEngineBase(Plate plate)
{
Plate = plate;
}
public Plate Plate { get; set; }
public int PlateNumber { get; set; }
public NestDirection NestDirection { get; set; }
public NestPhase WinnerPhase { get; protected set; }
public List<PhaseResult> PhaseResults { get; } = new();
public List<AngleResult> AngleResults { get; } = new();
public abstract string Name { get; }
public abstract string Description { get; }
// --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
public virtual List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
public virtual List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
// --- FillExact (non-virtual, delegates to virtual Fill) ---
public List<Part> FillExact(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return Fill(item, workArea, progress, token);
}
// --- Convenience overloads (mutate plate, return bool) ---
public bool Fill(NestItem item)
{
return Fill(item, Plate.WorkArea());
}
public bool Fill(NestItem item, Box workArea)
{
var parts = Fill(item, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
public bool Fill(List<Part> groupParts)
{
return Fill(groupParts, Plate.WorkArea());
}
public bool Fill(List<Part> groupParts, Box workArea)
{
var parts = Fill(groupParts, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
public bool Pack(List<NestItem> items)
{
var workArea = Plate.WorkArea();
var parts = PackArea(workArea, items, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
// --- Protected utilities ---
protected static void ReportProgress(
IProgress<NestProgress> progress,
NestPhase phase,
int plateNumber,
List<Part> best,
Box workArea,
string description)
{
if (progress == null || best == null || best.Count == 0)
return;
var score = FillScore.Compute(best, workArea);
var clonedParts = new List<Part>(best.Count);
var totalPartArea = 0.0;
foreach (var part in best)
{
clonedParts.Add((Part)part.Clone());
totalPartArea += part.BaseDrawing.Area;
}
var bounds = best.GetBoundingBox();
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
Debug.WriteLine(msg);
try { System.IO.File.AppendAllText(
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n"); } catch { }
progress.Report(new NestProgress
{
Phase = phase,
PlateNumber = plateNumber,
BestPartCount = score.Count,
BestDensity = score.Density,
NestedWidth = bounds.Width,
NestedLength = bounds.Length,
NestedArea = totalPartArea,
UsableRemnantArea = workArea.Area() - totalPartArea,
BestParts = clonedParts,
Description = description
});
}
protected string BuildProgressSummary()
{
if (PhaseResults.Count == 0)
return null;
var parts = new List<string>(PhaseResults.Count);
foreach (var r in PhaseResults)
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
return string.Join(" | ", parts);
}
protected bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
}
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
{
Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})");
return false;
}
return IsBetterFill(candidate, current, workArea);
}
protected static bool HasOverlaps(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)
return false;
for (var i = 0; i < parts.Count; i++)
{
var box1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var box2 = parts[j].BoundingBox;
if (box1.Right < box2.Left || box2.Right < box1.Left ||
box1.Top < box2.Bottom || box2.Top < box1.Bottom)
continue;
List<Vector> pts;
if (parts[i].Intersects(parts[j], out pts))
{
var b1 = parts[i].BoundingBox;
var b2 = parts[j].BoundingBox;
Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" +
$" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" +
$" intersections={pts?.Count ?? 0}");
return true;
}
}
}
return false;
}
protected static string FormatPhaseName(NestPhase phase)
{
switch (phase)
{
case NestPhase.Pairs: return "Pairs";
case NestPhase.Linear: return "Linear";
case NestPhase.RectBestFit: return "BestFit";
case NestPhase.Remainder: return "Remainder";
default: return phase.ToString();
}
}
}
}
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngineBase.cs
git commit -m "feat: add NestEngineBase abstract class"
```
---
### Task 2: Convert NestEngine to DefaultNestEngine
**Files:**
- Rename: `OpenNest.Engine/NestEngine.cs``OpenNest.Engine/DefaultNestEngine.cs`
Rename the class, make it inherit `NestEngineBase`, add `Name`/`Description`, change the virtual methods to `override`, and remove methods that now live in the base class (convenience overloads, `ReportProgress`, `BuildProgressSummary`, `IsBetterFill`, `IsBetterValidFill`, `HasOverlaps`, `FormatPhaseName`, `FillExact`).
- [ ] **Step 1: Rename the file**
```bash
git mv OpenNest.Engine/NestEngine.cs OpenNest.Engine/DefaultNestEngine.cs
```
- [ ] **Step 2: Update class declaration and add inheritance**
In `DefaultNestEngine.cs`, change the class declaration from:
```csharp
public class NestEngine
{
public NestEngine(Plate plate)
{
Plate = plate;
}
public Plate Plate { get; set; }
public NestDirection NestDirection { get; set; }
public int PlateNumber { get; set; }
public NestPhase WinnerPhase { get; private set; }
public List<PhaseResult> PhaseResults { get; } = new();
public bool ForceFullAngleSweep { get; set; }
public List<AngleResult> AngleResults { get; } = new();
```
To:
```csharp
public class DefaultNestEngine : NestEngineBase
{
public DefaultNestEngine(Plate plate) : base(plate)
{
}
public override string Name => "Default";
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
public bool ForceFullAngleSweep { get; set; }
```
This removes properties that now come from the base class (`Plate`, `PlateNumber`, `NestDirection`, `WinnerPhase`, `PhaseResults`, `AngleResults`).
- [ ] **Step 3: Convert the convenience Fill overloads to override the virtual methods**
Remove the non-progress `Fill` convenience overloads (they are now in the base class). The two remaining `Fill` methods that take `IProgress<NestProgress>` and `CancellationToken` become overrides.
Change:
```csharp
public List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
```
To:
```csharp
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
```
Change:
```csharp
public List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
```
To:
```csharp
public override List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
```
Remove these methods entirely (now in base class):
- `bool Fill(NestItem item)` (2-arg convenience)
- `bool Fill(NestItem item, Box workArea)` (convenience that calls the 4-arg)
- `bool Fill(List<Part> groupParts)` (convenience)
- `bool Fill(List<Part> groupParts, Box workArea)` (convenience that calls the 4-arg)
- `FillExact` (now in base class)
- `ReportProgress` (now in base class)
- `BuildProgressSummary` (now in base class)
- `IsBetterFill` (now in base class)
- `IsBetterValidFill` (now in base class)
- `HasOverlaps` (now in base class)
- `FormatPhaseName` (now in base class)
- [ ] **Step 4: Convert Pack/PackArea to override**
Remove `Pack(List<NestItem>)` (now in base class).
Convert `PackArea` to override with the new signature. Replace:
```csharp
public bool Pack(List<NestItem> items)
{
var workArea = Plate.WorkArea();
return PackArea(workArea, items);
}
public bool PackArea(Box box, List<NestItem> items)
{
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
var engine = new PackBottomLeft(bin);
engine.Pack(binItems);
var parts = BinConverter.ToParts(bin, items);
Plate.Parts.AddRange(parts);
return parts.Count > 0;
}
```
With:
```csharp
public override List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
var engine = new PackBottomLeft(bin);
engine.Pack(binItems);
return BinConverter.ToParts(bin, items);
}
```
Note: the `progress` and `token` parameters are not used yet in the default rectangle packing — the contract is there for engines that need them.
- [ ] **Step 5: Update BruteForceRunner to use DefaultNestEngine**
`BruteForceRunner.cs` is in the same project and still references `NestEngine`. It must be updated before the Engine project can compile. This is the one callsite that stays as a direct `DefaultNestEngine` reference (not via registry) because training data must come from the known algorithm.
In `OpenNest.Engine/ML/BruteForceRunner.cs`, change line 30:
```csharp
var engine = new NestEngine(plate);
```
To:
```csharp
var engine = new DefaultNestEngine(plate);
```
- [ ] **Step 6: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded (other projects will have errors since their callsites still reference `NestEngine` — fixed in Chunk 3)
- [ ] **Step 7: Commit**
```bash
git add OpenNest.Engine/DefaultNestEngine.cs OpenNest.Engine/ML/BruteForceRunner.cs
git commit -m "refactor: rename NestEngine to DefaultNestEngine, inherit NestEngineBase"
```
---
## Chunk 2: NestEngineRegistry and NestEngineInfo
### Task 3: Create NestEngineInfo
**Files:**
- Create: `OpenNest.Engine/NestEngineInfo.cs`
- [ ] **Step 1: Create NestEngineInfo.cs**
```csharp
using System;
namespace OpenNest
{
public class NestEngineInfo
{
public NestEngineInfo(string name, string description, Func<Plate, NestEngineBase> factory)
{
Name = name;
Description = description;
Factory = factory;
}
public string Name { get; }
public string Description { get; }
public Func<Plate, NestEngineBase> Factory { get; }
}
}
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngineInfo.cs
git commit -m "feat: add NestEngineInfo metadata class"
```
---
### Task 4: Create NestEngineRegistry
**Files:**
- Create: `OpenNest.Engine/NestEngineRegistry.cs`
Static class with built-in registration, plugin loading, active engine selection, and factory creation.
- [ ] **Step 1: Create NestEngineRegistry.cs**
```csharp
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
namespace OpenNest
{
public static class NestEngineRegistry
{
private static readonly List<NestEngineInfo> engines = new();
static NestEngineRegistry()
{
Register("Default",
"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
plate => new DefaultNestEngine(plate));
}
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;
public static string ActiveEngineName { get; set; } = "Default";
public static NestEngineBase Create(Plate plate)
{
var info = engines.FirstOrDefault(e =>
e.Name.Equals(ActiveEngineName, StringComparison.OrdinalIgnoreCase));
if (info == null)
{
Debug.WriteLine($"[NestEngineRegistry] Engine '{ActiveEngineName}' not found, falling back to Default");
info = engines[0];
}
return info.Factory(plate);
}
public static void Register(string name, string description, Func<Plate, NestEngineBase> factory)
{
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
Debug.WriteLine($"[NestEngineRegistry] Duplicate engine '{name}' skipped");
return;
}
engines.Add(new NestEngineInfo(name, description, factory));
}
public static void LoadPlugins(string directory)
{
if (!Directory.Exists(directory))
return;
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
{
try
{
var assembly = Assembly.LoadFrom(dll);
foreach (var type in assembly.GetTypes())
{
if (type.IsAbstract || !typeof(NestEngineBase).IsAssignableFrom(type))
continue;
var ctor = type.GetConstructor(new[] { typeof(Plate) });
if (ctor == null)
{
Debug.WriteLine($"[NestEngineRegistry] Skipping {type.Name}: no Plate constructor");
continue;
}
// Create a temporary instance to read Name and Description.
try
{
var tempPlate = new Plate();
var instance = (NestEngineBase)ctor.Invoke(new object[] { tempPlate });
Register(instance.Name, instance.Description,
plate => (NestEngineBase)ctor.Invoke(new object[] { plate }));
Debug.WriteLine($"[NestEngineRegistry] Loaded plugin engine: {instance.Name}");
}
catch (Exception ex)
{
Debug.WriteLine($"[NestEngineRegistry] Failed to instantiate {type.Name}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Debug.WriteLine($"[NestEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}");
}
}
}
}
}
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngineRegistry.cs
git commit -m "feat: add NestEngineRegistry with built-in registration and plugin loading"
```
---
## Chunk 3: Callsite Migration
### Task 5: Migrate OpenNest.Mcp callsites
**Files:**
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs`
Six `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`. The `PackArea` call on line 276 changes signature since `PackArea` now returns `List<Part>` instead of mutating the plate.
- [ ] **Step 1: Replace all NestEngine instantiations**
In `NestingTools.cs`, replace all six occurrences of `new NestEngine(plate)` with `NestEngineRegistry.Create(plate)`.
Lines to change:
- Line 37: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- Line 73: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- Line 114: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- Line 176: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- Line 255: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- Line 275: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- [ ] **Step 2: Fix PackArea call in AutoNestPlate**
The old code on line 276 was:
```csharp
engine.PackArea(workArea, packItems);
```
This used the old `bool PackArea(Box, List<NestItem>)` which mutated the plate. The new virtual method returns `List<Part>`. Use the convenience `Pack`-like pattern instead. Replace lines 274-277:
```csharp
var before = plate.Parts.Count;
var engine = new NestEngine(plate);
engine.PackArea(workArea, packItems);
totalPlaced += plate.Parts.Count - before;
```
With:
```csharp
var engine = NestEngineRegistry.Create(plate);
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
if (packParts.Count > 0)
{
plate.Parts.AddRange(packParts);
totalPlaced += packParts.Count;
}
```
- [ ] **Step 3: Build OpenNest.Mcp**
Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj`
Expected: Build succeeded
- [ ] **Step 4: Commit**
```bash
git add OpenNest.Mcp/Tools/NestingTools.cs
git commit -m "refactor: migrate NestingTools to NestEngineRegistry"
```
---
### Task 6: Migrate OpenNest.Console callsites
**Files:**
- Modify: `OpenNest.Console/Program.cs`
Three `new NestEngine(plate)` calls. The `PackArea` call also needs the same signature update.
- [ ] **Step 1: Replace NestEngine instantiations**
In `Program.cs`, replace:
- Line 351: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- Line 380: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- [ ] **Step 2: Fix PackArea call**
Replace lines 370-372:
```csharp
var engine = new NestEngine(plate);
var before = plate.Parts.Count;
engine.PackArea(workArea, packItems);
```
With:
```csharp
var engine = NestEngineRegistry.Create(plate);
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
plate.Parts.AddRange(packParts);
```
And update line 374-375 from:
```csharp
if (plate.Parts.Count > before)
success = true;
```
To:
```csharp
if (packParts.Count > 0)
success = true;
```
- [ ] **Step 3: Build OpenNest.Console**
Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj`
Expected: Build succeeded
- [ ] **Step 4: Commit**
```bash
git add OpenNest.Console/Program.cs
git commit -m "refactor: migrate Console Program to NestEngineRegistry"
```
---
### Task 7: Migrate OpenNest WinForms callsites
**Files:**
- Modify: `OpenNest/Actions/ActionFillArea.cs`
- Modify: `OpenNest/Controls/PlateView.cs`
- Modify: `OpenNest/Forms/MainForm.cs`
- [ ] **Step 1: Migrate ActionFillArea.cs**
In `ActionFillArea.cs`, replace both `new NestEngine(plateView.Plate)` calls:
- Line 50: `var engine = new NestEngine(plateView.Plate);``var engine = NestEngineRegistry.Create(plateView.Plate);`
- Line 64: `var engine = new NestEngine(plateView.Plate);``var engine = NestEngineRegistry.Create(plateView.Plate);`
- [ ] **Step 2: Migrate PlateView.cs**
In `PlateView.cs`, replace:
- Line 836: `var engine = new NestEngine(Plate);``var engine = NestEngineRegistry.Create(Plate);`
- [ ] **Step 3: Migrate MainForm.cs**
In `MainForm.cs`, replace all three `new NestEngine(plate)` calls:
- Line 797: `var engine = new NestEngine(plate) { PlateNumber = plateCount };``var engine = NestEngineRegistry.Create(plate); engine.PlateNumber = plateCount;`
- Line 829: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- Line 965: `var engine = new NestEngine(plate);``var engine = NestEngineRegistry.Create(plate);`
- [ ] **Step 4: Fix MainForm PackArea call**
In `MainForm.cs`, the auto-nest pack phase (around line 829-832) uses the old `PackArea` signature. Replace:
```csharp
var engine = new NestEngine(plate);
var partsBefore = plate.Parts.Count;
engine.PackArea(workArea, packItems);
var packed = plate.Parts.Count - partsBefore;
```
With:
```csharp
var engine = NestEngineRegistry.Create(plate);
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
plate.Parts.AddRange(packParts);
var packed = packParts.Count;
```
- [ ] **Step 5: Add plugin loading at startup**
In `MainForm.cs`, find where post-processors are loaded at startup (look for `Posts` directory loading) and add engine plugin loading nearby. Add after the existing plugin loading:
```csharp
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
NestEngineRegistry.LoadPlugins(enginesDir);
```
If there is no explicit post-processor loading call visible, add this to the `MainForm` constructor or `Load` event.
- [ ] **Step 6: Build the full solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded with no errors
- [ ] **Step 7: Commit**
```bash
git add OpenNest/Actions/ActionFillArea.cs OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs
git commit -m "refactor: migrate WinForms callsites to NestEngineRegistry"
```
---
## Chunk 4: Verification and Cleanup
### Task 8: Verify no remaining NestEngine references
**Files:**
- No changes expected — verification only
- [ ] **Step 1: Search for stale references**
Run: `grep -rn "new NestEngine(" --include="*.cs" .`
Expected: Only `BruteForceRunner.cs` should have `new DefaultNestEngine(`. No `new NestEngine(` references should remain.
Also run: `grep -rn "class NestEngine[^B]" --include="*.cs" .`
Expected: No matches (the old `class NestEngine` no longer exists).
- [ ] **Step 2: Build and run smoke test**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded, 0 errors, 0 warnings related to NestEngine
- [ ] **Step 3: Publish MCP server**
Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"`
Expected: Publish succeeded
- [ ] **Step 4: Commit if any fixes were needed**
If any issues were found and fixed in previous steps, commit them now.
---
### Task 9: Update CLAUDE.md architecture documentation
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: Update architecture section**
Update the `### OpenNest.Engine` section in `CLAUDE.md` to document the new engine hierarchy:
- `NestEngineBase` is the abstract base class
- `DefaultNestEngine` is the current multi-phase engine (formerly `NestEngine`)
- `NestEngineRegistry` manages available engines and the active selection
- `NestEngineInfo` holds engine metadata
- Plugin engines loaded from `Engines/` directory
Also update any references to `NestEngine` that should now say `DefaultNestEngine` or `NestEngineBase`.
- [ ] **Step 2: Build to verify no docs broke anything**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md for abstract nest engine architecture"
```
@@ -0,0 +1,462 @@
# FillExact Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `FillExact` method to `NestEngine` that binary-searches for the smallest work area sub-region that fits an exact quantity of parts, then integrate it into AutoNest.
**Architecture:** `FillExact` wraps the existing `Fill(NestItem, Box, IProgress, CancellationToken)` method. It calls Fill repeatedly with progressively smaller test boxes (binary search on one dimension, both orientations), picks the tightest fit, then re-runs the winner with progress reporting. Callers swap `Fill` for `FillExact` — no other engine changes needed.
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Console, OpenNest.Mcp
**Spec:** `docs/superpowers/specs/2026-03-15-fill-exact-design.md`
---
## Chunk 1: Core Implementation
### Task 1: Add `BinarySearchFill` helper to NestEngine
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs` (add private method after the existing `Fill` overloads, around line 85)
- [ ] **Step 1: Add the BinarySearchFill method**
Add after the `Fill(NestItem, Box, IProgress, CancellationToken)` method (line 85):
```csharp
/// <summary>
/// Binary-searches for the smallest sub-area (one dimension fixed) that fits
/// exactly item.Quantity parts. Returns the best parts list and the dimension
/// value that achieved it.
/// </summary>
private (List<Part> parts, double usedDim) BinarySearchFill(
NestItem item, Box workArea, bool shrinkWidth,
CancellationToken token)
{
var quantity = item.Quantity;
var partBox = item.Drawing.Program.BoundingBox();
var partArea = item.Drawing.Area;
// Fixed and variable dimensions.
var fixedDim = shrinkWidth ? workArea.Length : workArea.Width;
var highDim = shrinkWidth ? workArea.Width : workArea.Length;
// Estimate starting point: target area at 50% utilization.
var targetArea = partArea * quantity / 0.5;
var minPartDim = shrinkWidth
? partBox.Width + Plate.PartSpacing
: partBox.Length + Plate.PartSpacing;
var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim);
var low = estimatedDim;
var high = highDim;
List<Part> bestParts = null;
var bestDim = high;
for (var iter = 0; iter < 8; iter++)
{
if (token.IsCancellationRequested)
break;
if (high - low < Plate.PartSpacing)
break;
var mid = (low + high) / 2.0;
var testBox = shrinkWidth
? new Box(workArea.X, workArea.Y, mid, workArea.Length)
: new Box(workArea.X, workArea.Y, workArea.Width, mid);
var result = Fill(item, testBox, null, token);
if (result.Count >= quantity)
{
bestParts = result.Count > quantity
? result.Take(quantity).ToList()
: result;
bestDim = mid;
high = mid;
}
else
{
low = mid;
}
}
return (bestParts, bestDim);
}
```
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat(engine): add BinarySearchFill helper for exact-quantity search"
```
---
### Task 2: Add `FillExact` public method to NestEngine
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs` (add public method after the existing `Fill` overloads, before `BinarySearchFill`)
- [ ] **Step 1: Add the FillExact method**
Add between the `Fill(NestItem, Box, IProgress, CancellationToken)` method and `BinarySearchFill`:
```csharp
/// <summary>
/// Finds the smallest sub-area of workArea that fits exactly item.Quantity parts.
/// Uses binary search on both orientations and picks the tightest fit.
/// Falls through to standard Fill for unlimited (0) or single (1) quantities.
/// </summary>
public List<Part> FillExact(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
// Early exits: unlimited or single quantity — no benefit from area search.
if (item.Quantity <= 1)
return Fill(item, workArea, progress, token);
// Full fill to establish upper bound.
var fullResult = Fill(item, workArea, progress, token);
if (fullResult.Count <= item.Quantity)
return fullResult;
// Binary search: try shrinking each dimension.
var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token);
var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token);
// Pick winner by smallest test box area. Tie-break: prefer shrink-length.
List<Part> winner;
Box winnerBox;
var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue;
var widthArea = widthParts != null ? widthDim * workArea.Length : double.MaxValue;
if (lengthParts != null && lengthArea <= widthArea)
{
winner = lengthParts;
winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim);
}
else if (widthParts != null)
{
winner = widthParts;
winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length);
}
else
{
// Neither search found the exact quantity — return full fill truncated.
return fullResult.Take(item.Quantity).ToList();
}
// Re-run the winner with progress so PhaseResults/WinnerPhase are correct
// and the progress form shows the final result.
var finalResult = Fill(item, winnerBox, progress, token);
if (finalResult.Count >= item.Quantity)
return finalResult.Count > item.Quantity
? finalResult.Take(item.Quantity).ToList()
: finalResult;
// Fallback: return the binary search result if the re-run produced fewer.
return winner;
}
```
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat(engine): add FillExact method for exact-quantity nesting"
```
---
### Task 3: Add Compactor class to Engine
**Files:**
- Create: `OpenNest.Engine/Compactor.cs`
- [ ] **Step 1: Create the Compactor class**
Create `OpenNest.Engine/Compactor.cs`:
```csharp
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// Pushes a group of parts left and down to close gaps after placement.
/// Uses the same directional-distance logic as PlateView.PushSelected
/// but operates on Part objects directly.
/// </summary>
public static class Compactor
{
private const double ChordTolerance = 0.001;
/// <summary>
/// Compacts movingParts toward the bottom-left of the plate work area.
/// Everything already on the plate (excluding movingParts) is treated
/// as stationary obstacles.
/// </summary>
public static void Compact(List<Part> movingParts, Plate plate)
{
if (movingParts == null || movingParts.Count == 0)
return;
Push(movingParts, plate, PushDirection.Left);
Push(movingParts, plate, PushDirection.Down);
}
private static void Push(List<Part> movingParts, Plate plate, PushDirection direction)
{
var stationaryParts = plate.Parts
.Where(p => !movingParts.Contains(p))
.ToList();
var stationaryBoxes = new Box[stationaryParts.Count];
for (var i = 0; i < stationaryParts.Count; i++)
stationaryBoxes[i] = stationaryParts[i].BoundingBox;
var stationaryLines = new List<Line>[stationaryParts.Count];
var opposite = Helper.OppositeDirection(direction);
var halfSpacing = plate.PartSpacing / 2;
var isHorizontal = Helper.IsHorizontalDirection(direction);
var workArea = plate.WorkArea();
foreach (var moving in movingParts)
{
var distance = double.MaxValue;
var movingBox = moving.BoundingBox;
// Plate edge distance.
var edgeDist = Helper.EdgeDistance(movingBox, workArea, direction);
if (edgeDist > 0 && edgeDist < distance)
distance = edgeDist;
List<Line> movingLines = null;
for (var i = 0; i < stationaryBoxes.Length; i++)
{
var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction);
if (gap < 0 || gap >= distance)
continue;
var perpOverlap = isHorizontal
? movingBox.IsHorizontalTo(stationaryBoxes[i], out _)
: movingBox.IsVerticalTo(stationaryBoxes[i], out _);
if (!perpOverlap)
continue;
movingLines ??= halfSpacing > 0
? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
: Helper.GetPartLines(moving, direction, ChordTolerance);
stationaryLines[i] ??= halfSpacing > 0
? Helper.GetOffsetPartLines(stationaryParts[i], halfSpacing, opposite, ChordTolerance)
: Helper.GetPartLines(stationaryParts[i], opposite, ChordTolerance);
var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction);
if (d < distance)
distance = d;
}
if (distance < double.MaxValue && distance > 0)
{
var offset = Helper.DirectionToOffset(direction, distance);
moving.Offset(offset);
// Update this part's bounding box in the stationary set for
// subsequent moving parts to collide against correctly.
// (Parts already pushed become obstacles for the next part.)
}
}
}
}
}
```
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/Compactor.cs
git commit -m "feat(engine): add Compactor for post-fill gravity compaction"
```
---
## Chunk 2: Integration
### Task 4: Integrate FillExact and Compactor into AutoNest (MainForm)
**Files:**
- Modify: `OpenNest/Forms/MainForm.cs` (RunAutoNest_Click, around lines 797-815)
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
In `RunAutoNest_Click`, change the Fill call and the block after it (around lines 799-815). Replace:
```csharp
var parts = await Task.Run(() =>
engine.Fill(item, workArea, progress, token));
```
with:
```csharp
var parts = await Task.Run(() =>
engine.FillExact(item, workArea, progress, token));
```
Then after `plate.Parts.AddRange(parts);` and before `ComputeRemainderStrip`, add the compaction call:
```csharp
plate.Parts.AddRange(parts);
Compactor.Compact(parts, plate);
activeForm.PlateView.Invalidate();
```
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.sln --nologo -v q`
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 3: Commit**
```bash
git add OpenNest/Forms/MainForm.cs
git commit -m "feat(ui): use FillExact + Compactor in AutoNest"
```
---
### Task 5: Integrate FillExact and Compactor into Console app
**Files:**
- Modify: `OpenNest.Console/Program.cs` (around lines 346-360)
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
Change the Fill call (around line 352) from:
```csharp
var parts = engine.Fill(item, workArea, null, CancellationToken.None);
```
to:
```csharp
var parts = engine.FillExact(item, workArea, null, CancellationToken.None);
```
Then after `plate.Parts.AddRange(parts);` add the compaction call:
```csharp
plate.Parts.AddRange(parts);
Compactor.Compact(parts, plate);
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
```
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj --nologo -v q`
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Console/Program.cs
git commit -m "feat(console): use FillExact + Compactor in --autonest"
```
---
### Task 6: Integrate FillExact and Compactor into MCP server
**Files:**
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` (around lines 255-264)
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
Change the Fill call (around line 256) from:
```csharp
var parts = engine.Fill(item, workArea, null, CancellationToken.None);
```
to:
```csharp
var parts = engine.FillExact(item, workArea, null, CancellationToken.None);
```
Then after `plate.Parts.AddRange(parts);` add the compaction call:
```csharp
plate.Parts.AddRange(parts);
Compactor.Compact(parts, plate);
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
```
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj --nologo -v q`
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Mcp/Tools/NestingTools.cs
git commit -m "feat(mcp): use FillExact in autonest_plate for tighter packing"
```
---
## Chunk 3: Verification
### Task 7: End-to-end test via Console
- [ ] **Step 1: Run AutoNest with qty > 1 and verify tighter packing**
Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --quantity 10 --no-save "C:\Users\AJ\Desktop\N0312-002.zip"`
Verify:
- Completes without error
- Parts placed count is reasonable (not 0, not wildly over-placed)
- Utilization is reported
- [ ] **Step 2: Run with qty=1 to verify fallback path**
Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --no-save "C:\Users\AJ\Desktop\N0312-002.zip"`
Verify:
- Completes quickly (qty=1 goes through Pack, no binary search)
- Parts placed > 0
- [ ] **Step 3: Build full solution one final time**
Run: `dotnet build OpenNest.sln --nologo -v q`
Expected: `Build succeeded. 0 Error(s)`
@@ -0,0 +1,350 @@
# Helper Class Decomposition
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Break the 1,464-line `Helper` catch-all class into focused, single-responsibility static classes.
**Architecture:** Extract six logical groups from `Helper` into dedicated classes. Each extraction creates a new file, moves methods, updates all call sites, and verifies with `dotnet build`. The original `Helper.cs` is deleted once empty. No behavioral changes — pure mechanical refactoring.
**Tech Stack:** .NET 8, C# 12
---
## File Structure
| New File | Namespace | Responsibility | Methods Moved |
|----------|-----------|----------------|---------------|
| `OpenNest.Core/Math/Rounding.cs` | `OpenNest.Math` | Factor-based rounding | `RoundDownToNearest`, `RoundUpToNearest`, `RoundToNearest` |
| `OpenNest.Core/Geometry/GeometryOptimizer.cs` | `OpenNest.Geometry` | Merge collinear lines / coradial arcs | `Optimize(arcs)`, `Optimize(lines)`, `TryJoinLines`, `TryJoinArcs`, `GetCollinearLines`, `GetCoradialArs` |
| `OpenNest.Core/Geometry/ShapeBuilder.cs` | `OpenNest.Geometry` | Chain entities into shapes | `GetShapes`, `GetConnected` |
| `OpenNest.Core/Geometry/Intersect.cs` | `OpenNest.Geometry` | All intersection algorithms | 16 `Intersects` overloads |
| `OpenNest.Core/PartGeometry.cs` | `OpenNest` | Convert Parts to line geometry | `GetPartLines` (×2), `GetOffsetPartLines` (×2), `GetDirectionalLines` |
| `OpenNest.Core/Geometry/SpatialQuery.cs` | `OpenNest.Geometry` | Directional distance, ray casting, box queries | `RayEdgeDistance` (×2), `DirectionalDistance` (×3), `FlattenLines`, `OneWayDistance`, `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionToOffset`, `DirectionalGap`, `ClosestDistance*` (×4), `GetLargestBox*` (×2) |
**Files modified (call-site updates):**
| File | Methods Referenced |
|------|--------------------|
| `OpenNest.Core/Plate.cs` | `RoundUpToNearest``Rounding.RoundUpToNearest` |
| `OpenNest.IO/DxfImporter.cs` | `Optimize``GeometryOptimizer.Optimize` |
| `OpenNest.Core/Geometry/Shape.cs` | `Optimize``GeometryOptimizer.Optimize`, `Intersects``Intersect.Intersects` |
| `OpenNest.Core/Drawing.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Core/Timing.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Core/Converters/ConvertGeometry.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Core/Geometry/ShapeProfile.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Core/Geometry/Arc.cs` | `Intersects``Intersect.Intersects` |
| `OpenNest.Core/Geometry/Circle.cs` | `Intersects``Intersect.Intersects` |
| `OpenNest.Core/Geometry/Line.cs` | `Intersects``Intersect.Intersects` |
| `OpenNest.Core/Geometry/Polygon.cs` | `Intersects``Intersect.Intersects` |
| `OpenNest/LayoutPart.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest/Actions/ActionSetSequence.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest/Actions/ActionSelectArea.cs` | `GetLargestBox*``SpatialQuery.GetLargestBox*` |
| `OpenNest/Actions/ActionClone.cs` | `GetLargestBox*``SpatialQuery.GetLargestBox*` |
| `OpenNest.Gpu/PartBitmap.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Gpu/GpuPairEvaluator.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Engine/RotationAnalysis.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Engine/BestFit/BestFitFinder.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Engine/BestFit/PairEvaluator.cs` | `GetShapes``ShapeBuilder.GetShapes` |
| `OpenNest.Engine/FillLinear.cs` | `DirectionalDistance`, `OppositeDirection``SpatialQuery.*` |
| `OpenNest.Engine/Compactor.cs` | Multiple `Helper.*``SpatialQuery.*` + `PartGeometry.*` |
| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Multiple `Helper.*``SpatialQuery.*` + `PartGeometry.*` |
---
## Chunk 1: Rounding + GeometryOptimizer + ShapeBuilder
### Task 1: Extract Rounding to OpenNest.Math
**Files:**
- Create: `OpenNest.Core/Math/Rounding.cs`
- Modify: `OpenNest.Core/Plate.cs:415-416`
- Delete from: `OpenNest.Core/Helper.cs` (lines 1445)
- [ ] **Step 1: Create `Rounding.cs`**
```csharp
using OpenNest.Math;
namespace OpenNest.Math
{
public static class Rounding
{
public static double RoundDownToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor;
}
public static double RoundUpToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor;
}
public static double RoundToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor;
}
}
}
```
- [ ] **Step 2: Update call site in `Plate.cs`**
Replace `Helper.RoundUpToNearest` with `Rounding.RoundUpToNearest`. Add `using OpenNest.Math;` if not present.
- [ ] **Step 3: Remove three rounding methods from `Helper.cs`**
Delete lines 1445 (the three methods and their XML doc comments).
- [ ] **Step 4: Build and verify**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded
- [ ] **Step 5: Commit**
```
refactor: extract Rounding from Helper to OpenNest.Math
```
---
### Task 2: Extract GeometryOptimizer
**Files:**
- Create: `OpenNest.Core/Geometry/GeometryOptimizer.cs`
- Modify: `OpenNest.IO/DxfImporter.cs:59-60`, `OpenNest.Core/Geometry/Shape.cs:162-163`
- Delete from: `OpenNest.Core/Helper.cs` (lines 47237)
- [ ] **Step 1: Create `GeometryOptimizer.cs`**
Move these 6 methods (preserving exact code):
- `Optimize(IList<Arc>)`
- `Optimize(IList<Line>)`
- `TryJoinLines`
- `TryJoinArcs`
- `GetCollinearLines` (private extension method)
- `GetCoradialArs` (private extension method)
Namespace: `OpenNest.Geometry`. Class: `public static class GeometryOptimizer`.
Required usings: `System`, `System.Collections.Generic`, `System.Threading.Tasks`, `OpenNest.Math`.
- [ ] **Step 2: Update call sites**
- `DxfImporter.cs`: `Helper.Optimize(...)``GeometryOptimizer.Optimize(...)`. Add `using OpenNest.Geometry;`.
- `Shape.cs`: `Helper.Optimize(...)``GeometryOptimizer.Optimize(...)`. Already in `OpenNest.Geometry` namespace — no using needed.
- [ ] **Step 3: Remove methods from `Helper.cs`**
- [ ] **Step 4: Build and verify**
Run: `dotnet build OpenNest.sln`
- [ ] **Step 5: Commit**
```
refactor: extract GeometryOptimizer from Helper
```
---
### Task 3: Extract ShapeBuilder
**Files:**
- Create: `OpenNest.Core/Geometry/ShapeBuilder.cs`
- Modify: 11 files (see call-site table above for `GetShapes` callers)
- Delete from: `OpenNest.Core/Helper.cs` (lines 239378)
- [ ] **Step 1: Create `ShapeBuilder.cs`**
Move these 2 methods:
- `GetShapes(IEnumerable<Entity>)` — public
- `GetConnected(Vector, IEnumerable<Entity>)` — internal
Namespace: `OpenNest.Geometry`. Class: `public static class ShapeBuilder`.
Required usings: `System.Collections.Generic`, `System.Diagnostics`, `OpenNest.Math`.
- [ ] **Step 2: Update all call sites**
Replace `Helper.GetShapes``ShapeBuilder.GetShapes` in every file. Add `using OpenNest.Geometry;` where the file isn't already in that namespace.
Files to update:
- `OpenNest.Core/Drawing.cs`
- `OpenNest.Core/Timing.cs`
- `OpenNest.Core/Converters/ConvertGeometry.cs`
- `OpenNest.Core/Geometry/ShapeProfile.cs` (already in namespace)
- `OpenNest/LayoutPart.cs`
- `OpenNest/Actions/ActionSetSequence.cs`
- `OpenNest.Gpu/PartBitmap.cs`
- `OpenNest.Gpu/GpuPairEvaluator.cs`
- `OpenNest.Engine/RotationAnalysis.cs`
- `OpenNest.Engine/BestFit/BestFitFinder.cs`
- `OpenNest.Engine/BestFit/PairEvaluator.cs`
- [ ] **Step 3: Remove methods from `Helper.cs`**
- [ ] **Step 4: Build and verify**
Run: `dotnet build OpenNest.sln`
- [ ] **Step 5: Commit**
```
refactor: extract ShapeBuilder from Helper
```
---
## Chunk 2: Intersect + PartGeometry
### Task 4: Extract Intersect
**Files:**
- Create: `OpenNest.Core/Geometry/Intersect.cs`
- Modify: `Arc.cs`, `Circle.cs`, `Line.cs`, `Shape.cs`, `Polygon.cs` (all in `OpenNest.Core/Geometry/`)
- Delete from: `OpenNest.Core/Helper.cs` (lines 380742)
- [ ] **Step 1: Create `Intersect.cs`**
Move all 16 `Intersects` overloads. Namespace: `OpenNest.Geometry`. Class: `public static class Intersect`.
All methods keep their existing access modifiers (`internal` for most, none are `public`).
Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`.
- [ ] **Step 2: Update call sites in geometry types**
All callers are in the same namespace (`OpenNest.Geometry`) so no using changes needed. Replace `Helper.Intersects``Intersect.Intersects` in:
- `Arc.cs` (10 calls)
- `Circle.cs` (10 calls)
- `Line.cs` (8 calls)
- `Shape.cs` (12 calls, including the internal offset usage at line 537)
- `Polygon.cs` (10 calls)
- [ ] **Step 3: Remove methods from `Helper.cs`**
- [ ] **Step 4: Build and verify**
Run: `dotnet build OpenNest.sln`
- [ ] **Step 5: Commit**
```
refactor: extract Intersect from Helper
```
---
### Task 5: Extract PartGeometry
**Files:**
- Create: `OpenNest.Core/PartGeometry.cs`
- Modify: `OpenNest.Engine/Compactor.cs`, `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`
- Delete from: `OpenNest.Core/Helper.cs` (lines 744858)
- [ ] **Step 1: Create `PartGeometry.cs`**
Move these 5 methods:
- `GetPartLines(Part, double)` — public
- `GetPartLines(Part, PushDirection, double)` — public
- `GetOffsetPartLines(Part, double, double)` — public
- `GetOffsetPartLines(Part, double, PushDirection, double)` — public
- `GetDirectionalLines(Polygon, PushDirection)` — private
Namespace: `OpenNest`. Class: `public static class PartGeometry`.
Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Converters`, `OpenNest.Geometry`.
- [ ] **Step 2: Update call sites**
- `Compactor.cs`: `Helper.GetOffsetPartLines` / `Helper.GetPartLines``PartGeometry.*`
- `RotationSlideStrategy.cs`: `Helper.GetOffsetPartLines``PartGeometry.GetOffsetPartLines`
- [ ] **Step 3: Remove methods from `Helper.cs`**
- [ ] **Step 4: Build and verify**
Run: `dotnet build OpenNest.sln`
- [ ] **Step 5: Commit**
```
refactor: extract PartGeometry from Helper
```
---
## Chunk 3: SpatialQuery + Cleanup
### Task 6: Extract SpatialQuery
**Files:**
- Create: `OpenNest.Core/Geometry/SpatialQuery.cs`
- Modify: `Compactor.cs`, `FillLinear.cs`, `RotationSlideStrategy.cs`, `ActionClone.cs`, `ActionSelectArea.cs`
- Delete from: `OpenNest.Core/Helper.cs` (lines 8601462, all remaining methods)
- [ ] **Step 1: Create `SpatialQuery.cs`**
Move all remaining methods (14 total):
- `RayEdgeDistance(Vector, Line, PushDirection)` — private
- `RayEdgeDistance(double, double, double, double, double, double, PushDirection)` — private, `[AggressiveInlining]`
- `DirectionalDistance(List<Line>, List<Line>, PushDirection)` — public
- `DirectionalDistance(List<Line>, double, double, List<Line>, PushDirection)` — public
- `DirectionalDistance((Vector,Vector)[], Vector, (Vector,Vector)[], Vector, PushDirection)` — public
- `FlattenLines(List<Line>)` — public
- `OneWayDistance(Vector, (Vector,Vector)[], Vector, PushDirection)` — public
- `OppositeDirection(PushDirection)` — public
- `IsHorizontalDirection(PushDirection)` — public
- `EdgeDistance(Box, Box, PushDirection)` — public
- `DirectionToOffset(PushDirection, double)` — public
- `DirectionalGap(Box, Box, PushDirection)` — public
- `ClosestDistanceLeft/Right/Up/Down` — public (4 methods)
- `GetLargestBoxVertically/Horizontally` — public (2 methods)
Namespace: `OpenNest.Geometry`. Class: `public static class SpatialQuery`.
Required usings: `System`, `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`.
- [ ] **Step 2: Update call sites**
Replace `Helper.*``SpatialQuery.*` and add `using OpenNest.Geometry;` where needed:
- `OpenNest.Engine/Compactor.cs``OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionalGap`, `DirectionalDistance`, `DirectionToOffset`
- `OpenNest.Engine/FillLinear.cs``DirectionalDistance`, `OppositeDirection`
- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs``FlattenLines`, `OppositeDirection`, `OneWayDistance`
- `OpenNest/Actions/ActionClone.cs``GetLargestBoxVertically`, `GetLargestBoxHorizontally`
- `OpenNest/Actions/ActionSelectArea.cs``GetLargestBoxHorizontally`, `GetLargestBoxVertically`
- [ ] **Step 3: Remove methods from `Helper.cs`**
At this point `Helper.cs` should be empty (just the class wrapper and usings).
- [ ] **Step 4: Build and verify**
Run: `dotnet build OpenNest.sln`
- [ ] **Step 5: Commit**
```
refactor: extract SpatialQuery from Helper
```
---
### Task 7: Delete Helper.cs
**Files:**
- Delete: `OpenNest.Core/Helper.cs`
- [ ] **Step 1: Delete the empty `Helper.cs` file**
- [ ] **Step 2: Build and verify**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded with zero errors
- [ ] **Step 3: Commit**
```
refactor: remove empty Helper class
```
@@ -0,0 +1,588 @@
# Strip Nester Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a strip-based multi-drawing nesting strategy as a `NestEngineBase` subclass that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings.
**Architecture:** `StripNestEngine` extends `NestEngineBase`, uses `DefaultNestEngine` internally (composition) for individual fills. Registered in `NestEngineRegistry`. For single-item fills, delegates to `DefaultNestEngine`. For multi-drawing nesting, orchestrates the strip+remnant strategy. The MCP `autonest_plate` tool always runs `StripNestEngine` as a competitor alongside the current sequential approach, picking the denser result.
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest.Mcp
**Spec:** `docs/superpowers/specs/2026-03-15-strip-nester-design.md`
**Depends on:** `docs/superpowers/plans/2026-03-15-abstract-nest-engine.md` (must be implemented first — provides `NestEngineBase`, `DefaultNestEngine`, `NestEngineRegistry`)
---
## Chunk 1: Core StripNestEngine
### Task 1: Create StripDirection enum
**Files:**
- Create: `OpenNest.Engine/StripDirection.cs`
- [ ] **Step 1: Create the enum file**
```csharp
namespace OpenNest
{
public enum StripDirection
{
Bottom,
Left
}
}
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/StripDirection.cs
git commit -m "feat: add StripDirection enum"
```
---
### Task 2: Create StripNestResult internal class
**Files:**
- Create: `OpenNest.Engine/StripNestResult.cs`
- [ ] **Step 1: Create the result class**
```csharp
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest
{
internal class StripNestResult
{
public List<Part> Parts { get; set; } = new();
public Box StripBox { get; set; }
public Box RemnantBox { get; set; }
public FillScore Score { get; set; }
public StripDirection Direction { get; set; }
}
}
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/StripNestResult.cs
git commit -m "feat: add StripNestResult internal class"
```
---
### Task 3: Create StripNestEngine — class skeleton with selection and estimation helpers
**Files:**
- Create: `OpenNest.Engine/StripNestEngine.cs`
This task creates the class extending `NestEngineBase`, with `Name`/`Description` overrides, the single-item `Fill` override that delegates to `DefaultNestEngine`, and the helper methods for strip item selection and dimension estimation. The main `Nest` method is added in the next task.
- [ ] **Step 1: Create StripNestEngine with skeleton and helpers**
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
public class StripNestEngine : NestEngineBase
{
private const int MaxShrinkIterations = 20;
public StripNestEngine(Plate plate) : base(plate)
{
}
public override string Name => "Strip";
public override string Description => "Strip-based nesting for mixed-drawing layouts";
/// <summary>
/// Single-item fill delegates to DefaultNestEngine.
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
/// </summary>
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(item, workArea, progress, token);
}
/// <summary>
/// Selects the item that consumes the most plate area (bounding box area x quantity).
/// Returns the index into the items list.
/// </summary>
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
{
var bestIndex = 0;
var bestArea = 0.0;
for (var i = 0; i < items.Count; i++)
{
var bbox = items[i].Drawing.Program.BoundingBox();
var qty = items[i].Quantity > 0
? items[i].Quantity
: (int)(workArea.Area() / bbox.Area());
var totalArea = bbox.Area() * qty;
if (totalArea > bestArea)
{
bestArea = totalArea;
bestIndex = i;
}
}
return bestIndex;
}
/// <summary>
/// Estimates the strip dimension (height for bottom, width for left) needed
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
/// This is only an estimate for the shrink loop starting point — the actual fill
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
/// </summary>
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
{
var bbox = item.Drawing.Program.BoundingBox();
var qty = item.Quantity > 0
? item.Quantity
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
var perRow0 = (int)(stripLength / bbox.Width);
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
var dim0 = rows0 * bbox.Length;
// At 90 deg: rotated bounding box (Width and Length swap)
var perRow90 = (int)(stripLength / bbox.Length);
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
var dim90 = rows90 * bbox.Width;
var estimate = System.Math.Min(dim0, dim90);
// Clamp to available dimension
return System.Math.Min(estimate, maxDimension);
}
}
}
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/StripNestEngine.cs
git commit -m "feat: add StripNestEngine skeleton with Fill delegate and estimation helpers"
```
---
### Task 4: Add the Nest method and TryOrientation
**Files:**
- Modify: `OpenNest.Engine/StripNestEngine.cs`
This is the main multi-drawing algorithm: tries both orientations, fills strip + remnant, compares results. Uses `DefaultNestEngine` internally for all fill operations (composition pattern per the abstract engine spec).
Key detail: The remnant fill shrinks the remnant box after each item fill using `ComputeRemainderWithin` to prevent overlapping placements.
- [ ] **Step 1: Add Nest, TryOrientation, and ComputeRemainderWithin methods**
Add these methods to the `StripNestEngine` class, after the `EstimateStripDimension` method:
```csharp
/// <summary>
/// Multi-drawing strip nesting strategy.
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
/// in both bottom and left orientations, fills remnants with remaining drawings,
/// and returns the denser result.
/// </summary>
public List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var workArea = Plate.WorkArea();
// Select which item gets the strip treatment.
var stripIndex = SelectStripItemIndex(items, workArea);
var stripItem = items[stripIndex];
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
// Try both orientations.
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, token);
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, token);
// Pick the better result.
if (bottomResult.Score >= leftResult.Score)
return bottomResult.Parts;
return leftResult.Parts;
}
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
List<NestItem> remainderItems, Box workArea, CancellationToken token)
{
var result = new StripNestResult { Direction = direction };
if (token.IsCancellationRequested)
return result;
// Estimate initial strip dimension.
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
// Create the initial strip box.
var stripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
// Initial fill using DefaultNestEngine (composition, not inheritance).
var inner = new DefaultNestEngine(Plate);
var stripParts = inner.Fill(
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
stripBox, null, token);
if (stripParts == null || stripParts.Count == 0)
return result;
// Measure actual strip dimension from placed parts.
var placedBox = stripParts.Cast<IBoundable>().GetBoundingBox();
var actualDim = direction == StripDirection.Bottom
? placedBox.Top - workArea.Y
: placedBox.Right - workArea.X;
var bestParts = stripParts;
var bestDim = actualDim;
var targetCount = stripParts.Count;
// Shrink loop: reduce strip dimension by PartSpacing until count drops.
for (var i = 0; i < MaxShrinkIterations; i++)
{
if (token.IsCancellationRequested)
break;
var trialDim = bestDim - Plate.PartSpacing;
if (trialDim <= 0)
break;
var trialBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, trialDim)
: new Box(workArea.X, workArea.Y, trialDim, workArea.Length);
var trialInner = new DefaultNestEngine(Plate);
var trialParts = trialInner.Fill(
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
trialBox, null, token);
if (trialParts == null || trialParts.Count < targetCount)
break;
// Same count in a tighter strip — keep going.
bestParts = trialParts;
var trialPlacedBox = trialParts.Cast<IBoundable>().GetBoundingBox();
bestDim = direction == StripDirection.Bottom
? trialPlacedBox.Top - workArea.Y
: trialPlacedBox.Right - workArea.X;
}
// Build remnant box with spacing gap.
var spacing = Plate.PartSpacing;
var remnantBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y + bestDim + spacing,
workArea.Width, workArea.Length - bestDim - spacing)
: new Box(workArea.X + bestDim + spacing, workArea.Y,
workArea.Width - bestDim - spacing, workArea.Length);
// Collect all parts.
var allParts = new List<Part>(bestParts);
// If strip item was only partially placed, add leftovers to remainder.
var placed = bestParts.Count;
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
var effectiveRemainder = new List<NestItem>(remainderItems);
if (leftover > 0)
{
effectiveRemainder.Add(new NestItem
{
Drawing = stripItem.Drawing,
Quantity = leftover
});
}
// Sort remainder by descending bounding box area x quantity.
effectiveRemainder = effectiveRemainder
.OrderByDescending(i =>
{
var bb = i.Drawing.Program.BoundingBox();
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
})
.ToList();
// Fill remnant with remainder items, shrinking the available area after each.
if (remnantBox.Width > 0 && remnantBox.Length > 0)
{
var currentRemnant = remnantBox;
foreach (var item in effectiveRemainder)
{
if (token.IsCancellationRequested)
break;
if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0)
break;
var remnantInner = new DefaultNestEngine(Plate);
var remnantParts = remnantInner.Fill(
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
currentRemnant, null, token);
if (remnantParts != null && remnantParts.Count > 0)
{
allParts.AddRange(remnantParts);
// Shrink remnant to avoid overlap with next item.
var usedBox = remnantParts.Cast<IBoundable>().GetBoundingBox();
currentRemnant = ComputeRemainderWithin(currentRemnant, usedBox, spacing);
}
}
}
result.Parts = allParts;
result.StripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
result.RemnantBox = remnantBox;
result.Score = FillScore.Compute(allParts, workArea);
return result;
}
/// <summary>
/// Computes the largest usable remainder within a work area after a portion has been used.
/// Picks whichever is larger: the horizontal strip to the right, or the vertical strip above.
/// </summary>
private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing)
{
var hWidth = workArea.Right - usedBox.Right - spacing;
var hStrip = hWidth > 0
? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length)
: Box.Empty;
var vHeight = workArea.Top - usedBox.Top - spacing;
var vStrip = vHeight > 0
? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight)
: Box.Empty;
return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip;
}
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/StripNestEngine.cs
git commit -m "feat: add StripNestEngine.Nest with strip fill, shrink loop, and remnant fill"
```
---
### Task 5: Register StripNestEngine in NestEngineRegistry
**Files:**
- Modify: `OpenNest.Engine/NestEngineRegistry.cs`
- [ ] **Step 1: Add Strip registration**
In `NestEngineRegistry.cs`, add the strip engine registration in the static constructor, after the Default registration:
```csharp
Register("Strip",
"Strip-based nesting for mixed-drawing layouts",
plate => new StripNestEngine(plate));
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngineRegistry.cs
git commit -m "feat: register StripNestEngine in NestEngineRegistry"
```
---
## Chunk 2: MCP Integration
### Task 6: Integrate StripNestEngine into autonest_plate MCP tool
**Files:**
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs`
Run the strip nester alongside the existing sequential approach. Both use side-effect-free fills (4-arg `Fill` returning `List<Part>`), then the winner's parts are added to the plate.
Note: After the abstract engine migration, callsites already use `NestEngineRegistry.Create(plate)`. The `autonest_plate` tool creates a `StripNestEngine` directly for the strip strategy competition (it's always tried, regardless of active engine selection).
- [ ] **Step 1: Refactor AutoNestPlate to run both strategies**
In `NestingTools.cs`, replace the fill/pack logic in `AutoNestPlate` (the section after the items list is built) with a strategy competition.
Replace the fill/pack logic with:
```csharp
// Strategy 1: Strip nesting
var stripEngine = new StripNestEngine(plate);
var stripResult = stripEngine.Nest(items, null, CancellationToken.None);
var stripScore = FillScore.Compute(stripResult, plate.WorkArea());
// Strategy 2: Current sequential fill
var seqResult = SequentialFill(plate, items);
var seqScore = FillScore.Compute(seqResult, plate.WorkArea());
// Pick winner and apply to plate.
var winner = stripScore >= seqScore ? stripResult : seqResult;
var winnerName = stripScore >= seqScore ? "strip" : "sequential";
plate.Parts.AddRange(winner);
var totalPlaced = winner.Count;
```
Update the output section:
```csharp
var sb = new StringBuilder();
sb.AppendLine($"AutoNest plate {plateIndex} ({winnerName} strategy): {(totalPlaced > 0 ? "success" : "no parts placed")}");
sb.AppendLine($" Parts placed: {totalPlaced}");
sb.AppendLine($" Total parts: {plate.Parts.Count}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
sb.AppendLine($" Strip score: {stripScore.Count} parts, density {stripScore.Density:P1}");
sb.AppendLine($" Sequential score: {seqScore.Count} parts, density {seqScore.Density:P1}");
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
foreach (var group in groups)
sb.AppendLine($" {group.Key}: {group.Count()}");
return sb.ToString();
```
- [ ] **Step 2: Add the SequentialFill helper method**
Add this private method to `NestingTools`. It mirrors the existing sequential fill phase using side-effect-free fills.
```csharp
private static List<Part> SequentialFill(Plate plate, List<NestItem> items)
{
var fillItems = items
.Where(i => i.Quantity != 1)
.OrderBy(i => i.Priority)
.ThenByDescending(i => i.Drawing.Area)
.ToList();
var workArea = plate.WorkArea();
var allParts = new List<Part>();
foreach (var item in fillItems)
{
if (item.Quantity == 0 || workArea.Width <= 0 || workArea.Length <= 0)
continue;
var engine = new DefaultNestEngine(plate);
var parts = engine.Fill(
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
workArea, null, CancellationToken.None);
if (parts.Count > 0)
{
allParts.AddRange(parts);
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing);
}
}
return allParts;
}
```
- [ ] **Step 3: Add required using statement**
Add `using System.Threading;` to the top of `NestingTools.cs` if not already present.
- [ ] **Step 4: Build the full solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Mcp/Tools/NestingTools.cs
git commit -m "feat: integrate StripNestEngine into autonest_plate MCP tool"
```
---
## Chunk 3: Publish and Test
### Task 7: Publish MCP server and test with real parts
**Files:**
- No code changes — publish and manual testing
- [ ] **Step 1: Publish OpenNest.Mcp**
Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"`
Expected: Build and publish succeeded
- [ ] **Step 2: Test with SULLYS parts**
Using the MCP tools, test the strip nester with the SULLYS-001 and SULLYS-002 parts:
1. Load the test nest file or import the DXF files
2. Create a 60x120 plate
3. Run `autonest_plate` with both drawings at qty 10
4. Verify the output reports which strategy won (strip vs sequential)
5. Verify the output shows scores for both strategies
6. Check plate info for part placement and utilization
- [ ] **Step 3: Compare with current results**
Verify the strip nester produces a result matching or improving on the target layout from screenshot 190519 (all 20 parts on one 60x120 plate with organized strip arrangement).
- [ ] **Step 4: Commit any fixes**
If issues are found during testing, fix and commit with descriptive messages.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
# Abstract Nest Engine Design Spec
**Date:** 2026-03-15
**Goal:** Create a pluggable nest engine architecture so users can create custom nesting algorithms, switch between engines globally, and load third-party engines as plugins.
---
## Motivation
The current `NestEngine` is a concrete class with a sophisticated multi-phase fill strategy (Linear, Pairs, RectBestFit, Remainder). Different part geometries benefit from different algorithms — circles need circle-packing, strip-based layouts work better for mixed-drawing nests, and users may want to experiment with their own approaches. The engine needs to be swappable without changing the UI or other consumers.
## Architecture Overview
```
NestEngineBase (abstract, OpenNest.Engine)
├── DefaultNestEngine (current multi-phase logic)
├── StripNestEngine (strip-based multi-drawing nesting)
├── CircleNestEngine (future, circle-packing)
└── [Plugin engines loaded from DLLs]
NestEngineRegistry (static, OpenNest.Engine)
├── Tracks available engines (built-in + plugins)
├── Manages active engine selection (global)
└── Factory method: Create(Plate) → NestEngineBase
```
**Note on AutoNester:** The existing `AutoNester` static class (NFP + simulated annealing for mixed parts) is a natural future candidate for the registry but is currently unused by any caller. It is out of scope for this refactor — it can be wrapped as an engine later when it's ready for use.
## NestEngineBase
Abstract base class in `OpenNest.Engine`. Provides the contract, shared state, and utility methods.
**Instance lifetime:** Engine instances are short-lived and plate-specific — created per operation via the registry factory. Some engines (like `DefaultNestEngine`) maintain internal state across multiple `Fill` calls on the same instance (e.g., `knownGoodAngles` for angle pruning). Plugin authors should be aware that a single engine instance may receive multiple `Fill` calls within one nesting session.
### Properties
| Property | Type | Notes |
|----------|------|-------|
| `Plate` | `Plate` | The plate being nested |
| `PlateNumber` | `int` | For progress reporting |
| `NestDirection` | `NestDirection` | Fill direction preference, set by callers after creation |
| `WinnerPhase` | `NestPhase` | Which phase produced the best result (protected set) |
| `PhaseResults` | `List<PhaseResult>` | Per-phase results for diagnostics |
| `AngleResults` | `List<AngleResult>` | Per-angle results for diagnostics |
### Abstract Members
| Member | Type | Purpose |
|--------|------|---------|
| `Name` | `string` (get) | Display name for UI/registry |
| `Description` | `string` (get) | Human-readable description |
### Virtual Methods (return parts, no side effects)
These are the core methods subclasses override. Base class default implementations return empty lists — subclasses override the ones they support.
```csharp
virtual List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
virtual List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
virtual List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
```
**`FillExact` is non-virtual.** It is orchestration logic (binary search wrapper around `Fill`) that works regardless of the underlying fill algorithm. It lives in the base class and calls the virtual `Fill` method. Any engine that implements `Fill` gets `FillExact` for free.
**`PackArea` signature change:** The current `PackArea(Box, List<NestItem>)` mutates the plate directly and returns `bool`. The new virtual method adds `IProgress<NestProgress>` and `CancellationToken` parameters and returns `List<Part>` (side-effect-free). This is a deliberate refactor — the old mutating behavior moves to the convenience overload `Pack(List<NestItem>)`.
### Convenience Overloads (non-virtual, add parts to plate)
These call the virtual methods and handle plate mutation:
```csharp
bool Fill(NestItem item)
bool Fill(NestItem item, Box workArea)
bool Fill(List<Part> groupParts)
bool Fill(List<Part> groupParts, Box workArea)
bool Pack(List<NestItem> items)
```
Pattern: call the virtual method → if parts returned → add to `Plate.Parts` → return `true`.
### Protected Utilities
Available to all subclasses:
- `ReportProgress(IProgress<NestProgress>, NestPhase, int plateNumber, List<Part>, Box, string)` — clone parts and report
- `BuildProgressSummary()` — format PhaseResults into a status string
- `IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)` — FillScore comparison
- `IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)` — with overlap check
## DefaultNestEngine
Rename of the current `NestEngine`. Inherits `NestEngineBase` and overrides all virtual methods with the existing multi-phase logic.
- `Name``"Default"`
- `Description``"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"`
- All current private methods (`FindBestFill`, `FillWithPairs`, `FillRectangleBestFit`, `FillPattern`, `TryRemainderImprovement`, `BuildCandidateAngles`, `QuickFillCount`, etc.) remain as private methods in this class
- `ForceFullAngleSweep` property stays on `DefaultNestEngine` (not the base class) — only used by `BruteForceRunner` which references `DefaultNestEngine` directly
- `knownGoodAngles` HashSet stays as a private field — accumulates across multiple `Fill` calls for angle pruning
- No behavioral change — purely structural refactor
## StripNestEngine
The planned `StripNester` (from the strip nester spec) becomes a `NestEngineBase` subclass instead of a standalone class.
- `Name``"Strip"`
- `Description``"Strip-based nesting for mixed-drawing layouts"`
- Overrides `Fill` for multi-item scenarios with its strip+remnant strategy
- Uses `DefaultNestEngine` internally as a building block for individual strip/remnant fills (composition, not inheritance from Default)
## NestEngineRegistry
Static class in `OpenNest.Engine` managing engine discovery and selection. Accessed only from the UI thread — not thread-safe. Engines are created per-operation and used on background threads, but the registry itself is only mutated/queried from the UI thread at startup and when the user changes the active engine.
### NestEngineInfo
```csharp
class NestEngineInfo
{
string Name { get; }
string Description { get; }
Func<Plate, NestEngineBase> Factory { get; }
}
```
### API
| Member | Purpose |
|--------|---------|
| `List<NestEngineInfo> AvailableEngines` | All registered engines |
| `string ActiveEngineName` | Currently selected engine (defaults to `"Default"`) |
| `NestEngineBase Create(Plate plate)` | Creates instance of active engine |
| `void Register(string name, string description, Func<Plate, NestEngineBase> factory)` | Register a built-in engine |
| `void LoadPlugins(string directory)` | Scan DLLs for NestEngineBase subclasses |
### Built-in Registration
```csharp
Register("Default", "Multi-phase nesting...", plate => new DefaultNestEngine(plate));
Register("Strip", "Strip-based nesting...", plate => new StripNestEngine(plate));
```
### Plugin Discovery
Follows the existing `IPostProcessor` pattern from `Posts/`:
- Scan `Engines/` directory next to the executable for DLLs
- Reflect over types, find concrete subclasses of `NestEngineBase`
- Require a constructor taking `Plate`
- Register each with its `Name` and `Description` properties
- Called at application startup alongside post-processor loading (WinForms app only — Console and MCP use built-in engines only)
**Error handling:**
- DLLs that fail to load (bad assembly, missing dependencies) are logged and skipped
- Types without a `Plate` constructor are skipped
- Duplicate engine names: first registration wins, duplicates are logged and skipped
- Exceptions from plugin constructors during `Create()` are caught and surfaced to the caller
## Callsite Migration
All `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`:
| Location | Count | Notes |
|----------|-------|-------|
| `MainForm.cs` | 3 | Auto-nest fill, auto-nest pack, single-drawing fill plate |
| `ActionFillArea.cs` | 2 | |
| `PlateView.cs` | 1 | |
| `NestingTools.cs` (MCP) | 6 | |
| `Program.cs` (Console) | 3 | |
| `BruteForceRunner.cs` | 1 | **Keep as `new DefaultNestEngine(plate)`** — training data must come from the known algorithm |
## UI Integration
- Global engine selector: combobox or menu item bound to `NestEngineRegistry.AvailableEngines`
- Changing selection sets `NestEngineRegistry.ActiveEngineName`
- No per-plate engine state — global setting applies to all subsequent operations
- Plugin directory: `Engines/` next to executable, loaded at startup
## File Summary
| Action | File | Project |
|--------|------|---------|
| Create | `NestEngineBase.cs` | OpenNest.Engine |
| Rename/Modify | `NestEngine.cs``DefaultNestEngine.cs` | OpenNest.Engine |
| Create | `NestEngineRegistry.cs` | OpenNest.Engine |
| Create | `NestEngineInfo.cs` | OpenNest.Engine |
| Modify | `StripNester.cs``StripNestEngine.cs` | OpenNest.Engine |
| Modify | `MainForm.cs` | OpenNest |
| Modify | `ActionFillArea.cs` | OpenNest |
| Modify | `PlateView.cs` | OpenNest |
| Modify | `NestingTools.cs` | OpenNest.Mcp |
| Modify | `Program.cs` | OpenNest.Console |
@@ -0,0 +1,96 @@
# FillExact — Exact-Quantity Fill with Binary Search
## Problem
The current `NestEngine.Fill` fills an entire work area and truncates to `item.Quantity` with `.Take(n)`. This wastes plate space — parts are spread across the full area, leaving no usable remainder strip for subsequent drawings in AutoNest.
## Solution
Add a `FillExact` method that binary-searches for the smallest sub-area of the work area that fits exactly the requested quantity. This packs parts tightly against one edge, maximizing the remainder strip available for the next drawing.
## Coordinate Conventions
`Box.Width` is the X-axis extent. `Box.Length` is the Y-axis extent. The box is anchored at `(Box.X, Box.Y)` (bottom-left corner).
- **Shrink width** means reducing `Box.Width` (X-axis), producing a narrower box anchored at the left edge. The remainder strip extends to the right.
- **Shrink length** means reducing `Box.Length` (Y-axis), producing a shorter box anchored at the bottom edge. The remainder strip extends upward.
## Algorithm
1. **Early exits:**
- Quantity is 0 (unlimited): delegate to `Fill` directly.
- Quantity is 1: delegate to `Fill` directly (a single part placement doesn't benefit from area search).
2. **Full fill** — Call `Fill(item, workArea, progress, token)` to establish the upper bound (max parts that fit). This call gets progress reporting so the user sees the phases running.
3. **Already exact or under** — If `fullCount <= quantity`, return the full fill result. The plate can't fit more than requested anyway.
4. **Estimate starting point** — Calculate an initial dimension estimate assuming 50% utilization: `estimatedDim = (partArea * quantity) / (0.5 * fixedDim)`, clamped to at least the part's bounding box dimension in that axis.
5. **Binary search** (max 8 iterations, or until `high - low < partSpacing`) — Keep one dimension of the work area fixed and binary-search on the other:
- `low = estimatedDim`, `high = workArea dimension`
- Each iteration: create a test box, call `Fill(item, testBox, null, token)` (no progress — search iterations are silent), check count.
- `count >= quantity` → record result, shrink: `high = mid`
- `count < quantity` → expand: `low = mid`
- Check cancellation token between iterations; if cancelled, return best found so far.
6. **Try both orientations** — Run the binary search twice: once shrinking length (fixed width) and once shrinking width (fixed length).
7. **Pick winner** — Compare by test box area (`testBox.Width * testBox.Length`). Return whichever orientation's result has a smaller test box area, leaving more remainder for subsequent drawings. Tie-break: prefer shrink-length (leaves horizontal remainder strip, generally more useful on wide plates).
## Method Signature
```csharp
// NestEngine.cs
public List<Part> FillExact(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
```
Returns exactly `item.Quantity` parts packed into the smallest sub-area of `workArea`, or fewer if they don't all fit.
## Internal Helper
```csharp
private (List<Part> parts, double usedDim) BinarySearchFill(
NestItem item, Box workArea, bool shrinkWidth,
CancellationToken token)
```
Performs the binary search for one orientation. Returns the parts and the dimension value at which the exact quantity was achieved. Progress is not passed to inner Fill calls — the search iterations run silently.
## Engine State
Each inner `Fill` call clears `PhaseResults`, `AngleResults`, and overwrites `WinnerPhase`. After the winning Fill call is identified, `FillExact` runs the winner one final time with `progress` so:
- `PhaseResults` / `AngleResults` / `WinnerPhase` reflect the winning fill.
- The progress form shows the final result.
## Integration
### AutoNest (MainForm.RunAutoNest_Click)
Replace `engine.Fill(item, workArea, progress, token)` with `engine.FillExact(item, workArea, progress, token)` for multi-quantity items. The tighter packing means `ComputeRemainderStrip` returns a larger box for subsequent drawings.
### Single-drawing Fill
`FillExact` works for single-drawing fills too. When `item.Quantity` is set, the caller gets a tight layout instead of parts scattered across the full plate.
### Fallback
When `item.Quantity` is 0 (unlimited), `FillExact` falls through to the standard `Fill` behavior — fill the entire work area.
## Performance Notes
The binary search converges in at most 8 iterations per orientation. Each iteration calls `Fill` internally, which runs the pairs/linear/best-fit phases. For a typical auto-nest scenario:
- Full fill: 1 call (with progress)
- Shrink-length search: ~6-8 calls (silent)
- Shrink-width search: ~6-8 calls (silent)
- Final re-fill of winner: 1 call (with progress)
- Total: ~15-19 Fill calls per drawing
The inner `Fill` calls for reduced work areas are faster than full-plate fills since the search space is smaller. The `BestFitCache` (used by the pairs phase) is keyed on the full plate size, so it stays warm across iterations — only the linear/rect phases re-run.
Early termination (`high - low < partSpacing`) typically cuts 1-3 iterations, bringing the total closer to 12-15 calls.
## Edge Cases
- **Quantity 0 (unlimited):** Skip binary search, delegate to `Fill` directly.
- **Quantity 1:** Skip binary search, delegate to `Fill` directly.
- **Full fill already exact:** Return immediately without searching.
- **Part doesn't fit at all:** Return empty list.
- **Binary search can't hit exact count** (e.g., jumps from N-1 to N+2): Take the smallest test box where `count >= quantity` and truncate with `.Take(quantity)`.
- **Cancellation:** Check token between iterations. Return best result found so far.
@@ -0,0 +1,329 @@
# 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
### 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:
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 readonly 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 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
- `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`
@@ -0,0 +1,133 @@
# Strip Nester Design Spec
## Problem
The current multi-drawing nesting strategies (AutoNester with NFP/simulated annealing, sequential FillExact) produce scattered, unstructured layouts. For jobs with multiple part types, a structured strip-based approach can pack more densely by dedicating a tight strip to the highest-area drawing and filling the remnant with the rest.
## Strategy Overview
1. Pick the drawing that consumes the most plate area (bounding box area x quantity) as the "strip item." All others are "remainder items."
2. Try two orientations — bottom strip and left strip.
3. For each orientation, find the tightest strip that fits the strip item's full quantity.
4. Fill the remnant area with remainder items using existing fill strategies.
5. Compare both orientations. The denser overall result wins.
## Algorithm Detail
### Step 1: Select Strip Item
Sort `NestItem`s by `Drawing.Program.BoundingBox().Area() * quantity` descending — bounding box area, not `Drawing.Area`, because the bounding box represents the actual plate space consumed by each part. The first item becomes the strip item. If quantity is 0 (unlimited), estimate max capacity from `workArea.Area() / bboxArea` as a stand-in for sorting.
### Step 2: Estimate Initial Strip Height
For the strip item, calculate at both 0 deg and 90 deg rotation. These two angles are sufficient since this is only an estimate for the shrink loop starting point — the actual fill in Step 3 uses `NestEngine.Fill` which tries many rotation angles internally.
- Parts per row: `floor(stripLength / bboxWidth)`
- Rows needed: `ceil(quantity / partsPerRow)`
- Strip height: `rows * bboxHeight`
Pick the rotation with the shorter strip height. The strip length is the work area dimension along the strip's long axis (work area width for bottom strip, work area length for left strip).
### Step 3: Initial Fill
Create a `Box` for the strip area:
- **Bottom strip**: `(workArea.X, workArea.Y, workArea.Width, estimatedStripHeight)`
- **Left strip**: `(workArea.X, workArea.Y, estimatedStripWidth, workArea.Length)`
Fill using `NestEngine.Fill(stripItem, stripBox)`. Measure the actual strip dimension from placed parts: for a bottom strip, `actualStripHeight = placedParts.GetBoundingBox().Top - workArea.Y`; for a left strip, `actualStripWidth = placedParts.GetBoundingBox().Right - workArea.X`. This may be shorter than the estimate since FillLinear packs more efficiently than pure bounding-box grid.
### Step 4: Shrink Loop
Starting from the actual placed dimension (not the estimate), capped at 20 iterations:
1. Reduce strip height by `plate.PartSpacing` (typically 0.25").
2. Create new strip box with reduced dimension.
3. Fill with `NestEngine.Fill(stripItem, newStripBox)`.
4. If part count equals the initial fill count, record this as the new best and repeat.
5. If part count drops, stop. Use the previous iteration's result (tightest strip that still fits).
For unlimited quantity (qty = 0), the initial fill count becomes the target.
### Step 5: Remnant Fill
Calculate the remnant box from the tightest strip's actual placed dimension, adding `plate.PartSpacing` between the strip and remnant to prevent spacing violations:
- **Bottom strip remnant**: `(workArea.X, workArea.Y + actualStripHeight + partSpacing, workArea.Width, workArea.Length - actualStripHeight - partSpacing)`
- **Left strip remnant**: `(workArea.X + actualStripWidth + partSpacing, workArea.Y, workArea.Width - actualStripWidth - partSpacing, workArea.Length)`
Fill remainder items in descending order by `bboxArea * quantity` (largest first, same as strip selection). If the strip item was only partially placed (fewer than target quantity), add the leftover quantity as a remainder item so it participates in the remnant fill.
For each remainder item, fill using `NestEngine.Fill(remainderItem, remnantBox)`.
### Step 6: Compare Orientations
Score each orientation using `FillScore.Compute` over all placed parts (strip + remnant) against `plate.WorkArea()`. The orientation with the better `FillScore` wins. Apply the winning parts to the plate.
## Classes
### `StripNester` (new, `OpenNest.Engine`)
```csharp
public class StripNester
{
public StripNester(Plate plate) { }
public List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress,
CancellationToken token);
}
```
**Constructor**: Takes the target plate (for work area, part spacing, quadrant).
**`Nest` method**: Runs the full strategy. Returns the combined list of placed parts. The caller adds them to `plate.Parts`. Same instance-based pattern as `NestEngine`.
### `StripNestResult` (new, internal, `OpenNest.Engine`)
```csharp
internal class StripNestResult
{
public List<Part> Parts { get; set; } = new();
public Box StripBox { get; set; }
public Box RemnantBox { get; set; }
public FillScore Score { get; set; }
public StripDirection Direction { get; set; }
}
```
Holds intermediate results for comparing bottom vs left orientations.
### `StripDirection` (new enum, `OpenNest.Engine`)
```csharp
public enum StripDirection { Bottom, Left }
```
## Integration
### MCP (`NestingTools`)
`StripNester` becomes an additional strategy in the autonest flow. When multiple items are provided, both `StripNester` and the current approach run, and the better result wins.
### UI (`AutoNestForm`)
Can be offered as a strategy option alongside existing NFP-based auto-nesting.
### No changes to `NestEngine`
`StripNester` is a consumer of `NestEngine.Fill`, not a modification of it.
## Edge Cases
- **Single item**: Strategy reduces to strip optimization only (shrink loop with no remnant fill). Still valuable for finding the tightest area.
- **Strip item can't fill target quantity**: Use the partial result. Leftover quantity is added to remainder items for the remnant fill.
- **Remnant too small**: `NestEngine.Fill` returns empty naturally. No special handling needed.
- **Quantity = 0 (unlimited)**: Initial fill count becomes the shrink loop target.
- **Strip already one part tall**: Skip the shrink loop.
## Dependencies
- `NestEngine.Fill(NestItem, Box)` — existing API, no changes needed.
- `FillScore.Compute` — existing scoring, no changes needed.
- `Part.GetBoundingBox()` / list extensions — existing geometry utilities.
@@ -0,0 +1,206 @@
# Lead-In Assignment UI Design
## Overview
Add a dialog and menu item for assigning lead-ins to parts on a plate. The dialog provides two parameter sets — tabbed (V lead-in/out) and standard (straight lead-in with overtravel) — and applies them per-part based on a new `Part.IsTabbed` flag. The `ContourCuttingStrategy` auto-detects corner vs mid-entity pierce points to determine lead-out behavior.
This is the "manual override" path — when the user assigns lead-ins via this dialog, each part gets `HasManualLeadIns = true` so the automated `PlateProcessor` pipeline skips it.
## Model Changes
### Part (OpenNest.Core)
Add two properties:
```csharp
public bool IsTabbed { get; set; }
```
Indicates the part uses tabbed lead-in parameters (V lead-in/out). Defaults to `false` — all parts use standard parameters until a tab assignment UI is built.
```csharp
public void ApplyLeadIns(Program processedProgram)
{
Program = processedProgram;
HasManualLeadIns = true;
}
```
Atomically sets the processed program and marks lead-ins as manually assigned. The original drawing program is preserved on `Part.BaseDrawing.Program`. This is the intentional "manual" path — `PlateProcessor` (the automated path) stores results non-destructively in `ProcessedPart.ProcessedProgram` and does not call this method.
### PlateHelper (OpenNest.Engine)
Change `PlateHelper` from `internal static` to `public static` so the UI project can access `GetExitPoint`.
## Lead-In Dialog (`LeadInForm`)
A WinForms dialog in `OpenNest/Forms/LeadInForm.cs` with two groups of numeric inputs.
### Tabbed Group (V lead-in/lead-out)
- Lead-in angle (degrees) — default 60
- Lead-in length (inches) — default 0.15
- Lead-out angle (degrees) — default 60
- Lead-out length (inches) — default 0.08
These form a V shape at the pierce point where the breaking point lands on the part edge, leaving a less noticeable tab spot.
### Standard Group
- Lead-in angle (degrees) — default 90
- Lead-in length (inches) — default 0.125
- Overtravel distance (inches) — default 0.03
The lead-out behavior for standard parts depends on pierce point location (auto-detected by `ContourCuttingStrategy`):
- **Corner pierce:** straight `LineLeadOut` extending past the corner for the overtravel distance
- **Mid-entity pierce:** handled at the `ContourCuttingStrategy` level (not via `LeadOut.Generate`) — the strategy appends overcut moves that follow the contour path for the overtravel distance after the shape's closing segment
### Dialog Result
```csharp
public class LeadInSettings
{
// Tabbed parameters (V lead-in/out)
public double TabbedLeadInAngle { get; set; } = 60;
public double TabbedLeadInLength { get; set; } = 0.15;
public double TabbedLeadOutAngle { get; set; } = 60;
public double TabbedLeadOutLength { get; set; } = 0.08;
// Standard parameters
public double StandardLeadInAngle { get; set; } = 90;
public double StandardLeadInLength { get; set; } = 0.125;
public double StandardOvertravel { get; set; } = 0.03;
}
```
Note: `LineLeadIn.ApproachAngle` and `LineLeadOut.ApproachAngle` store degrees (not radians), converting internally via `Angle.ToRadians()`. The `LeadInSettings` values are degrees and can be passed directly.
## LeadInSettings to CuttingParameters Mapping
The caller builds two `CuttingParameters` instances up front — one for tabbed parts, one for standard — rather than swapping parameters per iteration:
**Tabbed:**
```
ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.TabbedLeadInAngle, Length = settings.TabbedLeadInLength }
ExternalLeadOut = new LineLeadOut { ApproachAngle = settings.TabbedLeadOutAngle, Length = settings.TabbedLeadOutLength }
InternalLeadIn = (same)
InternalLeadOut = (same)
ArcCircleLeadIn = (same)
ArcCircleLeadOut = (same)
```
**Standard:**
```
ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.StandardLeadInAngle, Length = settings.StandardLeadInLength }
ExternalLeadOut = new LineLeadOut { Length = settings.StandardOvertravel }
InternalLeadIn = (same)
InternalLeadOut = (same)
ArcCircleLeadIn = (same)
ArcCircleLeadOut = (same)
```
For standard parts, the `LineLeadOut` handles the corner case. The mid-entity contour-follow case is handled at the `ContourCuttingStrategy` level (see below).
All three contour types (external, internal, arc/circle) get the same settings for this iteration.
## Menu Integration
Add "Assign Lead-Ins" to the Plate menu in `MainForm`, after "Sequence Parts" and before "Calculate Cut Time".
Click handler in `MainForm` delegates to `EditNestForm.AssignLeadIns()`.
## AssignLeadIns Flow (EditNestForm)
```
1. Open LeadInForm dialog
2. If user clicks OK:
a. Get LeadInSettings from dialog
b. Build two ContourCuttingStrategy instances:
- tabbedStrategy with tabbed CuttingParameters
- standardStrategy with standard CuttingParameters
c. Get exit point: PlateHelper.GetExitPoint(plate) [now public]
d. Set currentPoint = exitPoint
e. For each part on the current plate (in list order):
- Skip if part.HasManualLeadIns is true
- Compute localApproach = currentPoint - part.Location
- Pick strategy = part.IsTabbed ? tabbedStrategy : standardStrategy
- Call strategy.Apply(part.Program, localApproach) → CuttingResult
- Call part.ApplyLeadIns(cutResult.Program)
(this sets Program AND HasManualLeadIns = true atomically)
- Update currentPoint = cutResult.LastCutPoint + part.Location
f. Invalidate PlateView to show updated geometry
```
Note: `ContourCuttingStrategy.Apply` builds a new `Program` from scratch — it reads `part.Program` but does not modify it. The returned `CuttingResult.Program` is a fresh instance with lead-ins baked in.
## ContourCuttingStrategy Changes
### Corner vs Mid-Entity Auto-Detection
When generating the lead-out for standard (non-tabbed) parts, the strategy detects whether the pierce point landed on a corner or mid-entity. Detection uses the `out Entity` from `ClosestPointTo` with type-specific endpoint checks:
```csharp
bool isCorner;
if (entity is Line line)
isCorner = closestPt.DistanceTo(line.StartPoint) < Tolerance.Epsilon
|| closestPt.DistanceTo(line.EndPoint) < Tolerance.Epsilon;
else if (entity is Arc arc)
isCorner = closestPt.DistanceTo(arc.StartPoint()) < Tolerance.Epsilon
|| closestPt.DistanceTo(arc.EndPoint()) < Tolerance.Epsilon;
else
isCorner = false;
```
Note: `Entity` has no polymorphic `StartPoint`/`EndPoint``Line` has properties, `Arc` has methods, `Circle` has neither.
### Corner Lead-Out
Delegates to `LeadOut.Generate()` as normal — `LineLeadOut` extends past the corner along the contour normal.
### Mid-Entity Lead-Out (Contour-Follow Overtravel)
Handled at the `ContourCuttingStrategy` level, NOT via `LeadOut.Generate()` (which lacks access to the contour shape). After the reindexed shape's moves are emitted, the strategy appends additional moves that retrace the beginning of the contour for the overtravel distance. This is done by:
1. Walking the reindexed shape's entities from the start
2. Accumulating distance until `overtravel` is reached
3. Emitting `LinearMove`/`ArcMove` codes for those segments (splitting the last segment if needed)
This produces a clean overcut that ensures the contour fully closes.
### Tabbed Lead-Out
For tabbed parts, the lead-out is always a `LineLeadOut` at the specified angle and length, regardless of corner/mid-entity. This creates the V shape.
## File Structure
```
OpenNest.Core/
├── Part.cs # add IsTabbed, ApplyLeadIns
└── CNC/CuttingStrategy/
└── ContourCuttingStrategy.cs # corner vs mid-entity lead-out detection
OpenNest.Engine/
└── Sequencing/
└── PlateHelper.cs # change internal → public
OpenNest/
├── Forms/
│ ├── LeadInForm.cs # new dialog
│ ├── LeadInForm.Designer.cs # new dialog designer
│ ├── MainForm.Designer.cs # add menu item
│ ├── MainForm.cs # add click handler
│ └── EditNestForm.cs # add AssignLeadIns method
└── LeadInSettings.cs # settings DTO
```
## Known Limitations
- `Part.IsTabbed` defaults to `false` with no UI to set it yet. All parts use standard parameters until a tab assignment UI is built. The tabbed code path is present but exercised only programmatically or via MCP tools for now.
- `IsTabbed` is not yet serialized through the nest file format (NestWriter/NestReader). Will need serialization support when the tab assignment UI is added.
## Out of Scope
- Per-contour-type lead-in configuration (deferred to database/datagrid UI)
- Lead-in visualization in PlateView (separate enhancement)
- Database storage of lead-in presets by material/thickness
- Tab assignment UI (setting `Part.IsTabbed`)
- MicrotabLeadOut integration
- Nest file serialization of `IsTabbed`