Compare commits

..

56 Commits

Author SHA1 Message Date
a548d5329a chore: update NestProgressForm designer layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:09:07 -04:00
07012033c7 feat: use direction-specific engines in StripNestEngine
Height shrink now uses HorizontalRemnantEngine (minimizes Y-extent)
and width shrink uses VerticalRemnantEngine (minimizes X-extent).
IterativeShrinkFiller accepts an optional widthFillFunc so each
shrink axis can use a different fill engine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:09:02 -04:00
92b17b2963 perf: parallelize PairFiller candidates and add GridDedup
- Evaluate pair candidates in parallel batches instead of sequentially
- Add GridDedup to skip duplicate pattern/direction/workArea combos
  across PairFiller and StripeFiller strategies
- Replace crude 30% remnant area estimate with L-shaped geometry
  calculation using actual grid extents and max utilization
- Move FillStrategyRegistry.SetEnabled to outer evaluation loop
  to avoid repeated enable/disable per remnant fill

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:08:55 -04:00
b6ee04f038 fix: use Part.Rotate() in PlateView to avoid mutating shared Programs
RotateSelectedParts was calling Program.Rotate() directly on shared
Program instances, bypassing Part's copy-on-write (EnsureOwnedProgram).
Parts created via CloneAtOffset share the same Program, so rotating one
part would rotate all parts with the same reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:08:47 -04:00
8ffdacd6c0 refactor: replace NestPhase switch statements with attribute-based extensions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 19:49:44 -04:00
ccd402c50f refactor: simplify NestProgress with computed properties and ProgressReport struct
Replace stored property setters (BestPartCount, BestDensity, NestedWidth,
NestedLength, NestedArea) with computed properties that derive values from
BestParts, with a lazy cache invalidated on setter. Add internal
ProgressReport struct to replace the 7-parameter ReportProgress signature.
Update all 13 callsites and AccumulatingProgress. Delete FormatPhaseName
in favor of NestPhase.ShortName() extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:44:45 -04:00
b1e872577c feat: add Description/ShortName attributes to NestPhase with extension methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 19:38:54 -04:00
9903478d3e refactor: simplify BestCombination.FindFrom2 and add tests
Remove redundant early-return branches and unify loop body — Floor(remaining/length2) already returns 0 when remaining < length2, so both branches collapse into one. 14 tests cover all edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:07:43 -04:00
93a8981d0a feat: add Disable/Enable API to FillStrategyRegistry
Adds methods to permanently disable/enable strategies by name. Disabled
strategies remain registered but are excluded from the default pipeline.
SetEnabled (used for remnant fills) takes precedence over the disabled
set, so explicit overrides still work.

Pipeline test now checks against active strategy count dynamically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:19:27 -04:00
00e7866506 feat: add remnant filling to PairFiller for better part density
PairFiller previously only filled the main grid with pair patterns,
leaving narrow waste strips unfilled. Row/Column strategies filled
their remnants, winning on count despite worse base grids.

Now PairFiller evaluates grid+remnant together for each angle/direction
combination, picking the best total. Uses a two-phase approach: fast
grid evaluation first, then remnant filling only for grids within
striking distance of the current best. Remnant results are cached
via FillResultCache.

Constructor now takes Plate (needed to create remnant engine).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:19:19 -04:00
560105f952 refactor: extract shared convergence loop and reduce parameter counts in StripeFiller
Extract ConvergeFromAngle to deduplicate ~40 lines shared between
ConvergeStripeAngle and ConvergeStripeAngleShrink. Reduce BuildGrid
from 7 to 4 params and FillRemnant from 6 to 2 by reading context
fields directly. Remove unused angle parameter from FillRemnant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:22:29 -04:00
266f8a83e6 docs: update CLAUDE.md with fill goal engines architecture
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:58:35 -04:00
0b7697e9c0 feat: add VerticalRemnantEngine and HorizontalRemnantEngine
Two new engine classes subclassing DefaultNestEngine that override
CreateComparer, PreferredDirection, and BuildAngles to optimize for
preserving side remnants. Both registered in NestEngineRegistry and
covered by 6 integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:57:33 -04:00
83124eb38d feat: wire IFillComparer through PairFiller and StripeFiller
PairFiller now accepts an optional IFillComparer (defaulting to
DefaultFillComparer) and uses it in EvaluateCandidates and
EvaluateCandidate/FillPattern instead of raw FillScore comparisons.
PairsFillStrategy passes context.Policy?.Comparer through.
StripeFiller derives _comparer from FillContext.Policy in its
constructor and uses it in Fill() instead of FillScore comparisons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:53:28 -04:00
24beb8ada1 feat: wire IFillComparer through FillHelpers, Linear, and Extents strategies
- FillHelpers.FillPattern gains optional IFillComparer parameter; falls back to FillScore when null
- LinearFillStrategy.Fill replaced with FillWithDirectionPreference + comparer from context.Policy
- ExtentsFillStrategy.Fill replaced with comparer.IsBetter, removing FillScore comparison
- DefaultNestEngine group-fill path resolves Task 6 TODO, passing Comparer to FillPattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:49:59 -04:00
ee83f17afe feat: wire FillPolicy into DefaultNestEngine and FillContext
- FillContext gains a Policy property (init-only) carrying the IFillComparer
- DefaultNestEngine.Fill sets Policy = BuildPolicy() on every context
- RunPipeline now uses context.Policy.Comparer.IsBetter instead of IsBetterFill
- RunPipeline promoted to protected virtual so subclasses can override
- BuildAngles/RecordProductiveAngles overrides delegate to angleBuilder
- RunPipeline calls virtual BuildAngles/RecordProductiveAngles instead of angleBuilder directly
- TODO comment added in group-fill overload for Task 6 Comparer pass-through

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:46:30 -04:00
99546e7eef feat: add IFillComparer hooks to NestEngineBase
Add virtual comparer, direction, and angle-building hooks to NestEngineBase
so subclasses can override scoring and direction policy. Rewire IsBetterFill
to delegate to the comparer instead of calling FillScore directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:43:45 -04:00
4586a53590 feat: add FillPolicy record and FillHelpers.FillWithDirectionPreference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:41:07 -04:00
1a41eeb81d feat: add VerticalRemnantComparer and HorizontalRemnantComparer
Implements two IFillComparer strategies that preserve axis-aligned remnants:
VerticalRemnantComparer minimizes X-extent, HorizontalRemnantComparer minimizes
Y-extent, both using a count > extent > density tiebreak chain. Includes 12
unit tests covering all tiebreak levels and null-guard cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:38:23 -04:00
f894ffd27c feat: add IFillComparer interface and DefaultFillComparer
Extracts the fill result scoring contract into IFillComparer with a DefaultFillComparer implementation that preserves the existing count-then-density lexicographic ranking via FillScore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:36:04 -04:00
0ec22f2207 feat: geometry-aware convergence, both-axis search, remnant engine, fill cache
- Convergence loop now uses FillLinear internally to measure actual
  waste with geometry-aware spacing instead of bounding-box arithmetic
- Each candidate pair is tried in both Row and Column orientations to
  find the shortest perpendicular dimension (more complete stripes)
- CompleteStripesOnly flag drops partial stripes; remnant strip is
  filled by a full engine run (injected via CreateRemnantEngine)
- ConvergeStripeAngleShrink tries N+1 narrower pairs as alternative
- FillResultCache avoids redundant engine runs on same-sized remnants
- CLAUDE.md: note to not commit specs/plans

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:12:31 -04:00
3f3d95a5e4 fix: orient pair short side along primary axis before convergence
The convergence loop now ensures the pair starts with its short side
parallel to the primary axis, maximizing the number of pairs that fit.
Also adds ConvergeStripeAngleShrink to try N+1 narrower pairs, and
evaluates both expand and shrink results to pick the better grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:22:13 -04:00
811d23510e feat: add RowFillStrategy and ColumnFillStrategy with registry integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:48:00 -04:00
0597a11a23 feat: implement StripeFiller.Fill with pair iteration, stripe tiling, and remnant fill
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:44:59 -04:00
2ae1d513cf feat: add StripeFiller.ConvergeStripeAngle iterative convergence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:37:59 -04:00
904d30d05d feat: add StripeFiller.FindAngleForTargetSpan with scan-then-bisect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:36:10 -04:00
e9678c73b2 chore: remove remaining stale plan docs
All features have been implemented; docs recoverable from git history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:14:18 -04:00
4060430757 chore: remove stale superpowers docs and update gitignore
Remove implemented plan/spec docs from docs/superpowers/ (recoverable
from git history). Add .superpowers/ and launchSettings.json to gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:13:01 -04:00
de527cd668 feat: add plate utilization to UI status bar
Display current plate utilization percentage in the status bar,
updating live when parts are added or removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:10:42 -04:00
9887cb1aa3 fix: swap BestFitCell dimension display to height x width
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:04:23 -04:00
cdf8e4e40e refactor: use IDistanceComputer and rename Type to StrategyIndex
Wire IDistanceComputer into RotationSlideStrategy, replacing inline
CPU/GPU branching. BestFitFinder constructs the appropriate implementation.
Replace PushDirection enum with direction vectors in BuildOffsets.
Rename IBestFitStrategy.Type and PairCandidate.StrategyType to StrategyIndex
for clarity (JSON field name unchanged for backward compatibility).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:04:19 -04:00
4f21fb91a1 refactor: extract IDistanceComputer with CPU and GPU implementations
Extract distance computation from RotationSlideStrategy into a pluggable
IDistanceComputer interface. CpuDistanceComputer adds leading-face vertex
culling (~50% fewer rays per direction) with early exit on overlap.
GpuDistanceComputer wraps ISlideComputer with Line-to-flat-array conversion.
SlideOffset struct uses direction vectors (DirX/DirY) instead of PushDirection.
SpatialQuery.RayEdgeDistance(dirX,dirY) made public for CPU path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:04:12 -04:00
7f96d632f3 fix: correct NFP polygon computation and inflation direction
Three bugs fixed in NfpSlideStrategy pipeline:

1. NoFitPolygon.Reflect() incorrectly reversed vertex order. Point
   reflection (negating both axes) is a 180° rotation that preserves
   winding — the Reverse() call was converting CCW to CW, producing
   self-intersecting bowtie NFPs.

2. PolygonHelper inflation used OffsetSide.Left which is inward for
   CCW perimeters. Changed to OffsetSide.Right for outward inflation
   so NFP boundary positions give properly-spaced part placements.

3. Removed incorrect correction vector — same-drawing pairs have
   identical polygon-to-part offsets that cancel out in the NFP
   displacement.

Also refactored NfpSlideStrategy to be immutable (removed mutable
cache fields, single constructor with required data, added Create
factory method). BestFitFinder remains on RotationSlideStrategy
as default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:24:04 -04:00
38dcaf16d3 revert: switch BestFitFinder back to RotationSlideStrategy
NFP strategy has coordinate correction issues causing overlaps.
The slide-based approach is fast and accurate — keeping it as default.
NfpSlideStrategy and PolygonHelper remain in the codebase for future use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:12:16 -04:00
8c57e43221 fix: use NoFitPolygon.Compute with hull inputs instead of direct ConvexMinkowskiSum
Calling ConvexMinkowskiSum directly with manual reflection produced
wrong winding/reference-point handling, causing all pairs to overlap.
Route through Compute which handles reflection correctly. Hull inputs
keep it fast — few triangles means trivial Clipper union.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:59:53 -04:00
bc78ddc49c perf: use convex hull NFP to avoid Clipper2 union bottleneck
ConvexMinkowskiSum is O(n+m) with no boolean geometry ops.
The concave Minkowski path was doing triangulation + pairwise
sums + Clipper2 Union, which hung at 100% CPU for complex parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:54:19 -04:00
c88cec2beb perf: remove no-op AutoNester.Optimize calls from fill pipelines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:11:49 -04:00
b7c7cecd75 feat: wire NfpSlideStrategy into BestFitFinder pipeline
Replace RotationSlideStrategy with NfpSlideStrategy in BuildStrategies,
and add integration tests covering the end-to-end FindBestFits pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:09:48 -04:00
4d0d8c453b fix: guard stepSize <= 0 in NfpSlideStrategy to prevent infinite loop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:07:43 -04:00
5f4288a786 feat: add NfpSlideStrategy for NFP-based best-fit candidate generation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:03:52 -04:00
707ddb80d9 style: fix var rule violation in PolygonHelper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:01:22 -04:00
71f28600d1 refactor: extract PolygonHelper from AutoNester for shared polygon operations
Creates PolygonHelper.cs in OpenNest.Engine.BestFit with ExtractPerimeterPolygon
(returning PolygonExtractionResult with polygon + correction vector) and RotatePolygon.
AutoNester.ExtractPerimeterPolygon and RotatePolygon become thin delegates.
Adds MakeSquareDrawing/MakeLShapeDrawing to TestHelpers and 6 PolygonHelperTests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:56:20 -04:00
d39b0ae540 docs: add NFP best-fit strategy implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:45:50 -04:00
ee5c77c645 docs: address spec review — coordinate correction, edge cases
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:32:12 -04:00
4615bcb40d docs: add NFP best-fit strategy design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:28:40 -04:00
7843de145b fix: swap bounding box dimensions in BestFitViewerForm
Size(width, length) maps Width to vertical and Length to horizontal in
PlateView, but BoundingWidth (the longer dimension) was being passed as
Width (vertical) instead of Length (horizontal), causing the bounding
box to appear portrait instead of landscape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:19:16 -04:00
2d1f2217e5 fix: guard IsHandleCreated in EditNestForm timer
Prevent InvalidOperationException when the timer fires before or
after the control handle is available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:51 -04:00
ae88c34361 fix: prioritize width-fitting candidates in PairFiller strip mode
In strip mode, build candidate list entirely from pairs whose
ShortestSide fits the narrow work area dimension, sorted by
estimated tile count. Previously, the top-50 utilization cut
ran first, excluding good strip candidates like #183.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:45 -04:00
708d895a04 perf: remove automatic angle sweep in linear fill
Remove NeedsSweep that triggered a 5-degree sweep (36 angles) when
the work area was narrower than the part. Position matters more than
angle for narrow areas, and the base angles (bestRotation + 90deg)
cover the useful cases. ForceFullSweep still works for training.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:38 -04:00
884817c5f9 fix: normalize best-fit pairs to landscape and fix viewer size swap
Normalize pair bounding box to landscape (width >= height) in
PairEvaluator for consistent display and filtering. Fix
BestFitViewerForm where BoundingWidth/BoundingHeight were passed
in the wrong order to the plate Size constructor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:31 -04:00
cf1c5fe120 feat: integrate NFP optimization into nest engines and fill UI
Add Compactor.Settle and AutoNester.Optimize post-passes to
NestEngineBase.Nest, StripNestEngine, and PlateView.FillWithProgress
so all fill paths benefit from geometry-aware compaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:24 -04:00
a04586f7df feat: add AutoNester.Optimize post-pass and NfpNestEngine
Add Optimize method that re-places parts using NFP-based BLF, keeping
the result only if it improves density without losing parts. Fix
perimeter inflation to use correct offset side. Add NfpNestEngine
that wraps AutoNester for the registry. Register NFP engine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:18 -04:00
069e966453 feat: add Compactor.Settle for iterative compaction
Add Settle method that repeatedly pushes parts left then down until
total movement falls below a threshold. Replaces manual single-pass
push calls for more consistent gap closure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:10 -04:00
d9d275b675 feat: improve BLF with Clipper paths, spatial pruning, and progress
Refactor BLF to compute NFP paths as Clipper PathsD with offsets
instead of translating full polygons. Add spatial pruning to skip
NFPs that don't intersect the IFP bounds. Clamp placement points
to IFP bounds to correct Clipper2 floating-point drift. Add
progress reporting to simulated annealing. Add debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:04 -04:00
9411dd0fdd refactor: extract PlacedPart/SequenceEntry types, add IFP caching
Move PlacedPart to its own file. Replace tuple-based sequences with
SequenceEntry struct for clarity. Add IProgress parameter to
INestOptimizer. Add IFP caching to NfpCache to avoid recomputing
inner fit polygons for the same drawing/rotation/workArea.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:42:50 -04:00
facd07d7de feat: add Box.Translate and improve NFP/IFP geometry APIs
Add immutable Translate methods to Box. Make NoFitPolygon
ToClipperPath/FromClipperPath public with optional offset parameter.
Refactor InnerFitPolygon.ComputeFeasibleRegion to accept PathsD
directly, letting Clipper2 handle implicit union. Add UpdateBounds
calls after polygon construction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:42:43 -04:00
144 changed files with 3748 additions and 30252 deletions

4
.gitignore vendored
View File

@@ -208,3 +208,7 @@ FakesAssemblies/
# Claude Code
.claude/
.superpowers/
# Launch settings
**/Properties/launchSettings.json

View File

@@ -35,7 +35,8 @@ Domain model, geometry, and CNC primitives organized into namespaces:
### OpenNest.Engine (class library, depends on Core)
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/`.
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases)`VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
- **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.
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
@@ -100,6 +101,8 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho
Always keep `README.md` and `CLAUDE.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change.
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
## Key Patterns
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.

View File

@@ -74,6 +74,16 @@ namespace OpenNest.Geometry
Location += voffset;
}
public Box Translate(double x, double y)
{
return new Box(X + x, Y + y, Width, Length);
}
public Box Translate(Vector offset)
{
return new Box(X + offset.X, Y + offset.Y, Width, Length);
}
public double Left
{
get { return X; }

View File

@@ -52,6 +52,7 @@ namespace OpenNest.Geometry
result.Vertices.Add(new Vector(ifpRight, ifpTop));
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
result.Close();
result.UpdateBounds();
return result;
}
@@ -62,36 +63,20 @@ namespace OpenNest.Geometry
/// Returns the polygon representing valid placement positions, or an empty
/// polygon if no valid position exists.
/// </summary>
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
public static Polygon ComputeFeasibleRegion(Polygon ifp, PathsD nfpPaths)
{
if (ifp.Vertices.Count < 3)
return new Polygon();
if (nfps == null || nfps.Length == 0)
if (nfpPaths == null || nfpPaths.Count == 0)
return ifp;
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
var ifpPaths = new PathsD { ifpPath };
// Union all NFPs.
var nfpPaths = new PathsD();
foreach (var nfp in nfps)
{
if (nfp.Vertices.Count >= 3)
{
var path = NoFitPolygon.ToClipperPath(nfp);
nfpPaths.Add(path);
}
}
if (nfpPaths.Count == 0)
return ifp;
var nfpUnion = Clipper.Union(nfpPaths, FillRule.NonZero);
// Subtract the NFP union from the IFP.
var feasible = Clipper.Difference(ifpPaths, nfpUnion, FillRule.NonZero);
// Subtract the NFPs from the IFP.
// Clipper2 handles the implicit union of the clip paths.
var feasible = Clipper.Difference(ifpPaths, nfpPaths, FillRule.NonZero);
if (feasible.Count == 0)
return new Polygon();
@@ -118,6 +103,25 @@ namespace OpenNest.Geometry
return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon();
}
/// <summary>
/// Computes the feasible region for placing a part given already-placed parts.
/// (Legacy overload for backward compatibility).
/// </summary>
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
{
if (nfps == null || nfps.Length == 0)
return ifp;
var nfpPaths = new PathsD(nfps.Length);
foreach (var nfp in nfps)
{
if (nfp.Vertices.Count >= 3)
nfpPaths.Add(NoFitPolygon.ToClipperPath(nfp));
}
return ComputeFeasibleRegion(ifp, nfpPaths);
}
/// <summary>
/// Finds the bottom-left-most point on a polygon boundary.
/// "Bottom-left" means: minimize Y first, then minimize X.

View File

@@ -1,4 +1,5 @@
using Clipper2Lib;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Geometry
@@ -22,8 +23,20 @@ namespace OpenNest.Geometry
return MinkowskiSum(stationary, reflected);
}
/// <summary>
/// Optimized version of Compute for polygons known to be convex.
/// Bypasses expensive triangulation and Clipper unions.
/// </summary>
public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
{
var reflected = Reflect(orbiting);
return ConvexMinkowskiSum(stationary, reflected);
}
/// <summary>
/// Reflects a polygon through the origin (negates all vertex coordinates).
/// Point reflection (negating both axes) is equivalent to 180° rotation,
/// which preserves winding order. No reversal needed.
/// </summary>
private static Polygon Reflect(Polygon polygon)
{
@@ -32,8 +45,6 @@ namespace OpenNest.Geometry
foreach (var v in polygon.Vertices)
result.Vertices.Add(new Vector(-v.X, -v.Y));
// Reflecting reverses winding order — reverse to maintain CCW.
result.Vertices.Reverse();
return result;
}
@@ -78,19 +89,24 @@ namespace OpenNest.Geometry
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
/// Both polygons must have CCW winding.
/// </summary>
internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
public static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
{
var edgesA = GetEdgeVectors(a);
var edgesB = GetEdgeVectors(b);
// Find bottom-most (then left-most) vertex for each polygon as starting point.
// Find indices of bottom-left vertices for both.
var startA = FindBottomLeft(a);
var startB = FindBottomLeft(b);
var result = new Polygon();
// The starting point of the Minkowski sum A + B is the sum of the
// starting points of A and B. For NFP = A + (-B), this is
// startA + startReflectedB.
var current = new Vector(
a.Vertices[startA].X + b.Vertices[startB].X,
a.Vertices[startA].Y + b.Vertices[startB].Y);
result.Vertices.Add(current);
var ia = 0;
@@ -98,7 +114,6 @@ namespace OpenNest.Geometry
var na = edgesA.Count;
var nb = edgesB.Count;
// Reorder edges to start from the bottom-left vertex.
var orderedA = ReorderEdges(edgesA, startA);
var orderedB = ReorderEdges(edgesB, startB);
@@ -117,7 +132,10 @@ namespace OpenNest.Geometry
else
{
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
if (angleA < 0) angleA += Angle.TwoPI;
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
if (angleB < 0) angleB += Angle.TwoPI;
if (angleA < angleB)
{
@@ -129,7 +147,6 @@ namespace OpenNest.Geometry
}
else
{
// Same angle — merge both edges.
edge = new Vector(
orderedA[ia].X + orderedB[ib].X,
orderedA[ia].Y + orderedB[ib].Y);
@@ -143,6 +160,7 @@ namespace OpenNest.Geometry
}
result.Close();
result.UpdateBounds();
return result;
}
@@ -250,9 +268,9 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Converts an OpenNest Polygon to a Clipper2 PathD.
/// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset.
/// </summary>
internal static PathD ToClipperPath(Polygon polygon)
public static PathD ToClipperPath(Polygon polygon, Vector offset = default)
{
var path = new PathD();
var verts = polygon.Vertices;
@@ -263,7 +281,7 @@ namespace OpenNest.Geometry
n--;
for (var i = 0; i < n; i++)
path.Add(new PointD(verts[i].X, verts[i].Y));
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
return path;
}
@@ -271,7 +289,7 @@ namespace OpenNest.Geometry
/// <summary>
/// Converts a Clipper2 PathD to an OpenNest Polygon.
/// </summary>
internal static Polygon FromClipperPath(PathD path)
public static Polygon FromClipperPath(PathD path)
{
var polygon = new Polygon();
@@ -279,6 +297,7 @@ namespace OpenNest.Geometry
polygon.Vertices.Add(new Vector(pt.x, pt.y));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
}

View File

@@ -76,7 +76,7 @@ namespace OpenNest.Geometry
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
private static double RayEdgeDistance(
public static double RayEdgeDistance(
double vx, double vy,
double p1x, double p1y, double p2x, double p2y,
double dirX, double dirY)

View File

@@ -12,14 +12,16 @@ namespace OpenNest.Engine.BestFit
public class BestFitFinder
{
private readonly IPairEvaluator _evaluator;
private readonly ISlideComputer _slideComputer;
private readonly IDistanceComputer _distanceComputer;
private readonly BestFitFilter _filter;
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
{
_evaluator = evaluator ?? new PairEvaluator();
_slideComputer = slideComputer;
_distanceComputer = slideComputer != null
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
: new CpuDistanceComputer();
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
_filter = new BestFitFilter
@@ -36,7 +38,7 @@ namespace OpenNest.Engine.BestFit
double stepSize = 0.25,
BestFitSortField sortBy = BestFitSortField.Area)
{
var strategies = BuildStrategies(drawing);
var strategies = BuildStrategies(drawing, spacing);
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
@@ -75,16 +77,16 @@ namespace OpenNest.Engine.BestFit
.ToList();
}
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
private List<IBestFitStrategy> BuildStrategies(Drawing drawing, double spacing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List<IBestFitStrategy>();
var type = 1;
var index = 1;
foreach (var angle in angles)
{
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
strategies.Add(new RotationSlideStrategy(angle, type++, desc, _slideComputer));
strategies.Add(new RotationSlideStrategy(angle, index++, desc, _distanceComputer));
}
return strategies;
@@ -226,7 +228,7 @@ namespace OpenNest.Engine.BestFit
case BestFitSortField.ShortestSide:
return results.OrderBy(r => r.ShortestSide).ToList();
case BestFitSortField.Type:
return results.OrderBy(r => r.Candidate.StrategyType)
return results.OrderBy(r => r.Candidate.StrategyIndex)
.ThenBy(r => r.Candidate.TestNumber).ToList();
case BestFitSortField.OriginalSequence:
return results.OrderBy(r => r.Candidate.TestNumber).ToList();

View File

@@ -0,0 +1,152 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.BestFit
{
public class CpuDistanceComputer : IDistanceComputer
{
public double[] ComputeDistances(
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets)
{
var count = offsets.Length;
var results = new double[count];
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var offset = offsets[i];
var dirX = offset.DirX;
var dirY = offset.DirY;
var oppX = -dirX;
var oppY = -dirY;
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary edges
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
var vy = leadingMoving[v].Y + offset.Dy;
for (var j = 0; j < stationaryLines.Count; j++)
{
var e = stationaryLines[j];
var d = SpatialQuery.RayEdgeDistance(
vx, vy,
e.StartPoint.X, e.StartPoint.Y,
e.EndPoint.X, e.EndPoint.Y,
dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Case 2: Facing stationary vertices → moving edges (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
var svy = facingStationary[v].Y;
for (var j = 0; j < movingTemplateLines.Count; j++)
{
var e = movingTemplateLines[j];
var d = SpatialQuery.RayEdgeDistance(
svx, svy,
e.StartPoint.X + offset.Dx, e.StartPoint.Y + offset.Dy,
e.EndPoint.X + offset.Dx, e.EndPoint.Y + offset.Dy,
oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
results[i] = minDist;
});
return results;
}
private static Vector[] ExtractUniqueVertices(List<Line> lines)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < lines.Count; i++)
{
vertices.Add(lines[i].StartPoint);
vertices.Add(lines[i].EndPoint);
}
return vertices.ToArray();
}
/// <summary>
/// Filters vertices by their projection onto the push direction.
/// keepHigh=true returns the leading half (front face, closest to target).
/// keepHigh=false returns the facing half (side facing the approaching part).
/// </summary>
private static Vector[] FilterVerticesByProjection(
Vector[] vertices, double dirX, double dirY, bool keepHigh)
{
if (vertices.Length == 0)
return vertices;
var projections = new double[vertices.Length];
var min = double.MaxValue;
var max = double.MinValue;
for (var i = 0; i < vertices.Length; i++)
{
projections[i] = vertices[i].X * dirX + vertices[i].Y * dirY;
if (projections[i] < min) min = projections[i];
if (projections[i] > max) max = projections[i];
}
var midpoint = (min + max) / 2;
var count = 0;
for (var i = 0; i < vertices.Length; i++)
{
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
count++;
}
var result = new Vector[count];
var idx = 0;
for (var i = 0; i < vertices.Length; i++)
{
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
result[idx++] = vertices[i];
}
return result;
}
}
}

View File

@@ -0,0 +1,51 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public class GpuDistanceComputer : IDistanceComputer
{
private readonly ISlideComputer _slideComputer;
public GpuDistanceComputer(ISlideComputer slideComputer)
{
_slideComputer = slideComputer;
}
public double[] ComputeDistances(
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets)
{
var stationarySegments = SpatialQuery.FlattenLines(stationaryLines);
var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines);
var count = offsets.Length;
var flatOffsets = new double[count * 2];
var directions = new int[count];
for (var i = 0; i < count; i++)
{
flatOffsets[i * 2] = offsets[i].Dx;
flatOffsets[i * 2 + 1] = offsets[i].Dy;
directions[i] = DirectionVectorToInt(offsets[i].DirX, offsets[i].DirY);
}
return _slideComputer.ComputeBatchMultiDir(
stationarySegments, stationaryLines.Count,
movingSegments, movingTemplateLines.Count,
flatOffsets, count, directions);
}
/// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
/// Left=0, Down=1, Right=2, Up=3.
/// </summary>
private static int DirectionVectorToInt(double dirX, double dirY)
{
if (dirX < -0.5) return (int)PushDirection.Left;
if (dirX > 0.5) return (int)PushDirection.Right;
if (dirY < -0.5) return (int)PushDirection.Down;
return (int)PushDirection.Up;
}
}
}

View File

@@ -4,7 +4,7 @@ namespace OpenNest.Engine.BestFit
{
public interface IBestFitStrategy
{
int Type { get; }
int StrategyIndex { get; }
string Description { get; }
List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
}

View File

@@ -0,0 +1,13 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public interface IDistanceComputer
{
double[] ComputeDistances(
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets);
}
}

View File

@@ -0,0 +1,179 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.IO;
namespace OpenNest.Engine.BestFit
{
public class NfpSlideStrategy : IBestFitStrategy
{
private static readonly string LogPath = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
"nfp-slide-debug.log");
private static readonly object LogLock = new object();
private readonly double _part2Rotation;
private readonly Polygon _stationaryPerimeter;
private readonly Polygon _stationaryHull;
private readonly Vector _correction;
public NfpSlideStrategy(double part2Rotation, int type, string description,
Polygon stationaryPerimeter, Polygon stationaryHull, Vector correction)
{
_part2Rotation = part2Rotation;
StrategyIndex = type;
Description = description;
_stationaryPerimeter = stationaryPerimeter;
_stationaryHull = stationaryHull;
_correction = correction;
}
public int StrategyIndex { get; }
public string Description { get; }
/// <summary>
/// Creates an NfpSlideStrategy by extracting polygon data from a drawing.
/// Returns null if the drawing has no valid perimeter.
/// </summary>
public static NfpSlideStrategy Create(Drawing drawing, double part2Rotation,
int type, string description, double spacing)
{
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, spacing / 2);
if (result.Polygon == null)
return null;
var hull = ConvexHull.Compute(result.Polygon.Vertices);
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
return new NfpSlideStrategy(part2Rotation, type, description,
result.Polygon, hull, result.Correction);
}
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
{
var candidates = new List<PairCandidate>();
if (stepSize <= 0)
return candidates;
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
// Orbiting polygon: same shape rotated to Part2's angle.
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
if (nfp == null || nfp.Vertices.Count < 3)
{
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
return candidates;
}
var verts = nfp.Vertices;
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
// Log NFP vertices
for (var v = 0; v < vertCount; v++)
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
// Compare with what RotationSlideStrategy would produce
var part1 = Part.CreateAtOrigin(drawing);
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
var testNumber = 0;
for (var i = 0; i < vertCount; i++)
{
var offset = ApplyCorrection(verts[i], _correction);
candidates.Add(MakeCandidate(drawing, offset, spacing, testNumber++));
// Add edge samples for long edges.
var next = (i + 1) % vertCount;
var dx = verts[next].X - verts[i].X;
var dy = verts[next].Y - verts[i].Y;
var edgeLength = System.Math.Sqrt(dx * dx + dy * dy);
if (edgeLength > stepSize)
{
var steps = (int)(edgeLength / stepSize);
for (var s = 1; s < steps; s++)
{
var t = (double)s / steps;
var sample = new Vector(
verts[i].X + dx * t,
verts[i].Y + dy * t);
var sampleOffset = ApplyCorrection(sample, _correction);
candidates.Add(MakeCandidate(drawing, sampleOffset, spacing, testNumber++));
}
}
}
// Log overlap check for vertex candidates (first few)
var checkCount = System.Math.Min(vertCount, 8);
for (var c = 0; c < checkCount; c++)
{
var cand = candidates[c];
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
p2.Location = cand.Part2Offset;
var overlaps = part1.Intersects(p2, out _);
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
}
Log($" Total candidates: {candidates.Count}");
Log("");
return candidates;
}
private static Vector ApplyCorrection(Vector nfpVertex, Vector correction)
{
return new Vector(nfpVertex.X - correction.X, nfpVertex.Y - correction.Y);
}
private PairCandidate MakeCandidate(Drawing drawing, Vector offset, double spacing, int testNumber)
{
return new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = _part2Rotation,
Part2Offset = offset,
StrategyIndex = StrategyIndex,
TestNumber = testNumber,
Spacing = spacing
};
}
private static string FormatBounds(Polygon polygon)
{
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
}
private static void Log(string message)
{
lock (LogLock)
{
File.AppendAllText(LogPath, message + "\n");
}
}
}
}

View File

@@ -8,7 +8,7 @@ namespace OpenNest.Engine.BestFit
public double Part1Rotation { get; set; }
public double Part2Rotation { get; set; }
public Vector Part2Offset { get; set; }
public int StrategyType { get; set; }
public int StrategyIndex { get; set; }
public int TestNumber { get; set; }
public double Spacing { get; set; }
}

View File

@@ -1,6 +1,7 @@
using OpenNest.Converters;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
@@ -67,6 +68,15 @@ namespace OpenNest.Engine.BestFit
var trueArea = drawing.Area * 2;
// Normalize to landscape (width >= height) for consistent display.
if (bestHeight > bestWidth)
{
var tmp = bestWidth;
bestWidth = bestHeight;
bestHeight = tmp;
bestRotation += Angle.HalfPI;
}
return new BestFitResult
{
Candidate = candidate,

View File

@@ -0,0 +1,77 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Linq;
namespace OpenNest.Engine.BestFit
{
public static class PolygonHelper
{
public static PolygonExtractionResult ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return new PolygonExtractionResult(null, Vector.Zero);
var definedShape = new ShapeProfile(entities);
var perimeter = definedShape.Perimeter;
if (perimeter == null)
return new PolygonExtractionResult(null, Vector.Zero);
// Inflate by half-spacing if spacing is non-zero.
// OffsetSide.Right = outward for CCW perimeters (standard for outer contours).
var inflated = halfSpacing > 0
? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter)
: perimeter;
// Convert to polygon with circumscribed arcs for tight nesting.
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
if (polygon.Vertices.Count < 3)
return new PolygonExtractionResult(null, Vector.Zero);
// Normalize: move polygon to origin.
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
polygon.Offset(-bb.Left, -bb.Bottom);
// No correction needed: BestFitFinder always pairs the same drawing with
// itself, so the polygon-to-part offset is identical for both parts and
// cancels out in the NFP displacement.
return new PolygonExtractionResult(polygon, Vector.Zero);
}
public static Polygon RotatePolygon(Polygon polygon, double angle, bool reNormalize = true)
{
if (angle.IsEqualTo(0))
return polygon;
var result = new Polygon();
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
foreach (var v in polygon.Vertices)
{
result.Vertices.Add(new Vector(
v.X * cos - v.Y * sin,
v.X * sin + v.Y * cos));
}
if (reNormalize)
{
// Re-normalize to origin.
result.UpdateBounds();
var bb = result.BoundingBox;
result.Offset(-bb.Left, -bb.Bottom);
}
return result;
}
}
public record PolygonExtractionResult(Polygon Polygon, Vector Correction);
}

View File

@@ -1,29 +1,31 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.BestFit
{
public class RotationSlideStrategy : IBestFitStrategy
{
private readonly ISlideComputer _slideComputer;
private readonly IDistanceComputer _distanceComputer;
private static readonly PushDirection[] AllDirections =
private static readonly (double DirX, double DirY)[] PushDirections =
{
PushDirection.Left, PushDirection.Down, PushDirection.Right, PushDirection.Up
(-1, 0), // Left
(0, -1), // Down
(1, 0), // Right
(0, 1) // Up
};
public RotationSlideStrategy(double part2Rotation, int type, string description,
ISlideComputer slideComputer = null)
public RotationSlideStrategy(double part2Rotation, int strategyIndex, string description,
IDistanceComputer distanceComputer)
{
Part2Rotation = part2Rotation;
Type = type;
StrategyIndex = strategyIndex;
Description = description;
_slideComputer = slideComputer;
_distanceComputer = distanceComputer;
}
public double Part2Rotation { get; }
public int Type { get; }
public int StrategyIndex { get; }
public string Description { get; }
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
@@ -40,36 +42,25 @@ namespace OpenNest.Engine.BestFit
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
// Collect offsets and directions across all 4 axes
var allDx = new List<double>();
var allDy = new List<double>();
var allDirs = new List<PushDirection>();
var offsets = BuildOffsets(bbox1, bbox2, spacing, stepSize);
foreach (var pushDir in AllDirections)
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
if (allDx.Count == 0)
if (offsets.Length == 0)
return candidates;
// Compute all distances — single GPU dispatch or CPU loop
var distances = ComputeAllDistances(
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
var distances = _distanceComputer.ComputeDistances(
part1Lines, part2TemplateLines, offsets);
// Create candidates from valid results
var testNumber = 0;
for (var i = 0; i < allDx.Count; i++)
for (var i = 0; i < offsets.Length; i++)
{
var slideDist = distances[i];
if (slideDist >= double.MaxValue || slideDist < 0)
continue;
var dx = allDx[i];
var dy = allDy[i];
var pushVector = GetPushVector(allDirs[i], slideDist);
var finalPosition = new Vector(
part2Template.Location.X + dx + pushVector.X,
part2Template.Location.Y + dy + pushVector.Y);
part2Template.Location.X + offsets[i].Dx + offsets[i].DirX * slideDist,
part2Template.Location.Y + offsets[i].Dy + offsets[i].DirY * slideDist);
candidates.Add(new PairCandidate
{
@@ -77,7 +68,7 @@ namespace OpenNest.Engine.BestFit
Part1Rotation = 0,
Part2Rotation = Part2Rotation,
Part2Offset = finalPosition,
StrategyType = Type,
StrategyIndex = StrategyIndex,
TestNumber = testNumber++,
Spacing = spacing
});
@@ -86,158 +77,44 @@ namespace OpenNest.Engine.BestFit
return candidates;
}
private static void BuildOffsets(
Box bbox1, Box bbox2, double spacing, double stepSize,
PushDirection pushDir, List<double> allDx, List<double> allDy,
List<PushDirection> allDirs)
private static SlideOffset[] BuildOffsets(Box bbox1, Box bbox2, double spacing, double stepSize)
{
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
var offsets = new List<SlideOffset>();
double perpMin, perpMax, pushStartOffset;
if (isHorizontalPush)
foreach (var (dirX, dirY) in PushDirections)
{
perpMin = -(bbox2.Length + spacing);
perpMax = bbox1.Length + bbox2.Length + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
}
else
{
perpMin = -(bbox2.Width + spacing);
perpMax = bbox1.Width + bbox2.Width + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
}
var isHorizontalPush = System.Math.Abs(dirX) > System.Math.Abs(dirY);
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
double perpMin, perpMax, pushStartOffset;
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
{
allDx.Add(isHorizontalPush ? startPos : offset);
allDy.Add(isHorizontalPush ? offset : startPos);
allDirs.Add(pushDir);
}
}
private double[] ComputeAllDistances(
List<Line> part1Lines, List<Line> part2TemplateLines,
List<double> allDx, List<double> allDy, List<PushDirection> allDirs)
{
var count = allDx.Count;
if (_slideComputer != null)
{
var stationarySegments = SpatialQuery.FlattenLines(part1Lines);
var movingSegments = SpatialQuery.FlattenLines(part2TemplateLines);
var offsets = new double[count * 2];
var directions = new int[count];
for (var i = 0; i < count; i++)
if (isHorizontalPush)
{
offsets[i * 2] = allDx[i];
offsets[i * 2 + 1] = allDy[i];
directions[i] = (int)allDirs[i];
perpMin = -(bbox2.Length + spacing);
perpMax = bbox1.Length + bbox2.Length + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
}
return _slideComputer.ComputeBatchMultiDir(
stationarySegments, part1Lines.Count,
movingSegments, part2TemplateLines.Count,
offsets, count, directions);
}
var results = new double[count];
// Pre-calculate moving vertices in local space.
var movingVerticesLocal = new HashSet<Vector>();
for (var i = 0; i < part2TemplateLines.Count; i++)
{
movingVerticesLocal.Add(part2TemplateLines[i].StartPoint);
movingVerticesLocal.Add(part2TemplateLines[i].EndPoint);
}
var movingVerticesArray = movingVerticesLocal.ToArray();
// Pre-calculate stationary vertices in local space.
var stationaryVerticesLocal = new HashSet<Vector>();
for (var i = 0; i < part1Lines.Count; i++)
{
stationaryVerticesLocal.Add(part1Lines[i].StartPoint);
stationaryVerticesLocal.Add(part1Lines[i].EndPoint);
}
var stationaryVerticesArray = stationaryVerticesLocal.ToArray();
// Pre-sort stationary and moving edges for all 4 directions.
var stationaryEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
var movingEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
foreach (var dir in AllDirections)
{
var sEdges = new (Vector start, Vector end)[part1Lines.Count];
for (var i = 0; i < part1Lines.Count; i++)
sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint);
if (dir == PushDirection.Left || dir == PushDirection.Right)
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
stationaryEdgesByDir[dir] = sEdges;
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);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
movingEdgesByDir[dir] = mEdges;
}
// Use Parallel.For for the heavy lifting.
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var dx = allDx[i];
var dy = allDy[i];
var dir = allDirs[i];
var movingOffset = new Vector(dx, dy);
var sEdges = stationaryEdgesByDir[dir];
var mEdges = movingEdgesByDir[dir];
var opposite = SpatialQuery.OppositeDirection(dir);
var minDist = double.MaxValue;
// Case 1: Moving vertices -> Stationary edges
foreach (var mv in movingVerticesArray)
{
var d = SpatialQuery.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
if (d < minDist) minDist = d;
perpMin = -(bbox2.Width + spacing);
perpMax = bbox1.Width + bbox2.Width + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
}
// Case 2: Stationary vertices -> Moving edges (translated)
foreach (var sv in stationaryVerticesArray)
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
// Start on the opposite side of the push direction.
var pushComponent = isHorizontalPush ? dirX : dirY;
var startPos = pushComponent < 0 ? pushStartOffset : -pushStartOffset;
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
{
var d = SpatialQuery.OneWayDistance(sv, mEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
var dx = isHorizontalPush ? startPos : offset;
var dy = isHorizontalPush ? offset : startPos;
offsets.Add(new SlideOffset(dx, dy, dirX, dirY));
}
results[i] = minDist;
});
return results;
}
private static Vector GetPushVector(PushDirection direction, double distance)
{
switch (direction)
{
case PushDirection.Left: return new Vector(-distance, 0);
case PushDirection.Right: return new Vector(distance, 0);
case PushDirection.Down: return new Vector(0, -distance);
case PushDirection.Up: return new Vector(0, distance);
default: return Vector.Zero;
}
return offsets.ToArray();
}
}
}

View File

@@ -0,0 +1,18 @@
namespace OpenNest.Engine.BestFit
{
public readonly struct SlideOffset
{
public double Dx { get; }
public double Dy { get; }
public double DirX { get; }
public double DirY { get; }
public SlideOffset(double dx, double dy, double dirX, double dirY)
{
Dx = dx;
Dy = dy;
DirX = dirX;
DirY = dirY;
}
}
}

View File

@@ -26,6 +26,16 @@ namespace OpenNest
set => angleBuilder.ForceFullSweep = value;
}
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
return angleBuilder.Build(item, bestRotation, workArea);
}
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
{
angleBuilder.RecordProductive(angleResults);
}
// --- Public Fill API ---
public override List<Part> Fill(NestItem item, Box workArea,
@@ -42,6 +52,7 @@ namespace OpenNest
PlateNumber = PlateNumber,
Token = token,
Progress = progress,
Policy = BuildPolicy(),
};
RunPipeline(context);
@@ -55,8 +66,15 @@ namespace OpenNest
if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary(),
isOverallBest: true);
ReportProgress(progress, new ProgressReport
{
Phase = WinnerPhase,
PlateNumber = PlateNumber,
Parts = best,
WorkArea = workArea,
Description = BuildProgressSummary(),
IsOverallBest = true,
});
return best;
}
@@ -78,13 +96,20 @@ namespace OpenNest
PhaseResults.Clear();
var engine = new FillLinear(workArea, Plate.PartSpacing);
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea);
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea, Comparer);
PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0));
Debug.WriteLine($"[Fill(groupParts,Box)] Linear pattern: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}");
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary(),
isOverallBest: true);
ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Linear,
PlateNumber = PlateNumber,
Parts = best,
WorkArea = workArea,
Description = BuildProgressSummary(),
IsOverallBest = true,
});
return best ?? new List<Part>();
}
@@ -105,12 +130,12 @@ namespace OpenNest
// --- RunPipeline: strategy-based orchestration ---
private void RunPipeline(FillContext context)
protected virtual void RunPipeline(FillContext context)
{
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
context.SharedState["BestRotation"] = bestRotation;
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
var angles = BuildAngles(context.Item, bestRotation, context.WorkArea);
context.SharedState["AngleCandidates"] = angles;
try
@@ -131,7 +156,7 @@ namespace OpenNest
// during progress reporting.
PhaseResults.Add(phaseResult);
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea))
{
context.CurrentBest = result;
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
@@ -140,9 +165,15 @@ namespace OpenNest
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
{
ReportProgress(context.Progress, context.WinnerPhase, PlateNumber,
context.CurrentBest, context.WorkArea, BuildProgressSummary(),
isOverallBest: true);
ReportProgress(context.Progress, new ProgressReport
{
Phase = context.WinnerPhase,
PlateNumber = PlateNumber,
Parts = context.CurrentBest,
WorkArea = context.WorkArea,
Description = BuildProgressSummary(),
IsOverallBest = true,
});
}
}
}
@@ -151,7 +182,7 @@ namespace OpenNest
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
}
angleBuilder.RecordProductive(context.AngleResults);
RecordProductiveAngles(context.AngleResults);
}
}

View File

@@ -26,7 +26,6 @@ namespace OpenNest.Engine.Fill
combined.AddRange(previousParts);
combined.AddRange(value.BestParts);
value.BestParts = combined;
value.BestPartCount = combined.Count;
}
inner.Report(value);

View File

@@ -27,7 +27,7 @@ namespace OpenNest.Engine.Fill
var angles = new List<double>(baseAngles);
if (NeedsSweep(item, bestRotation, workArea))
if (ForceFullSweep)
AddSweepAngles(angles);
if (!ForceFullSweep && angles.Count > 2)
@@ -36,18 +36,6 @@ namespace OpenNest.Engine.Fill
return angles;
}
private bool NeedsSweep(NestItem item, double bestRotation, Box workArea)
{
var testPart = new Part(item.Drawing);
if (!bestRotation.IsEqualTo(0))
testPart.Rotate(bestRotation);
testPart.UpdateBounds();
var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length);
var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length);
return workAreaShortSide < partLongestSide || ForceFullSweep;
}
private static void AddSweepAngles(List<double> angles)
{
var step = Angle.ToRadians(5);

View File

@@ -7,74 +7,30 @@ namespace OpenNest
public static bool FindFrom2(double length1, double length2, double overallLength, out int count1, out int count2)
{
overallLength += Tolerance.Epsilon;
if (length1 > overallLength)
{
if (length2 > overallLength)
{
count1 = 0;
count2 = 0;
return false;
}
count1 = 0;
count2 = (int)System.Math.Floor(overallLength / length2);
return true;
}
if (length2 > overallLength)
{
count1 = (int)System.Math.Floor(overallLength / length1);
count2 = 0;
return true;
}
var maxCountLength1 = (int)System.Math.Floor(overallLength / length1);
count1 = maxCountLength1;
count1 = 0;
count2 = 0;
var remnant = overallLength - maxCountLength1 * length1;
var maxCount1 = (int)System.Math.Floor(overallLength / length1);
var bestRemnant = overallLength + 1;
if (remnant.IsEqualTo(0))
return true;
for (int countLength1 = 0; countLength1 <= maxCountLength1; ++countLength1)
for (var c1 = 0; c1 <= maxCount1; c1++)
{
var remnant1 = overallLength - countLength1 * length1;
var remaining = overallLength - c1 * length1;
var c2 = (int)System.Math.Floor(remaining / length2);
var remnant = remaining - c2 * length2;
if (remnant1 >= length2)
{
var countLength2 = (int)System.Math.Floor(remnant1 / length2);
var remnant2 = remnant1 - length2 * countLength2;
if (!(remnant < bestRemnant))
continue;
if (!(remnant2 < remnant))
continue;
count1 = c1;
count2 = c2;
bestRemnant = remnant;
count1 = countLength1;
count2 = countLength2;
if (remnant2.IsEqualTo(0))
break;
remnant = remnant2;
}
else
{
if (!(remnant1 < remnant))
continue;
count1 = countLength1;
count2 = 0;
if (remnant1.IsEqualTo(0))
break;
remnant = remnant1;
}
if (remnant.IsEqualTo(0))
break;
}
return true;
return count1 > 0 || count2 > 0;
}
}
}

View File

@@ -174,5 +174,28 @@ namespace OpenNest.Engine.Fill
return 0;
}
/// <summary>
/// Repeatedly pushes parts left then down until total movement per
/// iteration falls below the given threshold.
/// </summary>
public static void Settle(List<Part> parts, Box workArea, double partSpacing,
double threshold = 0.01, int maxIterations = 20)
{
if (parts.Count < 2)
return;
var noObstacles = new List<Part>();
for (var i = 0; i < maxIterations; i++)
{
var moved = 0.0;
moved += Push(parts, noObstacles, workArea, partSpacing, PushDirection.Left);
moved += Push(parts, noObstacles, workArea, partSpacing, PushDirection.Down);
if (moved < threshold)
break;
}
}
}
}

View File

@@ -0,0 +1,23 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Ranks fill results by count first, then density.
/// This is the original scoring logic used by DefaultNestEngine.
/// </summary>
public class DefaultFillComparer : IFillComparer
{
public bool IsBetter(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);
}
}
}

View File

@@ -36,18 +36,36 @@ namespace OpenNest.Engine.Fill
if (column.Count == 0)
return new List<Part>();
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
column, workArea, $"Extents: initial column {column.Count} parts");
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = column,
WorkArea = workArea,
Description = $"Extents: initial column {column.Count} parts",
});
var adjusted = AdjustColumn(pair.Value, column, token);
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
adjusted, workArea, $"Extents: adjusted column {adjusted.Count} parts");
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = adjusted,
WorkArea = workArea,
Description = $"Extents: adjusted column {adjusted.Count} parts",
});
var result = RepeatColumns(adjusted, token);
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
result, workArea, $"Extents: {result.Count} parts total");
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = result,
WorkArea = workArea,
Description = $"Extents: {result.Count} parts total",
});
return result;
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using OpenNest.Geometry;
namespace OpenNest.Engine.Fill;
/// <summary>
/// Caches fill results by drawing and box dimensions so repeated fills
/// of the same size don't recompute. Parts are stored normalized to origin
/// and offset to the actual location on retrieval.
/// </summary>
public static class FillResultCache
{
private static readonly ConcurrentDictionary<CacheKey, List<Part>> _cache = new();
/// <summary>
/// Returns a cached fill result for the given drawing and box dimensions,
/// offset to the target location. Returns null on cache miss.
/// </summary>
public static List<Part> Get(Drawing drawing, Box targetBox, double spacing)
{
var key = new CacheKey(drawing, targetBox.Width, targetBox.Length, spacing);
if (!_cache.TryGetValue(key, out var cached) || cached.Count == 0)
return null;
var offset = targetBox.Location;
var result = new List<Part>(cached.Count);
foreach (var part in cached)
result.Add(part.CloneAtOffset(offset));
return result;
}
/// <summary>
/// Stores a fill result normalized to origin (0,0).
/// </summary>
public static void Store(Drawing drawing, Box sourceBox, double spacing, List<Part> parts)
{
if (parts == null || parts.Count == 0)
return;
var key = new CacheKey(drawing, sourceBox.Width, sourceBox.Length, spacing);
if (_cache.ContainsKey(key))
return;
var offset = new Vector(-sourceBox.X, -sourceBox.Y);
var normalized = new List<Part>(parts.Count);
foreach (var part in parts)
normalized.Add(part.CloneAtOffset(offset));
_cache.TryAdd(key, normalized);
}
public static void Clear() => _cache.Clear();
public static int Count => _cache.Count;
private readonly struct CacheKey : System.IEquatable<CacheKey>
{
public readonly Drawing Drawing;
public readonly double Width;
public readonly double Height;
public readonly double Spacing;
public CacheKey(Drawing drawing, double width, double height, double spacing)
{
Drawing = drawing;
Width = System.Math.Round(width, 2);
Height = System.Math.Round(height, 2);
Spacing = spacing;
}
public bool Equals(CacheKey other) =>
ReferenceEquals(Drawing, other.Drawing) &&
Width == other.Width && Height == other.Height &&
Spacing == other.Spacing;
public override bool Equals(object obj) => obj is CacheKey other && Equals(other);
public override int GetHashCode()
{
unchecked
{
var hash = RuntimeHelpers.GetHashCode(Drawing);
hash = hash * 397 ^ Width.GetHashCode();
hash = hash * 397 ^ Height.GetHashCode();
hash = hash * 397 ^ Spacing.GetHashCode();
return hash;
}
}
}
}

View File

@@ -0,0 +1,75 @@
using System;
using System.Collections.Concurrent;
using OpenNest.Geometry;
namespace OpenNest.Engine.Fill;
/// <summary>
/// Tracks evaluated grid configurations so duplicate pattern/direction/workArea
/// combinations can be skipped across fill strategies.
/// </summary>
public class GridDedup
{
public const string SharedStateKey = "GridDedup";
private readonly ConcurrentDictionary<GridKey, byte> _seen = new();
/// <summary>
/// Returns true if this configuration has NOT been seen before (i.e., should be evaluated).
/// Returns false if it's a duplicate.
/// </summary>
public bool TryAdd(Box patternBox, Box workArea, NestDirection dir)
{
var key = new GridKey(patternBox, workArea, dir);
return _seen.TryAdd(key, 0);
}
public int Count => _seen.Count;
/// <summary>
/// Gets or creates a GridDedup from FillContext.SharedState.
/// </summary>
public static GridDedup GetOrCreate(System.Collections.Generic.Dictionary<string, object> sharedState)
{
if (sharedState.TryGetValue(SharedStateKey, out var existing))
return (GridDedup)existing;
var dedup = new GridDedup();
sharedState[SharedStateKey] = dedup;
return dedup;
}
private readonly struct GridKey : IEquatable<GridKey>
{
private readonly int _patternW, _patternL, _workW, _workL, _dir;
public GridKey(Box patternBox, Box workArea, NestDirection dir)
{
_patternW = (int)System.Math.Round(patternBox.Width * 10);
_patternL = (int)System.Math.Round(patternBox.Length * 10);
_workW = (int)System.Math.Round(workArea.Width * 10);
_workL = (int)System.Math.Round(workArea.Length * 10);
_dir = (int)dir;
}
public bool Equals(GridKey other) =>
_patternW == other._patternW && _patternL == other._patternL &&
_workW == other._workW && _workL == other._workL &&
_dir == other._dir;
public override bool Equals(object obj) => obj is GridKey other && Equals(other);
public override int GetHashCode()
{
unchecked
{
var hash = _patternW;
hash = hash * 397 ^ _patternL;
hash = hash * 397 ^ _workW;
hash = hash * 397 ^ _workL;
hash = hash * 397 ^ _dir;
return hash;
}
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Ranks fill results to minimize Y-extent (preserve top-side horizontal remnant).
/// Tiebreak chain: count > smallest Y-extent > highest density.
/// </summary>
public class HorizontalRemnantComparer : IFillComparer
{
public bool IsBetter(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
if (candidate.Count != current.Count)
return candidate.Count > current.Count;
var candExtent = YExtent(candidate);
var currExtent = YExtent(current);
if (!candExtent.IsEqualTo(currExtent))
return candExtent < currExtent;
return FillScore.Compute(candidate, workArea).Density
> FillScore.Compute(current, workArea).Density;
}
private static double YExtent(List<Part> parts)
{
var minY = double.MaxValue;
var maxY = double.MinValue;
foreach (var part in parts)
{
var bb = part.BoundingBox;
if (bb.Bottom < minY) minY = bb.Bottom;
if (bb.Top > maxY) maxY = bb.Top;
}
return maxY - minY;
}
}
}

View File

@@ -31,7 +31,8 @@ namespace OpenNest.Engine.Fill
double spacing,
CancellationToken token = default,
IProgress<NestProgress> progress = null,
int plateNumber = 0)
int plateNumber = 0,
Func<NestItem, Box, List<Part>> widthFillFunc = null)
{
if (items == null || items.Count == 0)
return new IterativeShrinkResult();
@@ -72,6 +73,8 @@ namespace OpenNest.Engine.Fill
// include them in progress reports.
var placedSoFar = new List<Part>();
var wFillFunc = widthFillFunc ?? fillFunc;
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
{
var target = ni.Quantity > 0 ? ni.Quantity : 0;
@@ -84,7 +87,7 @@ namespace OpenNest.Engine.Fill
Parallel.Invoke(
() => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar),
() => widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token,
() => widthResult = ShrinkFiller.Shrink(wFillFunc, ni, box, spacing, ShrinkAxis.Width, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar)
);
@@ -108,8 +111,15 @@ namespace OpenNest.Engine.Fill
var allParts = new List<Part>(placedSoFar.Count + best.Count);
allParts.AddRange(placedSoFar);
allParts.AddRange(best);
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
allParts, box, $"Shrink: {best.Count} parts placed", isOverallBest: true);
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Custom,
PlateNumber = plateNumber,
Parts = allParts,
WorkArea = box,
Description = $"Shrink: {best.Count} parts placed",
IsOverallBest = true,
});
}
// Accumulate for the next item's progress reports.

View File

@@ -7,6 +7,8 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine;
namespace OpenNest.Engine.Fill
{
@@ -27,13 +29,19 @@ namespace OpenNest.Engine.Fill
private const int EarlyExitMinTried = 10;
private const int EarlyExitStaleLimit = 10;
private readonly Plate plate;
private readonly Size plateSize;
private readonly double partSpacing;
private readonly IFillComparer comparer;
private readonly GridDedup dedup;
public PairFiller(Size plateSize, double partSpacing)
public PairFiller(Plate plate, IFillComparer comparer = null, GridDedup dedup = null)
{
this.plateSize = plateSize;
this.partSpacing = partSpacing;
this.plate = plate;
this.plateSize = plate.Size;
this.partSpacing = plate.PartSpacing;
this.comparer = comparer ?? new DefaultFillComparer();
this.dedup = dedup ?? new GridDedup();
}
public PairFillResult Fill(NestItem item, Box workArea,
@@ -61,37 +69,62 @@ namespace OpenNest.Engine.Fill
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
{
List<Part> best = null;
var bestScore = default(FillScore);
var sinceImproved = 0;
var effectiveWorkArea = workArea;
var batchSize = System.Math.Max(2, Environment.ProcessorCount);
var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0;
var partBox = drawing.Program.BoundingBox();
var partArea = System.Math.Max(partBox.Width * partBox.Length, 1);
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
try
{
for (var i = 0; i < candidates.Count; i++)
for (var batchStart = 0; batchStart < candidates.Count; batchStart += batchSize)
{
token.ThrowIfCancellationRequested();
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
var score = FillScore.Compute(filled, effectiveWorkArea);
var batchEnd = System.Math.Min(batchStart + batchSize, candidates.Count);
var batchCount = batchEnd - batchStart;
var batchWorkArea = effectiveWorkArea;
var minCountToBeat = best?.Count ?? 0;
if (score > bestScore)
var results = new List<Part>[batchCount];
Parallel.For(0, batchCount,
new ParallelOptions { CancellationToken = token },
j =>
{
results[j] = EvaluateCandidate(
candidates[batchStart + j], drawing, batchWorkArea,
minCountToBeat, maxUtilization, partArea, token);
});
for (var j = 0; j < batchCount; j++)
{
best = filled;
bestScore = score;
sinceImproved = 0;
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
}
else
{
sinceImproved++;
if (comparer.IsBetter(results[j], best, effectiveWorkArea))
{
best = results[j];
sinceImproved = 0;
effectiveWorkArea = TryReduceWorkArea(best, targetCount, workArea, effectiveWorkArea);
}
else
{
sinceImproved++;
}
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Pairs,
PlateNumber = plateNumber,
Parts = best,
WorkArea = workArea,
Description = $"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts",
});
}
NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea,
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
if (i + 1 >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
{
Debug.WriteLine($"[PairFiller] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
Debug.WriteLine($"[PairFiller] Early exit at {batchEnd}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
break;
}
}
@@ -100,8 +133,12 @@ namespace OpenNest.Engine.Fill
{
Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
}
finally
{
FillStrategyRegistry.SetEnabled(null);
}
Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}");
Debug.WriteLine($"[PairFiller] Best pair result: {best?.Count ?? 0} parts");
return best ?? new List<Part>();
}
@@ -142,12 +179,162 @@ namespace OpenNest.Engine.Fill
System.Math.Min(newTop - workArea.Y, workArea.Length));
}
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing, Box workArea)
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing,
Box workArea, int minCountToBeat, double maxUtilization, double partArea,
CancellationToken token)
{
var pairParts = candidate.BuildParts(drawing);
var engine = new FillLinear(workArea, partSpacing);
var angles = BuildTilingAngles(candidate);
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
// Phase 1: evaluate all grids (fast)
var grids = new List<(List<Part> Parts, NestDirection Dir)>();
foreach (var angle in angles)
{
token.ThrowIfCancellationRequested();
var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
if (pattern.Parts.Count == 0)
continue;
var engine = new FillLinear(workArea, partSpacing);
foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
if (!dedup.TryAdd(pattern.BoundingBox, workArea, dir))
continue;
var gridParts = engine.Fill(pattern, dir);
if (gridParts != null && gridParts.Count > 0)
grids.Add((gridParts, dir));
}
}
if (grids.Count == 0)
return null;
// Sort by count descending so we try the best grids first
grids.Sort((a, b) => b.Parts.Count.CompareTo(a.Parts.Count));
// Early abort: if the best grid + optimistic remnant can't beat the global best, skip Phase 2
if (minCountToBeat > 0)
{
var topCount = grids[0].Parts.Count;
var optimisticRemnant = EstimateRemnantUpperBound(
grids[0].Parts, workArea, maxUtilization, partArea);
if (topCount + optimisticRemnant <= minCountToBeat)
{
Debug.WriteLine($"[PairFiller] Skipping candidate: grid {topCount} + estimate {optimisticRemnant} <= best {minCountToBeat}");
return null;
}
}
// Phase 2: try remnant for each grid, skip if grid is too far behind
List<Part> best = null;
foreach (var (gridParts, dir) in grids)
{
token.ThrowIfCancellationRequested();
// If this grid + max possible remnant can't beat current best, skip
if (best != null)
{
var remnantBound = EstimateRemnantUpperBound(
gridParts, workArea, maxUtilization, partArea);
if (gridParts.Count + remnantBound <= best.Count)
break; // sorted descending, so remaining are even smaller
}
var remnantParts = FillRemnant(gridParts, drawing, workArea, token);
List<Part> total;
if (remnantParts != null && remnantParts.Count > 0)
{
total = new List<Part>(gridParts.Count + remnantParts.Count);
total.AddRange(gridParts);
total.AddRange(remnantParts);
}
else
{
total = gridParts;
}
if (comparer.IsBetter(total, best, workArea))
best = total;
}
return best;
}
private int EstimateRemnantUpperBound(List<Part> gridParts, Box workArea,
double maxUtilization, double partArea)
{
var gridBox = ((IEnumerable<IBoundable>)gridParts).GetBoundingBox();
// L-shaped remnant: top strip (full width) + right strip (grid height only)
var topHeight = System.Math.Max(0, workArea.Top - gridBox.Top);
var rightWidth = System.Math.Max(0, workArea.Right - gridBox.Right);
var topArea = workArea.Width * topHeight;
var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Length);
var remnantArea = topArea + rightArea;
return (int)(remnantArea * maxUtilization / partArea) + 1;
}
private List<Part> FillRemnant(List<Part> gridParts, Drawing drawing,
Box workArea, CancellationToken token)
{
var gridBox = ((IEnumerable<IBoundable>)gridParts).GetBoundingBox();
var partBox = drawing.Program.BoundingBox();
var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing;
List<Part> bestRemnant = null;
// Try top remnant (full width, above grid)
var topY = gridBox.Top + partSpacing;
var topLength = workArea.Top - topY;
if (topLength >= minDim)
{
var topBox = new Box(workArea.X, topY, workArea.Width, topLength);
var parts = FillRemnantBox(drawing, topBox, token);
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
bestRemnant = parts;
}
// Try right remnant (full height, right of grid)
var rightX = gridBox.Right + partSpacing;
var rightWidth = workArea.Right - rightX;
if (rightWidth >= minDim)
{
var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Length);
var parts = FillRemnantBox(drawing, rightBox, token);
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
bestRemnant = parts;
}
return bestRemnant;
}
private List<Part> FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token)
{
var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing);
if (cachedResult != null)
{
Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
return cachedResult;
}
var remnantEngine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing };
var parts = remnantEngine.Fill(item, remnantBox, null, token);
Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " +
$"{remnantBox.Width:F2}x{remnantBox.Length:F2}");
if (parts != null && parts.Count > 0)
{
FillResultCache.Store(drawing, remnantBox, partSpacing, parts);
return parts;
}
return null;
}
private static List<double> BuildTilingAngles(BestFitResult candidate)
@@ -168,35 +355,30 @@ namespace OpenNest.Engine.Fill
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
{
var kept = bestFits.Where(r => r.Keep).ToList();
var top = kept.Take(MaxTopCandidates).ToList();
var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length);
if (workShortSide < plateShortSide * 0.5)
{
var stripCandidates = bestFits
// Strip mode: prioritize candidates that fit the narrow dimension.
var stripCandidates = kept
.Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
&& r.Utilization >= MinStripUtilization)
.OrderByDescending(r => r.Utilization);
.ToList();
var existing = new HashSet<BestFitResult>(top);
SortByEstimatedCount(stripCandidates, workArea);
foreach (var r in stripCandidates)
{
if (top.Count >= MaxStripCandidates)
break;
if (existing.Add(r))
top.Add(r);
}
var top = stripCandidates.Take(MaxStripCandidates).ToList();
Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
return top;
}
SortByEstimatedCount(top, workArea);
var result = kept.Take(MaxTopCandidates).ToList();
SortByEstimatedCount(result, workArea);
return top;
return result;
}
private void SortByEstimatedCount(List<BestFitResult> candidates, Box workArea)

View File

@@ -79,8 +79,14 @@ namespace OpenNest.Engine.Fill
var desc = $"Shrink {axis}: {bestParts.Count} parts, dim={dim:F1}";
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
allParts, workArea, desc);
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Custom,
PlateNumber = plateNumber,
Parts = allParts,
WorkArea = workArea,
Description = desc,
});
}
/// <summary>

View File

@@ -0,0 +1,473 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Diagnostics;
namespace OpenNest.Engine.Fill;
public class StripeFiller
{
private const int MaxPairCandidates = 5;
private const int MaxConvergenceIterations = 20;
private const int AngleSamples = 36;
private readonly FillContext _context;
private readonly NestDirection _primaryAxis;
private readonly IFillComparer _comparer;
private readonly GridDedup _dedup;
/// <summary>
/// When true, only complete stripes are placed — no partial rows/columns.
/// </summary>
public bool CompleteStripesOnly { get; set; }
/// <summary>
/// Factory to create the engine used for filling the remnant strip.
/// Defaults to NestEngineRegistry.Create (uses the user's selected engine).
/// </summary>
public Func<Plate, NestEngineBase> CreateRemnantEngine { get; set; }
= NestEngineRegistry.Create;
public StripeFiller(FillContext context, NestDirection primaryAxis)
{
_context = context;
_primaryAxis = primaryAxis;
_comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
_dedup = GridDedup.GetOrCreate(context.SharedState);
}
public List<Part> Fill()
{
var bestFits = GetPairCandidates();
if (bestFits.Count == 0)
return new List<Part>();
var workArea = _context.WorkArea;
var spacing = _context.Plate.PartSpacing;
var drawing = _context.Item.Drawing;
var strategyName = _primaryAxis == NestDirection.Horizontal ? "Row" : "Column";
List<Part> bestParts = null;
for (var i = 0; i < bestFits.Count; i++)
{
_context.Token.ThrowIfCancellationRequested();
var candidate = bestFits[i];
var pairParts = candidate.BuildParts(drawing);
foreach (var axis in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
var perpAxis = axis == NestDirection.Horizontal
? NestDirection.Vertical : NestDirection.Horizontal;
var sheetSpan = GetDimension(workArea, axis);
var dirLabel = axis == NestDirection.Horizontal ? "Row" : "Col";
var expandResult = ConvergeStripeAngle(
pairParts, sheetSpan, spacing, axis, _context.Token);
var shrinkResult = ConvergeStripeAngleShrink(
pairParts, sheetSpan, spacing, axis, _context.Token);
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
{
if (count <= 0)
continue;
var result = BuildGrid(pairParts, angle, axis, perpAxis);
if (result == null || result.Count == 0)
continue;
Debug.WriteLine($"[StripeFiller] {strategyName} candidate {i} {dirLabel}: " +
$"angle={Angle.ToDegrees(angle):F1}°, N={count}, waste={waste:F2}, " +
$"grid={result.Count} parts");
if (_comparer.IsBetter(result, bestParts, workArea))
{
bestParts = result;
}
}
}
NestEngineBase.ReportProgress(_context.Progress, new ProgressReport
{
Phase = NestPhase.Custom,
PlateNumber = _context.PlateNumber,
Parts = bestParts,
WorkArea = workArea,
Description = $"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts",
});
}
return bestParts ?? new List<Part>();
}
private List<Part> BuildGrid(List<Part> pairParts, double angle,
NestDirection primaryAxis, NestDirection perpAxis)
{
var workArea = _context.WorkArea;
var spacing = _context.Plate.PartSpacing;
var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis);
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
return null;
var stripeEngine = new FillLinear(stripeBox, spacing);
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
if (stripeParts == null || stripeParts.Count == 0)
return null;
var partsPerStripe = stripeParts.Count;
Debug.WriteLine($"[StripeFiller] Stripe: {partsPerStripe} parts, " +
$"box={stripeBox.Width:F2}x{stripeBox.Length:F2}");
var stripePattern = new Pattern();
stripePattern.Parts.AddRange(stripeParts);
stripePattern.UpdateBounds();
var gridEngine = new FillLinear(workArea, spacing);
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
if (gridParts == null || gridParts.Count == 0)
return null;
if (CompleteStripesOnly)
{
var completeCount = gridParts.Count / partsPerStripe * partsPerStripe;
if (completeCount < gridParts.Count)
{
Debug.WriteLine($"[StripeFiller] CompleteOnly: {gridParts.Count} → {completeCount} " +
$"(dropped {gridParts.Count - completeCount} partial)");
gridParts = gridParts.GetRange(0, completeCount);
}
}
Debug.WriteLine($"[StripeFiller] Grid: {gridParts.Count} parts");
if (gridParts.Count == 0)
return null;
var allParts = new List<Part>(gridParts);
var remnantParts = FillRemnant(gridParts, primaryAxis);
if (remnantParts != null)
{
Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts");
allParts.AddRange(remnantParts);
}
return allParts;
}
private List<BestFitResult> GetPairCandidates()
{
List<BestFitResult> bestFits;
if (_context.SharedState.TryGetValue("BestFits", out var cached))
bestFits = (List<BestFitResult>)cached;
else
bestFits = BestFitCache.GetOrCompute(
_context.Item.Drawing,
_context.Plate.Size.Length,
_context.Plate.Size.Width,
_context.Plate.PartSpacing);
return bestFits
.Where(r => r.Keep)
.Take(MaxPairCandidates)
.ToList();
}
private static Box MakeStripeBox(Box workArea, double perpDim, NestDirection primaryAxis)
{
return primaryAxis == NestDirection.Horizontal
? new Box(workArea.X, workArea.Y, workArea.Width, perpDim)
: new Box(workArea.X, workArea.Y, perpDim, workArea.Length);
}
private List<Part> FillRemnant(List<Part> gridParts, NestDirection primaryAxis)
{
var workArea = _context.WorkArea;
var spacing = _context.Plate.PartSpacing;
var drawing = _context.Item.Drawing;
var gridBox = gridParts.GetBoundingBox();
var minDim = System.Math.Min(
drawing.Program.BoundingBox().Width,
drawing.Program.BoundingBox().Length);
Box remnantBox;
if (primaryAxis == NestDirection.Horizontal)
{
var remnantY = gridBox.Top + spacing;
var remnantLength = workArea.Top - remnantY;
if (remnantLength < minDim)
return null;
remnantBox = new Box(workArea.X, remnantY, workArea.Width, remnantLength);
}
else
{
var remnantX = gridBox.Right + spacing;
var remnantWidth = workArea.Right - remnantX;
if (remnantWidth < minDim)
return null;
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Length);
}
Debug.WriteLine($"[StripeFiller] Remnant box: {remnantBox.Width:F2}x{remnantBox.Length:F2}");
var cachedResult = FillResultCache.Get(drawing, remnantBox, spacing);
if (cachedResult != null)
{
Debug.WriteLine($"[StripeFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
return cachedResult;
}
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
try
{
var engine = CreateRemnantEngine(_context.Plate);
var item = new NestItem { Drawing = drawing };
var parts = engine.Fill(item, remnantBox, _context.Progress, _context.Token);
Debug.WriteLine($"[StripeFiller] Remnant engine ({engine.Name}): {parts?.Count ?? 0} parts, " +
$"winner={engine.WinnerPhase}");
if (parts != null && parts.Count > 0)
{
FillResultCache.Store(drawing, remnantBox, spacing, parts);
return parts;
}
return null;
}
finally
{
FillStrategyRegistry.SetEnabled(null);
}
}
public static double FindAngleForTargetSpan(
List<Part> patternParts, double targetSpan, NestDirection axis)
{
var bestAngle = 0.0;
var bestDiff = double.MaxValue;
var samples = new (double angle, double span)[AngleSamples + 1];
for (var i = 0; i <= AngleSamples; i++)
{
var angle = i * Angle.HalfPI / AngleSamples;
var span = GetRotatedSpan(patternParts, angle, axis);
samples[i] = (angle, span);
var diff = System.Math.Abs(span - targetSpan);
if (diff < bestDiff)
{
bestDiff = diff;
bestAngle = angle;
}
}
if (bestDiff < Tolerance.Epsilon)
return bestAngle;
for (var i = 0; i < samples.Length - 1; i++)
{
var (a1, s1) = samples[i];
var (a2, s2) = samples[i + 1];
if ((s1 <= targetSpan && targetSpan <= s2) ||
(s2 <= targetSpan && targetSpan <= s1))
{
var result = BisectForTarget(patternParts, a1, a2, targetSpan, axis);
var resultSpan = GetRotatedSpan(patternParts, result, axis);
var resultDiff = System.Math.Abs(resultSpan - targetSpan);
if (resultDiff < bestDiff)
{
bestDiff = resultDiff;
bestAngle = result;
}
}
}
return bestAngle;
}
/// <summary>
/// Returns the rotation angle that orients the pair with its short side
/// along the given axis. Returns 0 if already oriented, PI/2 if rotated.
/// </summary>
private static double OrientShortSideAlong(List<Part> patternParts, NestDirection axis)
{
var box = FillHelpers.BuildRotatedPattern(patternParts, 0).BoundingBox;
var span0 = GetDimension(box, axis);
var perpSpan0 = axis == NestDirection.Horizontal ? box.Length : box.Width;
if (span0 <= perpSpan0)
return 0;
return Angle.HalfPI;
}
/// <summary>
/// Iteratively finds the rotation angle where N copies of the pattern
/// span the given dimension with minimal waste by expanding pair width.
/// Returns (angle, waste, pairCount).
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
List<Part> patternParts, double sheetSpan, double spacing,
NestDirection axis, CancellationToken token = default)
{
var startAngle = OrientShortSideAlong(patternParts, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
}
/// <summary>
/// Tries fitting N+1 narrower pairs by shrinking the pair width.
/// Complements ConvergeStripeAngle which only expands.
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngleShrink(
List<Part> patternParts, double sheetSpan, double spacing,
NestDirection axis, CancellationToken token = default)
{
var baseAngle = OrientShortSideAlong(patternParts, axis);
var naturalPattern = FillHelpers.BuildRotatedPattern(patternParts, baseAngle);
var naturalSpan = GetDimension(naturalPattern.BoundingBox, axis);
if (naturalSpan + spacing <= 0)
return (0, double.MaxValue, 0);
var naturalN = (int)System.Math.Floor((sheetSpan + spacing) / (naturalSpan + spacing));
var targetN = naturalN + 1;
var targetSpan = (sheetSpan + spacing) / targetN - spacing;
if (targetSpan <= 0)
return (0, double.MaxValue, 0);
var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
}
private static (double Angle, double Waste, int Count) ConvergeFromAngle(
List<Part> patternParts, double startAngle, double sheetSpan,
double spacing, NestDirection axis, CancellationToken token)
{
var bestWaste = double.MaxValue;
var bestAngle = startAngle;
var bestCount = 0;
var tolerance = sheetSpan * 0.001;
var currentAngle = startAngle;
for (var iteration = 0; iteration < MaxConvergenceIterations; iteration++)
{
token.ThrowIfCancellationRequested();
var rotated = FillHelpers.BuildRotatedPattern(patternParts, currentAngle);
var pairSpan = GetDimension(rotated.BoundingBox, axis);
var perpDim = axis == NestDirection.Horizontal
? rotated.BoundingBox.Length : rotated.BoundingBox.Width;
if (pairSpan + spacing <= 0)
break;
var stripeBox = axis == NestDirection.Horizontal
? new Box(0, 0, sheetSpan, perpDim)
: new Box(0, 0, perpDim, sheetSpan);
var engine = new FillLinear(stripeBox, spacing);
var filled = engine.Fill(rotated, axis);
var n = filled?.Count ?? 0;
if (n <= 0)
break;
var filledBox = ((IEnumerable<IBoundable>)filled).GetBoundingBox();
var remaining = sheetSpan - GetDimension(filledBox, axis);
Debug.WriteLine($"[Converge] iter={iteration}: angle={Angle.ToDegrees(currentAngle):F2}°, " +
$"pairSpan={pairSpan:F4}, perpDim={perpDim:F4}, N={n}, waste={remaining:F3}");
if (remaining < bestWaste)
{
bestWaste = remaining;
bestAngle = currentAngle;
bestCount = n;
}
if (remaining <= tolerance)
break;
var bboxN = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
if (bboxN <= 0) bboxN = 1;
var delta = remaining / bboxN;
var targetSpan = pairSpan + delta;
var prevAngle = currentAngle;
currentAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
if (System.Math.Abs(currentAngle - prevAngle) < Tolerance.Epsilon)
break;
}
return (bestAngle, bestWaste, bestCount);
}
private static double BisectForTarget(
List<Part> patternParts, double lo, double hi,
double targetSpan, NestDirection axis)
{
var bestAngle = lo;
var bestDiff = double.MaxValue;
for (var i = 0; i < 30; i++)
{
var mid = (lo + hi) / 2;
var span = GetRotatedSpan(patternParts, mid, axis);
var diff = System.Math.Abs(span - targetSpan);
if (diff < bestDiff)
{
bestDiff = diff;
bestAngle = mid;
}
if (diff < Tolerance.Epsilon)
break;
var loSpan = GetRotatedSpan(patternParts, lo, axis);
if ((loSpan < targetSpan && span < targetSpan) ||
(loSpan > targetSpan && span > targetSpan))
lo = mid;
else
hi = mid;
}
return bestAngle;
}
private static double GetRotatedSpan(
List<Part> patternParts, double angle, NestDirection axis)
{
var rotated = FillHelpers.BuildRotatedPattern(patternParts, angle);
return axis == NestDirection.Horizontal
? rotated.BoundingBox.Width
: rotated.BoundingBox.Length;
}
private static double GetDimension(Box box, NestDirection axis)
{
return axis == NestDirection.Horizontal ? box.Width : box.Length;
}
}

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Ranks fill results to minimize X-extent (preserve right-side vertical remnant).
/// Tiebreak chain: count > smallest X-extent > highest density.
/// </summary>
public class VerticalRemnantComparer : IFillComparer
{
public bool IsBetter(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
if (candidate.Count != current.Count)
return candidate.Count > current.Count;
var candExtent = XExtent(candidate);
var currExtent = XExtent(current);
if (!candExtent.IsEqualTo(currExtent))
return candExtent < currExtent;
return FillScore.Compute(candidate, workArea).Density
> FillScore.Compute(current, workArea).Density;
}
private static double XExtent(List<Part> parts)
{
var minX = double.MaxValue;
var maxX = double.MinValue;
foreach (var part in parts)
{
var bb = part.BoundingBox;
if (bb.Left < minX) minX = bb.Left;
if (bb.Right > maxX) maxX = bb.Right;
}
return maxX - minX;
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// Optimizes for the largest top-side horizontal drop.
/// Scores by count first, then minimizes Y-extent.
/// Prefers vertical nest direction and angles that keep parts narrow in Y.
/// </summary>
public class HorizontalRemnantEngine : DefaultNestEngine
{
public HorizontalRemnantEngine(Plate plate) : base(plate) { }
public override string Name => "Horizontal Remnant";
public override string Description => "Optimizes for largest top-side horizontal drop";
protected override IFillComparer CreateComparer() => new HorizontalRemnantComparer();
public override NestDirection? PreferredDirection => NestDirection.Vertical;
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b)));
return baseAngles;
}
private static double RotatedHeight(NestItem item, double angle)
{
var bb = item.Drawing.Program.BoundingBox();
var cos = System.Math.Abs(System.Math.Cos(angle));
var sin = System.Math.Abs(System.Math.Sin(angle));
return bb.Length * cos + bb.Width * sin;
}
}
}

View File

@@ -0,0 +1,14 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Engine
{
/// <summary>
/// Determines whether a candidate fill result is better than the current best.
/// Implementations must be stateless and thread-safe.
/// </summary>
public interface IFillComparer
{
bool IsBetter(List<Part> candidate, List<Part> current, Box workArea);
}
}

View File

@@ -1,4 +1,6 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
@@ -31,6 +33,25 @@ namespace OpenNest
public abstract string Description { get; }
// --- Engine policy ---
private IFillComparer _comparer;
protected IFillComparer Comparer => _comparer ??= CreateComparer();
protected virtual IFillComparer CreateComparer() => new DefaultFillComparer();
public virtual NestDirection? PreferredDirection => null;
public virtual List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
return new List<double> { bestRotation, bestRotation + OpenNest.Math.Angle.HalfPI };
}
protected virtual void RecordProductiveAngles(List<AngleResult> angleResults) { }
protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection);
// --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> Fill(NestItem item, Box workArea,
@@ -126,6 +147,9 @@ namespace OpenNest
}
}
// Compact placed parts toward the origin to close gaps.
Compactor.Settle(allParts, Plate.WorkArea(), Plate.PartSpacing);
return allParts;
}
@@ -186,55 +210,26 @@ namespace OpenNest
// --- Protected utilities ---
internal static void ReportProgress(
IProgress<NestProgress> progress,
NestPhase phase,
int plateNumber,
List<Part> best,
Box workArea,
string description,
bool isOverallBest = false)
IProgress<NestProgress> progress, ProgressReport report)
{
if (progress == null || best == null || best.Count == 0)
if (progress == null || report.Parts == null || report.Parts.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)
{
var clonedParts = new List<Part>(report.Parts.Count);
foreach (var part in report.Parts)
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 { }
Debug.WriteLine($"[Progress] Phase={report.Phase}, Plate={report.PlateNumber}, " +
$"Parts={clonedParts.Count} | {report.Description}");
progress.Report(new NestProgress
{
Phase = phase,
PlateNumber = plateNumber,
BestPartCount = score.Count,
BestDensity = score.Density,
NestedWidth = bounds.Width,
NestedLength = bounds.Length,
NestedArea = totalPartArea,
Phase = report.Phase,
PlateNumber = report.PlateNumber,
BestParts = clonedParts,
Description = description,
ActiveWorkArea = workArea,
IsOverallBest = isOverallBest,
Description = report.Description,
ActiveWorkArea = report.WorkArea,
IsOverallBest = report.IsOverallBest,
});
}
@@ -246,21 +241,13 @@ namespace OpenNest
var parts = new List<string>(PhaseResults.Count);
foreach (var r in PhaseResults)
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
parts.Add($"{r.Phase.ShortName()}: {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);
}
=> Comparer.IsBetter(candidate, current, workArea);
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
{
@@ -307,17 +294,5 @@ namespace OpenNest
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.Extents: return "Extents";
case NestPhase.Custom: return "Custom";
default: return phase.ToString();
}
}
}
}

View File

@@ -20,6 +20,18 @@ namespace OpenNest
Register("Strip",
"Strip-based nesting for mixed-drawing layouts",
plate => new StripNestEngine(plate));
Register("NFP",
"NFP-based mixed-part nesting with simulated annealing",
plate => new NfpNestEngine(plate));
Register("Vertical Remnant",
"Optimizes for largest right-side vertical drop",
plate => new VerticalRemnantEngine(plate));
Register("Horizontal Remnant",
"Optimizes for largest top-side horizontal drop",
plate => new HorizontalRemnantEngine(plate));
}
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;

View File

@@ -1,16 +1,52 @@
using OpenNest.Geometry;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Reflection;
namespace OpenNest
{
[AttributeUsage(AttributeTargets.Field)]
internal class ShortNameAttribute(string name) : Attribute
{
public string Name { get; } = name;
}
public enum NestPhase
{
Linear,
RectBestFit,
Pairs,
Nfp,
Extents,
Custom
[Description("Trying rotations..."), ShortName("Linear")] Linear,
[Description("Trying best fit..."), ShortName("BestFit")] RectBestFit,
[Description("Trying pairs..."), ShortName("Pairs")] Pairs,
[Description("Trying NFP..."), ShortName("NFP")] Nfp,
[Description("Trying extents..."), ShortName("Extents")] Extents,
[Description("Custom"), ShortName("Custom")] Custom
}
public static class NestPhaseExtensions
{
private static readonly ConcurrentDictionary<NestPhase, string> DisplayNames = new();
private static readonly ConcurrentDictionary<NestPhase, string> ShortNames = new();
public static string DisplayName(this NestPhase phase)
{
return DisplayNames.GetOrAdd(phase, p =>
{
var field = typeof(NestPhase).GetField(p.ToString());
var attr = field?.GetCustomAttribute<DescriptionAttribute>();
return attr?.Description ?? p.ToString();
});
}
public static string ShortName(this NestPhase phase)
{
return ShortNames.GetOrAdd(phase, p =>
{
var field = typeof(NestPhase).GetField(p.ToString());
var attr = field?.GetCustomAttribute<ShortNameAttribute>();
return attr?.Name ?? p.ToString();
});
}
}
public class PhaseResult
@@ -34,18 +70,93 @@ namespace OpenNest
public int PartCount { get; set; }
}
internal readonly struct ProgressReport
{
public NestPhase Phase { get; init; }
public int PlateNumber { get; init; }
public List<Part> Parts { get; init; }
public Box WorkArea { get; init; }
public string Description { get; init; }
public bool IsOverallBest { get; init; }
}
public class NestProgress
{
public NestPhase Phase { get; set; }
public int PlateNumber { get; set; }
public int BestPartCount { get; set; }
public double BestDensity { get; set; }
public double NestedWidth { get; set; }
public double NestedLength { get; set; }
public double NestedArea { get; set; }
public List<Part> BestParts { get; set; }
private List<Part> bestParts;
public List<Part> BestParts
{
get => bestParts;
set { bestParts = value; cachedParts = null; }
}
public string Description { get; set; }
public Box ActiveWorkArea { get; set; }
public bool IsOverallBest { get; set; }
public int BestPartCount => BestParts?.Count ?? 0;
private List<Part> cachedParts;
private Box cachedBounds;
private double cachedPartArea;
private void EnsureCache()
{
if (cachedParts == bestParts) return;
cachedParts = bestParts;
if (bestParts == null || bestParts.Count == 0)
{
cachedBounds = default;
cachedPartArea = 0;
return;
}
cachedBounds = bestParts.GetBoundingBox();
cachedPartArea = 0;
foreach (var p in bestParts)
cachedPartArea += p.BaseDrawing.Area;
}
public double BestDensity
{
get
{
if (BestParts == null || BestParts.Count == 0) return 0;
EnsureCache();
var bboxArea = cachedBounds.Width * cachedBounds.Length;
return bboxArea > 0 ? cachedPartArea / bboxArea : 0;
}
}
public double NestedWidth
{
get
{
if (BestParts == null || BestParts.Count == 0) return 0;
EnsureCache();
return cachedBounds.Width;
}
}
public double NestedLength
{
get
{
if (BestParts == null || BestParts.Count == 0) return 0;
EnsureCache();
return cachedBounds.Length;
}
}
public double NestedArea
{
get
{
if (BestParts == null || BestParts.Count == 0) return 0;
EnsureCache();
return cachedPartArea;
}
}
}
}

View File

@@ -1,8 +1,9 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
@@ -15,6 +16,7 @@ namespace OpenNest.Engine.Nfp
public static class AutoNester
{
public static List<Part> Nest(List<NestItem> items, Plate plate,
IProgress<NestProgress> progress = null,
CancellationToken cancellation = default)
{
var workArea = plate.WorkArea();
@@ -60,7 +62,7 @@ namespace OpenNest.Engine.Nfp
// Run simulated annealing optimizer.
var optimizer = new SimulatedAnnealing();
var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation);
var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, progress, cancellation);
if (result.Sequence == null || result.Sequence.Count == 0)
return new List<Part>();
@@ -72,52 +74,142 @@ namespace OpenNest.Engine.Nfp
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Nfp,
PlateNumber = 0,
Parts = parts,
WorkArea = workArea,
Description = $"NFP: {parts.Count} parts, {result.Iterations} iterations",
IsOverallBest = true,
});
return parts;
}
/// <summary>
/// Re-places already-positioned parts using NFP-based BLF.
/// Returns the tighter layout if BLF improves density without losing parts.
/// </summary>
public static List<Part> Optimize(List<Part> parts, Plate plate)
{
return Optimize(parts, plate.WorkArea(), plate.PartSpacing);
}
/// <summary>
/// Re-places already-positioned parts using NFP-based BLF within the given work area.
/// Returns the tighter layout if BLF improves density without losing parts.
/// </summary>
public static List<Part> Optimize(List<Part> parts, Box workArea, double partSpacing)
{
if (parts == null || parts.Count < 2)
return parts;
var halfSpacing = partSpacing / 2.0;
var nfpCache = new NfpCache();
var registeredRotations = new HashSet<(int id, double rotation)>();
// Extract polygons for each unique drawing+rotation used by the placed parts.
foreach (var part in parts)
{
var drawing = part.BaseDrawing;
var rotation = part.Rotation;
var key = (drawing.Id, rotation);
if (registeredRotations.Contains(key))
continue;
var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing);
if (perimeterPolygon == null)
continue;
var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation);
nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon);
registeredRotations.Add(key);
}
if (registeredRotations.Count == 0)
return parts;
nfpCache.PreComputeAll();
// Build BLF sequence sorted by area descending (largest first packs best).
var sequence = parts
.OrderByDescending(p => p.BaseDrawing.Area)
.Select(p => new SequenceEntry(p.BaseDrawing.Id, p.Rotation, p.BaseDrawing))
.ToList();
var blf = new BottomLeftFill(workArea, nfpCache);
var placed = blf.Fill(sequence);
var optimized = BottomLeftFill.ToNestParts(placed);
// Only use the NFP result if it kept all parts and improved density.
if (optimized.Count < parts.Count)
{
Debug.WriteLine($"[AutoNest.Optimize] Rejected: placed {optimized.Count}/{parts.Count} parts");
return parts;
}
// Reject if any part landed outside the work area.
if (!AllPartsInBounds(optimized, workArea))
{
Debug.WriteLine("[AutoNest.Optimize] Rejected: parts outside work area");
return parts;
}
var originalScore = Fill.FillScore.Compute(parts, workArea);
var optimizedScore = Fill.FillScore.Compute(optimized, workArea);
if (optimizedScore > originalScore)
{
Debug.WriteLine($"[AutoNest.Optimize] Improved: density {originalScore.Density:P1} -> {optimizedScore.Density:P1}");
return optimized;
}
Debug.WriteLine($"[AutoNest.Optimize] No improvement: {originalScore.Density:P1} >= {optimizedScore.Density:P1}");
return parts;
}
private static bool AllPartsInBounds(List<Part> parts, Box workArea)
{
var logPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log");
var allInBounds = true;
// Append to the log that BLF already started
using var log = new StreamWriter(logPath, true);
log.WriteLine($"\n[Bounds] workArea: X={workArea.X} Y={workArea.Y} W={workArea.Width} H={workArea.Length} Right={workArea.Right} Top={workArea.Top}");
foreach (var part in parts)
{
var bb = part.BoundingBox;
var outLeft = bb.Left < workArea.X - Tolerance.Epsilon;
var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon;
var outRight = bb.Right > workArea.Right + Tolerance.Epsilon;
var outTop = bb.Top > workArea.Top + Tolerance.Epsilon;
var oob = outLeft || outBottom || outRight || outTop;
if (oob)
{
log.WriteLine($"[Bounds] OOB DrawingId={part.BaseDrawing.Id} \"{part.BaseDrawing.Name}\" loc=({part.Location.X:F4},{part.Location.Y:F4}) rot={part.Rotation:F3} bb=({bb.Left:F4},{bb.Bottom:F4})-({bb.Right:F4},{bb.Top:F4}) violations: {(outLeft ? "LEFT " : "")}{(outBottom ? "BOTTOM " : "")}{(outRight ? "RIGHT " : "")}{(outTop ? "TOP " : "")}");
allInBounds = false;
}
}
if (allInBounds)
log.WriteLine($"[Bounds] All {parts.Count} parts in bounds.");
return allInBounds;
}
/// <summary>
/// Extracts the perimeter polygon from a drawing, inflated by half-spacing.
/// </summary>
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return null;
var definedShape = new ShapeProfile(entities);
var perimeter = definedShape.Perimeter;
if (perimeter == null)
return null;
// Inflate by half-spacing if spacing is non-zero.
Shape inflated;
if (halfSpacing > 0)
{
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right);
inflated = offsetEntity as Shape ?? perimeter;
}
else
{
inflated = perimeter;
}
// Convert to polygon with circumscribed arcs for tight nesting.
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
if (polygon.Vertices.Count < 3)
return null;
// Normalize: move reference point to origin.
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
polygon.Offset(-bb.Left, -bb.Bottom);
return polygon;
return BestFit.PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing).Polygon;
}
/// <summary>
@@ -197,26 +289,7 @@ namespace OpenNest.Engine.Nfp
/// </summary>
private static Polygon RotatePolygon(Polygon polygon, double angle)
{
if (angle.IsEqualTo(0))
return polygon;
var result = new Polygon();
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
foreach (var v in polygon.Vertices)
{
result.Vertices.Add(new Vector(
v.X * cos - v.Y * sin,
v.X * sin + v.Y * cos));
}
// Re-normalize to origin.
result.UpdateBounds();
var bb = result.BoundingBox;
result.Offset(-bb.Left, -bb.Bottom);
return result;
return BestFit.PolygonHelper.RotatePolygon(polygon, angle);
}
}
}

View File

@@ -1,5 +1,8 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.IO;
using Clipper2Lib;
namespace OpenNest.Engine.Nfp
{
@@ -10,6 +13,9 @@ namespace OpenNest.Engine.Nfp
/// </summary>
public class BottomLeftFill
{
private static readonly string DebugLogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log");
private readonly Box workArea;
private readonly NfpCache nfpCache;
@@ -21,55 +27,56 @@ namespace OpenNest.Engine.Nfp
/// <summary>
/// Places parts according to the given sequence using NFP-based BLF.
/// Each entry is (drawingId, rotation) determining what to place and how.
/// Returns the list of successfully placed parts with their positions.
/// </summary>
public List<PlacedPart> Fill(List<(int drawingId, double rotation, Drawing drawing)> sequence)
public List<PlacedPart> Fill(List<SequenceEntry> sequence)
{
var placedParts = new List<PlacedPart>();
foreach (var (drawingId, rotation, drawing) in sequence)
using var log = new StreamWriter(DebugLogPath, false);
log.WriteLine($"[BLF] {DateTime.Now:HH:mm:ss.fff} workArea: X={workArea.X} Y={workArea.Y} W={workArea.Width} H={workArea.Length} Right={workArea.Right} Top={workArea.Top}");
log.WriteLine($"[BLF] Sequence count: {sequence.Count}");
foreach (var entry in sequence)
{
var polygon = nfpCache.GetPolygon(drawingId, rotation);
if (polygon == null || polygon.Vertices.Count < 3)
continue;
// Compute IFP for this part inside the work area.
var ifp = InnerFitPolygon.Compute(workArea, polygon);
var ifp = nfpCache.GetIfp(entry.DrawingId, entry.Rotation, workArea);
if (ifp.Vertices.Count < 3)
continue;
// Compute NFPs against all already-placed parts.
var nfps = new Polygon[placedParts.Count];
for (var i = 0; i < placedParts.Count; i++)
{
var placed = placedParts[i];
var nfp = nfpCache.Get(placed.DrawingId, placed.Rotation, drawingId, rotation);
// Translate NFP to the placed part's position.
var translated = TranslatePolygon(nfp, placed.Position);
nfps[i] = translated;
log.WriteLine($"[BLF] DrawingId={entry.DrawingId} rot={entry.Rotation:F3} SKIPPED (IFP has {ifp.Vertices.Count} verts)");
continue;
}
// Compute feasible region and find bottom-left point.
var feasible = InnerFitPolygon.ComputeFeasibleRegion(ifp, nfps);
log.WriteLine($"[BLF] DrawingId={entry.DrawingId} rot={entry.Rotation:F3} IFP verts={ifp.Vertices.Count} bounds=({ifp.BoundingBox.X:F2},{ifp.BoundingBox.Y:F2},{ifp.BoundingBox.Width:F2},{ifp.BoundingBox.Length:F2})");
var nfpPaths = ComputeNfpPaths(placedParts, entry.DrawingId, entry.Rotation, ifp.BoundingBox);
var feasible = InnerFitPolygon.ComputeFeasibleRegion(ifp, nfpPaths);
var point = InnerFitPolygon.FindBottomLeftPoint(feasible);
if (double.IsNaN(point.X))
{
log.WriteLine($"[BLF] -> NO feasible point (NaN)");
continue;
}
// Clamp to IFP bounds to correct Clipper2 floating-point drift.
var ifpBb = ifp.BoundingBox;
point = new Vector(
System.Math.Max(ifpBb.X, System.Math.Min(ifpBb.Right, point.X)),
System.Math.Max(ifpBb.Y, System.Math.Min(ifpBb.Top, point.Y)));
log.WriteLine($"[BLF] -> placed at ({point.X:F4}, {point.Y:F4}) nfpPaths={nfpPaths.Count} feasibleVerts={feasible.Vertices.Count}");
placedParts.Add(new PlacedPart
{
DrawingId = drawingId,
Rotation = rotation,
DrawingId = entry.DrawingId,
Rotation = entry.Rotation,
Position = point,
Drawing = drawing
Drawing = entry.Drawing
});
}
log.WriteLine($"[BLF] Total placed: {placedParts.Count}/{sequence.Count}");
return placedParts;
}
@@ -82,12 +89,12 @@ namespace OpenNest.Engine.Nfp
foreach (var placed in placedParts)
{
var part = new Part(placed.Drawing);
if (placed.Rotation != 0)
part.Rotate(placed.Rotation);
part.Location = placed.Position;
var part = Part.CreateAtOrigin(placed.Drawing, placed.Rotation);
// CreateAtOrigin sets Location to compensate for the rotated program's
// bounding box offset. The BLF position is a displacement for the
// origin-normalized polygon, so we ADD it to the existing Location
// rather than replacing it.
part.Location = part.Location + placed.Position;
parts.Add(part);
}
@@ -95,27 +102,31 @@ namespace OpenNest.Engine.Nfp
}
/// <summary>
/// Creates a translated copy of a polygon.
/// Computes NFPs for a candidate part against all already-placed parts,
/// returned as Clipper paths with translations applied.
/// Filters NFPs that don't intersect the target IFP.
/// </summary>
private static Polygon TranslatePolygon(Polygon polygon, Vector offset)
private PathsD ComputeNfpPaths(List<PlacedPart> placedParts, int drawingId, double rotation, Box ifpBounds)
{
var result = new Polygon();
var nfpPaths = new PathsD(placedParts.Count);
foreach (var v in polygon.Vertices)
result.Vertices.Add(new Vector(v.X + offset.X, v.Y + offset.Y));
for (var i = 0; i < placedParts.Count; i++)
{
var placed = placedParts[i];
var nfp = nfpCache.Get(placed.DrawingId, placed.Rotation, drawingId, rotation);
return result;
if (nfp != null && nfp.Vertices.Count >= 3)
{
// Spatial pruning: only include NFPs that could actually subtract from the IFP.
var nfpBounds = nfp.BoundingBox.Translate(placed.Position);
if (nfpBounds.Intersects(ifpBounds))
{
nfpPaths.Add(NoFitPolygon.ToClipperPath(nfp, placed.Position));
}
}
}
return nfpPaths;
}
}
/// <summary>
/// Represents a part that has been placed by the BLF algorithm.
/// </summary>
public class PlacedPart
{
public int DrawingId { get; set; }
public double Rotation { get; set; }
public Vector Position { get; set; }
public Drawing Drawing { get; set; }
}
}

View File

@@ -1,5 +1,6 @@
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Threading;
@@ -11,9 +12,9 @@ namespace OpenNest.Engine.Nfp
public class OptimizationResult
{
/// <summary>
/// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
/// The best placement sequence found.
/// </summary>
public List<(int drawingId, double rotation, Drawing drawing)> Sequence { get; set; }
public List<SequenceEntry> Sequence { get; set; }
/// <summary>
/// The score achieved by the best sequence.
@@ -34,6 +35,7 @@ namespace OpenNest.Engine.Nfp
{
OptimizationResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
IProgress<NestProgress> progress = null,
CancellationToken cancellation = default);
}
}

View File

@@ -14,6 +14,8 @@ namespace OpenNest.Engine.Nfp
private readonly Dictionary<NfpKey, Polygon> cache = new Dictionary<NfpKey, Polygon>();
private readonly Dictionary<int, Dictionary<double, Polygon>> polygonCache
= new Dictionary<int, Dictionary<double, Polygon>>();
private readonly Dictionary<(int drawingId, double rotation), Polygon> ifpCache
= new Dictionary<(int drawingId, double rotation), Polygon>();
/// <summary>
/// Registers a pre-computed polygon for a drawing at a specific rotation.
@@ -28,6 +30,26 @@ namespace OpenNest.Engine.Nfp
}
rotations[rotation] = polygon;
// Clear IFP cache if a polygon is updated (though usually they aren't).
ifpCache.Remove((drawingId, rotation));
}
/// <summary>
/// Gets or computes the IFP for a drawing at a specific rotation within a work area.
/// </summary>
public Polygon GetIfp(int drawingId, double rotation, Box workArea)
{
if (ifpCache.TryGetValue((drawingId, rotation), out var ifp))
return ifp;
var polygon = GetPolygon(drawingId, rotation);
if (polygon == null)
return new Polygon();
ifp = InnerFitPolygon.Compute(workArea, polygon);
ifpCache[(drawingId, rotation)] = ifp;
return ifp;
}
/// <summary>

View File

@@ -0,0 +1,15 @@
using OpenNest.Geometry;
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// Represents a part that has been placed by the BLF algorithm.
/// </summary>
public class PlacedPart
{
public int DrawingId { get; set; }
public double Rotation { get; set; }
public Vector Position { get; set; }
public Drawing Drawing { get; set; }
}
}

View File

@@ -0,0 +1,24 @@
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// An entry in a placement sequence — identifies which drawing to place and at what rotation.
/// </summary>
public readonly struct SequenceEntry
{
public int DrawingId { get; }
public double Rotation { get; }
public Drawing Drawing { get; }
public SequenceEntry(int drawingId, double rotation, Drawing drawing)
{
DrawingId = drawingId;
Rotation = rotation;
Drawing = drawing;
}
public SequenceEntry WithRotation(double rotation)
{
return new SequenceEntry(DrawingId, rotation, Drawing);
}
}
}

View File

@@ -20,11 +20,12 @@ namespace OpenNest.Engine.Nfp
public OptimizationResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
IProgress<NestProgress> progress = null,
CancellationToken cancellation = default)
{
var random = new Random();
// Build initial sequence: expand NestItems into individual (drawingId, rotation, drawing) entries,
// Build initial sequence: expand NestItems into individual entries,
// sorted by area descending.
var sequence = BuildInitialSequence(items, candidateRotations);
@@ -35,9 +36,9 @@ namespace OpenNest.Engine.Nfp
var blf = new BottomLeftFill(workArea, cache);
var bestPlaced = blf.Fill(sequence);
var bestScore = FillScore.Compute(BottomLeftFill.ToNestParts(bestPlaced), workArea);
var bestSequence = new List<(int, double, Drawing)>(sequence);
var bestSequence = new List<SequenceEntry>(sequence);
var currentSequence = new List<(int, double, Drawing)>(sequence);
var currentSequence = new List<SequenceEntry>(sequence);
var currentScore = bestScore;
// Calibrate initial temperature so ~80% of worse moves are accepted.
@@ -49,13 +50,16 @@ namespace OpenNest.Engine.Nfp
Debug.WriteLine($"[SA] Initial: {bestScore.Count} parts, density={bestScore.Density:P1}, temp={initialTemp:F2}");
ReportBest(progress, BottomLeftFill.ToNestParts(bestPlaced), workArea,
$"NFP: initial {bestScore.Count} parts, density={bestScore.Density:P1}");
while (temperature > DefaultMinTemperature
&& noImprovement < DefaultMaxNoImprovement
&& !cancellation.IsCancellationRequested)
{
iteration++;
var candidate = new List<(int drawingId, double rotation, Drawing drawing)>(currentSequence);
var candidate = new List<SequenceEntry>(currentSequence);
Mutate(candidate, candidateRotations, random);
var candidatePlaced = blf.Fill(candidate);
@@ -72,10 +76,13 @@ namespace OpenNest.Engine.Nfp
if (currentScore > bestScore)
{
bestScore = currentScore;
bestSequence = new List<(int, double, Drawing)>(currentSequence);
bestSequence = new List<SequenceEntry>(currentSequence);
noImprovement = 0;
Debug.WriteLine($"[SA] New best at iter {iteration}: {bestScore.Count} parts, density={bestScore.Density:P1}");
ReportBest(progress, BottomLeftFill.ToNestParts(candidatePlaced), workArea,
$"NFP: iter {iteration}, {bestScore.Count} parts, density={bestScore.Density:P1}");
}
else
{
@@ -118,10 +125,10 @@ namespace OpenNest.Engine.Nfp
/// Builds the initial placement sequence sorted by drawing area descending.
/// Each NestItem is expanded by its quantity.
/// </summary>
private static List<(int drawingId, double rotation, Drawing drawing)> BuildInitialSequence(
private static List<SequenceEntry> BuildInitialSequence(
List<NestItem> items, Dictionary<int, List<double>> candidateRotations)
{
var sequence = new List<(int drawingId, double rotation, Drawing drawing)>();
var sequence = new List<SequenceEntry>();
// Sort items by area descending.
var sorted = items.OrderByDescending(i => i.Drawing.Area).ToList();
@@ -135,7 +142,7 @@ namespace OpenNest.Engine.Nfp
rotation = rotations[0];
for (var i = 0; i < qty; i++)
sequence.Add((item.Drawing.Id, rotation, item.Drawing));
sequence.Add(new SequenceEntry(item.Drawing.Id, rotation, item.Drawing));
}
return sequence;
@@ -144,7 +151,7 @@ namespace OpenNest.Engine.Nfp
/// <summary>
/// Applies a random mutation to the sequence.
/// </summary>
private static void Mutate(List<(int drawingId, double rotation, Drawing drawing)> sequence,
private static void Mutate(List<SequenceEntry> sequence,
Dictionary<int, List<double>> candidateRotations, Random random)
{
if (sequence.Count < 2)
@@ -169,7 +176,7 @@ namespace OpenNest.Engine.Nfp
/// <summary>
/// Swaps two random parts in the sequence.
/// </summary>
private static void MutateSwap(List<(int, double, Drawing)> sequence, Random random)
private static void MutateSwap(List<SequenceEntry> sequence, Random random)
{
var i = random.Next(sequence.Count);
var j = random.Next(sequence.Count);
@@ -183,23 +190,23 @@ namespace OpenNest.Engine.Nfp
/// <summary>
/// Changes a random part's rotation to another candidate angle.
/// </summary>
private static void MutateRotate(List<(int drawingId, double rotation, Drawing drawing)> sequence,
private static void MutateRotate(List<SequenceEntry> sequence,
Dictionary<int, List<double>> candidateRotations, Random random)
{
var idx = random.Next(sequence.Count);
var entry = sequence[idx];
if (!candidateRotations.TryGetValue(entry.drawingId, out var rotations) || rotations.Count <= 1)
if (!candidateRotations.TryGetValue(entry.DrawingId, out var rotations) || rotations.Count <= 1)
return;
var newRotation = rotations[random.Next(rotations.Count)];
sequence[idx] = (entry.drawingId, newRotation, entry.drawing);
sequence[idx] = entry.WithRotation(newRotation);
}
/// <summary>
/// Reverses a random contiguous subsequence.
/// </summary>
private static void MutateReverse(List<(int, double, Drawing)> sequence, Random random)
private static void MutateReverse(List<SequenceEntry> sequence, Random random)
{
var i = random.Next(sequence.Count);
var j = random.Next(sequence.Count);
@@ -221,7 +228,7 @@ namespace OpenNest.Engine.Nfp
/// are accepted initially.
/// </summary>
private static double CalibrateTemperature(
List<(int drawingId, double rotation, Drawing drawing)> sequence,
List<SequenceEntry> sequence,
Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations, Random random)
{
@@ -234,7 +241,7 @@ namespace OpenNest.Engine.Nfp
for (var i = 0; i < samples; i++)
{
var candidate = new List<(int, double, Drawing)>(sequence);
var candidate = new List<SequenceEntry>(sequence);
Mutate(candidate, candidateRotations, random);
var placed = blf.Fill(candidate);
@@ -266,5 +273,19 @@ namespace OpenNest.Engine.Nfp
return countDiff * 10.0 + densityDiff;
}
private static void ReportBest(IProgress<NestProgress> progress, List<Part> parts,
Box workArea, string description)
{
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Nfp,
PlateNumber = 0,
Parts = parts,
WorkArea = workArea,
Description = description,
IsOverallBest = true,
});
}
}
}

View File

@@ -0,0 +1,65 @@
using OpenNest.Engine.Fill;
using OpenNest.Engine.Nfp;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest
{
public class NfpNestEngine : NestEngineBase
{
public NfpNestEngine(Plate plate) : base(plate)
{
}
public override string Name => "NFP";
public override string Description => "NFP-based mixed-part nesting with simulated annealing";
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);
}
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);
}
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);
}
public override List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var parts = AutoNester.Nest(items, Plate, progress, token);
// Compact placed parts toward the origin to close gaps.
Compactor.Settle(parts, Plate.WorkArea(), Plate.PartSpacing);
// Deduct placed quantities from original items.
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
var placed = parts.FindAll(p => p.BaseDrawing.Name == item.Drawing.Name).Count;
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
return parts;
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using OpenNest.Engine.Fill;
namespace OpenNest.Engine.Strategies;
public class ColumnFillStrategy : IFillStrategy
{
public string Name => "Column";
public NestPhase Phase => NestPhase.Custom;
public int Order => 160;
public List<Part> Fill(FillContext context)
{
var filler = new StripeFiller(context, NestDirection.Vertical) { CompleteStripesOnly = true };
return filler.Fill();
}
}

View File

@@ -21,7 +21,7 @@ namespace OpenNest.Engine.Strategies
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
List<Part> best = null;
var bestScore = default(FillScore);
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
foreach (var angle in angles)
{
@@ -30,12 +30,8 @@ namespace OpenNest.Engine.Strategies
context.PlateNumber, context.Token, context.Progress);
if (result != null && result.Count > 0)
{
var score = FillScore.Compute(result, context.WorkArea);
if (best == null || score > bestScore)
{
if (best == null || comparer.IsBetter(result, best, context.WorkArea))
best = result;
bestScore = score;
}
}
}

View File

@@ -14,8 +14,10 @@ namespace OpenNest.Engine.Strategies
public int PlateNumber { get; init; }
public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; }
public List<Part> CurrentBest { get; set; }
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
public FillScore CurrentBestScore { get; set; }
public NestPhase WinnerPhase { get; set; }
public List<PhaseResult> PhaseResults { get; } = new();

View File

@@ -1,6 +1,7 @@
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -29,7 +30,7 @@ namespace OpenNest.Engine.Strategies
return pattern;
}
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea, IFillComparer comparer = null)
{
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
@@ -54,14 +55,59 @@ namespace OpenNest.Engine.Strategies
foreach (var res in results)
{
if (best == null || res.Score > bestScore)
if (comparer != null)
{
best = res.Parts;
bestScore = res.Score;
if (best == null || comparer.IsBetter(res.Parts, best, workArea))
best = res.Parts;
}
else
{
if (best == null || res.Score > bestScore)
{
best = res.Parts;
bestScore = res.Score;
}
}
}
return best;
}
/// <summary>
/// Runs a fill function with direction preference logic.
/// If preferred is null, tries both directions and returns the better result.
/// If preferred is set, tries preferred first; only tries other if preferred yields zero.
/// </summary>
public static List<Part> FillWithDirectionPreference(
Func<NestDirection, List<Part>> fillFunc,
NestDirection? preferred,
IFillComparer comparer,
Box workArea)
{
if (preferred == null)
{
var h = fillFunc(NestDirection.Horizontal);
var v = fillFunc(NestDirection.Vertical);
if ((h == null || h.Count == 0) && (v == null || v.Count == 0))
return new List<Part>();
if (h == null || h.Count == 0) return v;
if (v == null || v.Count == 0) return h;
return comparer.IsBetter(h, v, workArea) ? h : v;
}
var other = preferred == NestDirection.Horizontal
? NestDirection.Vertical
: NestDirection.Horizontal;
var pref = fillFunc(preferred.Value);
if (pref != null && pref.Count > 0)
return pref;
var fallback = fillFunc(other);
return fallback ?? new List<Part>();
}
}
}

View File

@@ -0,0 +1,8 @@
namespace OpenNest.Engine.Strategies
{
/// <summary>
/// Groups engine scoring and direction policy into a single object.
/// Set by the engine, consumed by strategies via FillContext.Policy.
/// </summary>
public record FillPolicy(IFillComparer Comparer, NestDirection? PreferredDirection = null);
}

View File

@@ -12,6 +12,7 @@ namespace OpenNest.Engine.Strategies
private static readonly List<IFillStrategy> strategies = new();
private static List<IFillStrategy> sorted;
private static HashSet<string> enabledFilter;
private static readonly HashSet<string> disabled = new(StringComparer.OrdinalIgnoreCase);
static FillStrategyRegistry()
{
@@ -19,9 +20,36 @@ namespace OpenNest.Engine.Strategies
}
public static IReadOnlyList<IFillStrategy> Strategies =>
sorted ??= (enabledFilter != null
? strategies.Where(s => enabledFilter.Contains(s.Name)).OrderBy(s => s.Order).ToList()
: strategies.OrderBy(s => s.Order).ToList());
sorted ??= FilterStrategies();
private static List<IFillStrategy> FilterStrategies()
{
var source = enabledFilter != null
? strategies.Where(s => enabledFilter.Contains(s.Name))
: strategies.Where(s => !disabled.Contains(s.Name));
return source.OrderBy(s => s.Order).ToList();
}
/// <summary>
/// Permanently disables strategies by name. They remain registered
/// but are excluded from the default pipeline.
/// </summary>
public static void Disable(params string[] names)
{
foreach (var name in names)
disabled.Add(name);
sorted = null;
}
/// <summary>
/// Re-enables a previously disabled strategy.
/// </summary>
public static void Enable(params string[] names)
{
foreach (var name in names)
disabled.Remove(name);
sorted = null;
}
/// <summary>
/// Restricts the active strategies to only those whose names are listed.

View File

@@ -17,8 +17,9 @@ namespace OpenNest.Engine.Strategies
: new List<double> { 0, Angle.HalfPI };
var workArea = context.WorkArea;
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
var preferred = context.Policy?.PreferredDirection;
List<Part> best = null;
var bestScore = default(FillScore);
for (var ai = 0; ai < angles.Count; ai++)
{
@@ -26,48 +27,34 @@ namespace OpenNest.Engine.Strategies
var angle = angles[ai];
var engine = new FillLinear(workArea, context.Plate.PartSpacing);
var h = engine.Fill(context.Item.Drawing, angle, NestDirection.Horizontal);
var v = engine.Fill(context.Item.Drawing, angle, NestDirection.Vertical);
var result = FillHelpers.FillWithDirectionPreference(
dir => engine.Fill(context.Item.Drawing, angle, dir),
preferred, comparer, workArea);
var angleDeg = Angle.ToDegrees(angle);
if (h != null && h.Count > 0)
if (result != null && result.Count > 0)
{
var scoreH = FillScore.Compute(h, workArea);
context.AngleResults.Add(new AngleResult
{
AngleDeg = angleDeg,
Direction = NestDirection.Horizontal,
PartCount = h.Count
Direction = preferred ?? NestDirection.Horizontal,
PartCount = result.Count
});
if (best == null || scoreH > bestScore)
{
best = h;
bestScore = scoreH;
}
if (best == null || comparer.IsBetter(result, best, workArea))
best = result;
}
if (v != null && v.Count > 0)
NestEngineBase.ReportProgress(context.Progress, new ProgressReport
{
var scoreV = FillScore.Compute(v, workArea);
context.AngleResults.Add(new AngleResult
{
AngleDeg = angleDeg,
Direction = NestDirection.Vertical,
PartCount = v.Count
});
if (best == null || scoreV > bestScore)
{
best = v;
bestScore = scoreV;
}
}
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
context.PlateNumber, best, workArea,
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
Phase = NestPhase.Linear,
PlateNumber = context.PlateNumber,
Parts = best,
WorkArea = workArea,
Description = $"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts",
});
}
return best ?? new List<Part>();

View File

@@ -11,7 +11,9 @@ namespace OpenNest.Engine.Strategies
public List<Part> Fill(FillContext context)
{
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
var comparer = context.Policy?.Comparer;
var dedup = GridDedup.GetOrCreate(context.SharedState);
var filler = new PairFiller(context.Plate, comparer, dedup);
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using OpenNest.Engine.Fill;
namespace OpenNest.Engine.Strategies;
public class RowFillStrategy : IFillStrategy
{
public string Name => "Row";
public NestPhase Phase => NestPhase.Custom;
public int Order => 150;
public List<Part> Fill(FillContext context)
{
var filler = new StripeFiller(context, NestDirection.Horizontal) { CompleteStripesOnly = true };
return filler.Fill();
}
}

View File

@@ -77,27 +77,28 @@ namespace OpenNest
// Phase 1: Iterative shrink-fill for multi-quantity items.
if (fillItems.Count > 0)
{
// Pass progress through so the UI shows intermediate results
// during the initial BestFitCache computation and fill phases.
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
// Use direction-specific engines: height shrink benefits from
// minimizing Y-extent, width shrink from minimizing X-extent.
Func<NestItem, Box, List<Part>> heightFillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
var inner = new HorizontalRemnantEngine(Plate);
return inner.Fill(ni, b, progress, token);
};
Func<NestItem, Box, List<Part>> widthFillFunc = (ni, b) =>
{
var inner = new VerticalRemnantEngine(Plate);
return inner.Fill(ni, b, progress, token);
};
var shrinkResult = IterativeShrinkFiller.Fill(
fillItems, workArea, fillFunc, Plate.PartSpacing, token,
progress, PlateNumber);
fillItems, workArea, heightFillFunc, Plate.PartSpacing, token,
progress, PlateNumber, widthFillFunc);
allParts.AddRange(shrinkResult.Parts);
// Compact placed parts toward the origin to close gaps.
if (allParts.Count > 1)
{
var noObstacles = new List<Part>();
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Left);
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Down);
}
Compactor.Settle(allParts, workArea, Plate.PartSpacing);
// Add unfilled items to pack list.
packItems.AddRange(shrinkResult.Leftovers);

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// Optimizes for the largest right-side vertical drop.
/// Scores by count first, then minimizes X-extent.
/// Prefers horizontal nest direction and angles that keep parts narrow in X.
/// </summary>
public class VerticalRemnantEngine : DefaultNestEngine
{
public VerticalRemnantEngine(Plate plate) : base(plate) { }
public override string Name => "Vertical Remnant";
public override string Description => "Optimizes for largest right-side vertical drop";
protected override IFillComparer CreateComparer() => new VerticalRemnantComparer();
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b)));
return baseAngles;
}
private static double RotatedWidth(NestItem item, double angle)
{
var bb = item.Drawing.Program.BoundingBox();
var cos = System.Math.Abs(System.Math.Cos(angle));
var sin = System.Math.Abs(System.Math.Sin(angle));
return bb.Width * cos + bb.Length * sin;
}
}
}

View File

@@ -129,7 +129,7 @@ namespace OpenNest.IO
Part1Rotation = r.Part1Rotation,
Part2Rotation = r.Part2Rotation,
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
StrategyType = r.StrategyType,
StrategyIndex = r.StrategyType,
TestNumber = r.TestNumber,
Spacing = r.CandidateSpacing
},

View File

@@ -214,7 +214,7 @@ namespace OpenNest.IO
Part2Rotation = r.Candidate.Part2Rotation,
Part2OffsetX = r.Candidate.Part2Offset.X,
Part2OffsetY = r.Candidate.Part2Offset.Y,
StrategyType = r.Candidate.StrategyType,
StrategyType = r.Candidate.StrategyIndex,
TestNumber = r.Candidate.TestNumber,
CandidateSpacing = r.Candidate.Spacing,
RotatedArea = r.RotatedArea,

View File

@@ -18,7 +18,7 @@ public class AccumulatingProgressTests
var accumulating = new AccumulatingProgress(inner, previous);
var newParts = new List<Part> { TestHelpers.MakePartAt(20, 0, 10) };
accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
accumulating.Report(new NestProgress { BestParts = newParts });
Assert.NotNull(inner.Last);
Assert.Equal(2, inner.Last.BestParts.Count);
@@ -32,7 +32,7 @@ public class AccumulatingProgressTests
var accumulating = new AccumulatingProgress(inner, new List<Part>());
var newParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
accumulating.Report(new NestProgress { BestParts = newParts });
Assert.NotNull(inner.Last);
Assert.Single(inner.Last.BestParts);

View File

@@ -29,18 +29,16 @@ public class AngleCandidateBuilderTests
}
[Fact]
public void Build_NarrowWorkArea_ProducesMoreAngles()
public void Build_NarrowWorkArea_UsesBaseAnglesOnly()
{
var builder = new AngleCandidateBuilder();
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var wideArea = new Box(0, 0, 100, 100);
var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side
var wideAngles = builder.Build(item, 0, wideArea);
var narrowAngles = builder.Build(item, 0, narrowArea);
var angles = builder.Build(item, 0, narrowArea);
Assert.True(narrowAngles.Count > wideAngles.Count,
$"Narrow ({narrowAngles.Count}) should have more angles than wide ({wideAngles.Count})");
// Without ForceFullSweep, narrow areas use only base angles (0° and 90°)
Assert.Equal(2, angles.Count);
}
[Fact]

View File

@@ -0,0 +1,150 @@
namespace OpenNest.Tests;
public class BestCombinationTests
{
[Fact]
public void BothFit_FindsZeroRemnant()
{
// 100 = 0*30 + 5*20 (algorithm iterates from countLength1=0, finds zero remnant first)
var result = BestCombination.FindFrom2(30, 20, 100, out var c1, out var c2);
Assert.True(result);
Assert.Equal(0.0, 100.0 - (c1 * 30.0 + c2 * 20.0), 5);
}
[Fact]
public void OnlyLength1Fits_ReturnsMaxCount1()
{
var result = BestCombination.FindFrom2(10, 200, 50, out var c1, out var c2);
Assert.True(result);
Assert.Equal(5, c1);
Assert.Equal(0, c2);
}
[Fact]
public void OnlyLength2Fits_ReturnsMaxCount2()
{
var result = BestCombination.FindFrom2(200, 10, 50, out var c1, out var c2);
Assert.True(result);
Assert.Equal(0, c1);
Assert.Equal(5, c2);
}
[Fact]
public void NeitherFits_ReturnsFalse()
{
var result = BestCombination.FindFrom2(100, 200, 50, out var c1, out var c2);
Assert.False(result);
Assert.Equal(0, c1);
Assert.Equal(0, c2);
}
[Fact]
public void Length1FillsExactly_ZeroRemnant()
{
var result = BestCombination.FindFrom2(25, 10, 100, out var c1, out var c2);
Assert.True(result);
Assert.Equal(0.0, 100.0 - (c1 * 25.0 + c2 * 10.0), 5);
}
[Fact]
public void MixMinimizesRemnant()
{
// 7 and 3 into 20: best is 2*7 + 2*3 = 20 (zero remnant)
var result = BestCombination.FindFrom2(7, 3, 20, out var c1, out var c2);
Assert.True(result);
Assert.Equal(2, c1);
Assert.Equal(2, c2);
Assert.True(c1 * 7 + c2 * 3 <= 20);
}
[Fact]
public void PrefersLessRemnant_OverMoreOfLength1()
{
// 6 and 5 into 17:
// all length1: 2*6=12, remnant=5 -> actually 2*6+1*5=17 perfect
var result = BestCombination.FindFrom2(6, 5, 17, out var c1, out var c2);
Assert.True(result);
Assert.Equal(0.0, 17.0 - (c1 * 6.0 + c2 * 5.0), 5);
}
[Fact]
public void EqualLengths_FillsWithLength1()
{
var result = BestCombination.FindFrom2(10, 10, 50, out var c1, out var c2);
Assert.True(result);
Assert.Equal(5, c1 + c2);
}
[Fact]
public void SmallLengths_LargeOverall()
{
var result = BestCombination.FindFrom2(3, 7, 100, out var c1, out var c2);
Assert.True(result);
var used = c1 * 3.0 + c2 * 7.0;
Assert.True(used <= 100);
Assert.True(100 - used < 3); // remnant less than smallest piece
}
[Fact]
public void Length2IsBetter_SoleCandidate()
{
// length1=9, length2=5, overall=10:
// length1 alone: 1*9=9 remnant=1
// length2 alone: 2*5=10 remnant=0
var result = BestCombination.FindFrom2(9, 5, 10, out var c1, out var c2);
Assert.True(result);
Assert.Equal(0, c1);
Assert.Equal(2, c2);
}
[Fact]
public void FractionalLengths_WorkCorrectly()
{
var result = BestCombination.FindFrom2(2.5, 3.5, 12, out var c1, out var c2);
Assert.True(result);
var used = c1 * 2.5 + c2 * 3.5;
Assert.True(used <= 12.0 + 0.001);
}
[Fact]
public void OverallExactlyOneOfEach()
{
var result = BestCombination.FindFrom2(40, 60, 100, out var c1, out var c2);
Assert.True(result);
Assert.Equal(1, c1);
Assert.Equal(1, c2);
}
[Fact]
public void OverallSmallerThanEither_ReturnsFalse()
{
var result = BestCombination.FindFrom2(10, 20, 5, out var c1, out var c2);
Assert.False(result);
Assert.Equal(0, c1);
Assert.Equal(0, c2);
}
[Fact]
public void ZeroRemnant_StopsEarly()
{
// 4 and 6 into 24: 0*4+4*6=24 or 3*4+2*6=24 or 6*4+0*6=24
// Algorithm iterates from 0 length1 upward, finds zero remnant and breaks
var result = BestCombination.FindFrom2(4, 6, 24, out var c1, out var c2);
Assert.True(result);
Assert.Equal(0.0, 24.0 - (c1 * 4.0 + c2 * 6.0), 5);
}
}

View File

@@ -0,0 +1,173 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class DefaultFillComparerTests
{
private readonly IFillComparer comparer = new DefaultFillComparer();
private readonly Box workArea = new(0, 0, 100, 100);
[Fact]
public void NullCandidate_ReturnsFalse()
{
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.False(comparer.IsBetter(null, current, workArea));
}
[Fact]
public void EmptyCandidate_ReturnsFalse()
{
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.False(comparer.IsBetter(new List<Part>(), current, workArea));
}
[Fact]
public void NullCurrent_ReturnsTrue()
{
var candidate = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.True(comparer.IsBetter(candidate, null, workArea));
}
[Fact]
public void HigherCount_Wins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(20, 0, 10),
TestHelpers.MakePartAt(40, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(20, 0, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void SameCount_HigherDensityWins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(12, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(50, 0, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
}
public class VerticalRemnantComparerTests
{
private readonly IFillComparer comparer = new VerticalRemnantComparer();
private readonly Box workArea = new(0, 0, 100, 100);
[Fact]
public void HigherCount_WinsRegardlessOfExtent()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(40, 0, 10),
TestHelpers.MakePartAt(80, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(12, 0, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void SameCount_SmallerXExtent_Wins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(12, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(50, 0, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void SameCount_SameExtent_HigherDensityWins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(40, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(40, 40, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void NullCandidate_ReturnsFalse()
{
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.False(comparer.IsBetter(null, current, workArea));
}
[Fact]
public void NullCurrent_ReturnsTrue()
{
var candidate = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.True(comparer.IsBetter(candidate, null, workArea));
}
}
public class HorizontalRemnantComparerTests
{
private readonly IFillComparer comparer = new HorizontalRemnantComparer();
private readonly Box workArea = new(0, 0, 100, 100);
[Fact]
public void SameCount_SmallerYExtent_Wins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(0, 12, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(0, 50, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void HigherCount_WinsRegardlessOfExtent()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(0, 40, 10),
TestHelpers.MakePartAt(0, 80, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(0, 12, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
}

View File

@@ -0,0 +1,50 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class FillWithDirectionPreferenceTests
{
private readonly IFillComparer comparer = new DefaultFillComparer();
private readonly Box workArea = new(0, 0, 100, 100);
[Fact]
public void NullPreference_TriesBothDirections_ReturnsBetter()
{
var hParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(12, 0, 10) };
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
var result = FillHelpers.FillWithDirectionPreference(
dir => dir == NestDirection.Horizontal ? hParts : vParts,
null, comparer, workArea);
Assert.Equal(2, result.Count);
}
[Fact]
public void PreferredDirection_UsedFirst_WhenProducesResults()
{
var hParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(12, 0, 10) };
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(0, 12, 10), TestHelpers.MakePartAt(0, 24, 10) };
var result = FillHelpers.FillWithDirectionPreference(
dir => dir == NestDirection.Horizontal ? hParts : vParts,
NestDirection.Horizontal, comparer, workArea);
Assert.Equal(2, result.Count); // H has results, so H is returned (preferred)
}
[Fact]
public void PreferredDirection_FallsBack_WhenPreferredReturnsEmpty()
{
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
var result = FillHelpers.FillWithDirectionPreference(
dir => dir == NestDirection.Horizontal ? new List<Part>() : vParts,
NestDirection.Horizontal, comparer, workArea);
Assert.Equal(1, result.Count); // Falls back to V
}
}

View File

@@ -0,0 +1,28 @@
namespace OpenNest.Tests;
public class NestPhaseExtensionsTests
{
[Theory]
[InlineData(NestPhase.Linear, "Trying rotations...")]
[InlineData(NestPhase.RectBestFit, "Trying best fit...")]
[InlineData(NestPhase.Pairs, "Trying pairs...")]
[InlineData(NestPhase.Nfp, "Trying NFP...")]
[InlineData(NestPhase.Extents, "Trying extents...")]
[InlineData(NestPhase.Custom, "Custom")]
public void DisplayName_ReturnsDescription(NestPhase phase, string expected)
{
Assert.Equal(expected, phase.DisplayName());
}
[Theory]
[InlineData(NestPhase.Linear, "Linear")]
[InlineData(NestPhase.RectBestFit, "BestFit")]
[InlineData(NestPhase.Pairs, "Pairs")]
[InlineData(NestPhase.Nfp, "NFP")]
[InlineData(NestPhase.Extents, "Extents")]
[InlineData(NestPhase.Custom, "Custom")]
public void ShortName_ReturnsShortLabel(NestPhase phase, string expected)
{
Assert.Equal(expected, phase.ShortName());
}
}

View File

@@ -0,0 +1,100 @@
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class NestProgressTests
{
[Fact]
public void BestPartCount_NullParts_ReturnsZero()
{
var progress = new NestProgress { BestParts = null };
Assert.Equal(0, progress.BestPartCount);
}
[Fact]
public void BestPartCount_ReturnsBestPartsCount()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(10, 0, 5),
};
var progress = new NestProgress { BestParts = parts };
Assert.Equal(2, progress.BestPartCount);
}
[Fact]
public void BestDensity_NullParts_ReturnsZero()
{
var progress = new NestProgress { BestParts = null };
Assert.Equal(0, progress.BestDensity);
}
[Fact]
public void BestDensity_MatchesFillScoreFormula()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(5, 0, 5),
};
var workArea = new Box(0, 0, 100, 100);
var progress = new NestProgress { BestParts = parts, ActiveWorkArea = workArea };
Assert.Equal(1.0, progress.BestDensity, precision: 4);
}
[Fact]
public void NestedWidth_ReturnsPartsSpan()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(10, 0, 5),
};
var progress = new NestProgress { BestParts = parts };
Assert.Equal(15, progress.NestedWidth, precision: 4);
}
[Fact]
public void NestedLength_ReturnsPartsSpan()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(0, 10, 5),
};
var progress = new NestProgress { BestParts = parts };
Assert.Equal(15, progress.NestedLength, precision: 4);
}
[Fact]
public void NestedArea_ReturnsSumOfPartAreas()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(10, 0, 5),
};
var progress = new NestProgress { BestParts = parts };
Assert.Equal(50, progress.NestedArea, precision: 4);
}
[Fact]
public void SettingBestParts_InvalidatesCache()
{
var parts1 = new List<Part> { TestHelpers.MakePartAt(0, 0, 5) };
var parts2 = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(10, 0, 5),
};
var progress = new NestProgress { BestParts = parts1 };
Assert.Equal(1, progress.BestPartCount);
Assert.Equal(25, progress.NestedArea, precision: 4);
progress.BestParts = parts2;
Assert.Equal(2, progress.BestPartCount);
Assert.Equal(50, progress.NestedArea, precision: 4);
}
}

View File

@@ -0,0 +1,57 @@
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class NfpBestFitIntegrationTests
{
[Fact]
public void FindBestFits_ReturnsKeptResults_ForSquare()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
Assert.NotEmpty(results);
Assert.NotEmpty(results.Where(r => r.Keep));
}
[Fact]
public void FindBestFits_ResultsHaveValidDimensions()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
foreach (var result in results.Where(r => r.Keep))
{
Assert.True(result.BoundingWidth > 0);
Assert.True(result.BoundingHeight > 0);
Assert.True(result.RotatedArea > 0);
}
}
[Fact]
public void FindBestFits_LShape_HasBetterUtilization_ThanBoundingBox()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeLShapeDrawing();
var results = finder.FindBestFits(drawing);
var bestUtilization = results
.Where(r => r.Keep)
.Max(r => r.Utilization);
Assert.True(bestUtilization > 0.5);
}
[Fact]
public void FindBestFits_NoOverlaps_InKeptResults()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
Assert.All(results.Where(r => r.Keep), r =>
Assert.Equal("Valid", r.Reason));
}
}

View File

@@ -0,0 +1,124 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests;
public class NfpSlideStrategyTests
{
[Fact]
public void GenerateCandidates_ReturnsNonEmpty_ForSquare()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.NotEmpty(candidates);
}
[Fact]
public void GenerateCandidates_AllCandidatesHaveCorrectDrawing()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Same(drawing, c.Drawing));
}
[Fact]
public void GenerateCandidates_Part1RotationIsAlwaysZero()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, Angle.HalfPI, 1, "90 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Equal(0, c.Part1Rotation));
}
[Fact]
public void GenerateCandidates_Part2RotationMatchesStrategy()
{
var rotation = Angle.HalfPI;
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, rotation, 1, "90 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Equal(rotation, c.Part2Rotation));
}
[Fact]
public void GenerateCandidates_ProducesReasonableCandidateCount()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
// Convex hull NFP for a square produces vertices + edge samples.
// Should have more than just vertices but not thousands.
Assert.True(candidates.Count >= 4);
Assert.True(candidates.Count < 1000);
}
[Fact]
public void GenerateCandidates_MoreCandidates_WithSmallerStepSize()
{
var drawing = TestHelpers.MakeSquareDrawing();
var largeStepStrategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
var smallStepStrategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(largeStepStrategy);
Assert.NotNull(smallStepStrategy);
var largeStep = largeStepStrategy.GenerateCandidates(drawing, 0.25, 5.0);
var smallStep = smallStepStrategy.GenerateCandidates(drawing, 0.25, 0.5);
Assert.True(smallStep.Count >= largeStep.Count);
}
[Fact]
public void Create_ReturnsNull_ForEmptyDrawing()
{
var pgm = new Program();
var drawing = new Drawing("empty", pgm);
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.Null(strategy);
}
[Fact]
public void GenerateCandidates_LShape_ProducesCandidates()
{
var lshape = TestHelpers.MakeLShapeDrawing();
var strategy = NfpSlideStrategy.Create(lshape, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(lshape, 0.25, 0.25);
Assert.NotEmpty(candidates);
}
[Fact]
public void GenerateCandidates_At180Degrees_ProducesAtLeastOneNonOverlappingCandidate()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, System.Math.PI, 1, "180 deg NFP", 1.0);
Assert.NotNull(strategy);
// Use a large spacing (1.0) and step size.
// This should make NFP much larger than the parts.
var candidates = strategy.GenerateCandidates(drawing, 1.0, 1.0);
Assert.NotEmpty(candidates);
var part1 = Part.CreateAtOrigin(drawing);
var validCount = 0;
foreach (var candidate in candidates)
{
var part2 = Part.CreateAtOrigin(drawing, candidate.Part2Rotation);
part2.Location = candidate.Part2Offset;
// With 1.0 spacing, parts should NOT intersect even with tiny precision errors.
if (!part1.Intersects(part2, out _))
validCount++;
}
Assert.True(validCount > 0, $"No non-overlapping candidates found out of {candidates.Count} total. Candidate 0 offset: {candidates[0].Part2Offset}");
}
}

View File

@@ -16,11 +16,15 @@ public class PairFillerTests
return new Drawing("rect", pgm);
}
private static Plate MakePlate(double width, double length, double spacing = 0.5)
{
return new Plate { Size = new Size(width, length), PartSpacing = spacing };
}
[Fact]
public void Fill_ReturnsPartsForSimpleDrawing()
{
var plateSize = new Size(120, 60);
var filler = new PairFiller(plateSize, 0.5);
var filler = new PairFiller(MakePlate(120, 60));
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var workArea = new Box(0, 0, 120, 60);
@@ -33,8 +37,7 @@ public class PairFillerTests
[Fact]
public void Fill_EmptyResult_WhenPartTooLarge()
{
var plateSize = new Size(10, 10);
var filler = new PairFiller(plateSize, 0.5);
var filler = new PairFiller(MakePlate(10, 10));
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
var workArea = new Box(0, 0, 10, 10);
@@ -50,8 +53,7 @@ public class PairFillerTests
var cts = new System.Threading.CancellationTokenSource();
cts.Cancel();
var plateSize = new Size(120, 60);
var filler = new PairFiller(plateSize, 0.5);
var filler = new PairFiller(MakePlate(120, 60));
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var workArea = new Box(0, 0, 120, 60);

View File

@@ -0,0 +1,88 @@
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests;
public class PolygonHelperTests
{
[Fact]
public void ExtractPerimeterPolygon_ReturnsPolygon_ForValidDrawing()
{
var drawing = TestHelpers.MakeSquareDrawing();
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.NotNull(result.Polygon);
Assert.True(result.Polygon.Vertices.Count >= 4);
}
[Fact]
public void ExtractPerimeterPolygon_InflatesPolygon_WhenSpacingNonZero()
{
var drawing = TestHelpers.MakeSquareDrawing(10);
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
noSpacing.Polygon.UpdateBounds();
withSpacing.Polygon.UpdateBounds();
// The offset polygon should differ in size from the non-offset polygon.
// OffsetSide.Left offsets outward or inward depending on winding,
// but either way the result must be a different size.
Assert.True(
System.Math.Abs(withSpacing.Polygon.BoundingBox.Width - noSpacing.Polygon.BoundingBox.Width) > 0.5,
$"Expected polygon width to differ by >0.5 with 1mm spacing. " +
$"No-spacing width: {noSpacing.Polygon.BoundingBox.Width:F3}, " +
$"With-spacing width: {withSpacing.Polygon.BoundingBox.Width:F3}");
}
[Fact]
public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing()
{
var pgm = new Program();
var drawing = new Drawing("empty", pgm);
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.Null(result.Polygon);
}
[Fact]
public void ExtractPerimeterPolygon_CorrectionVector_ReflectsOriginDifference()
{
var drawing = TestHelpers.MakeSquareDrawing();
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.NotNull(result.Polygon);
Assert.True(System.Math.Abs(result.Correction.X) < 1);
Assert.True(System.Math.Abs(result.Correction.Y) < 1);
}
[Fact]
public void RotatePolygon_AtZero_ReturnsSamePolygon()
{
var polygon = new Polygon();
polygon.Vertices.Add(new Vector(0, 0));
polygon.Vertices.Add(new Vector(10, 0));
polygon.Vertices.Add(new Vector(10, 10));
polygon.Vertices.Add(new Vector(0, 10));
polygon.UpdateBounds();
var rotated = PolygonHelper.RotatePolygon(polygon, 0);
Assert.Same(polygon, rotated);
}
[Fact]
public void RotatePolygon_At90Degrees_SwapsDimensions()
{
var polygon = new Polygon();
polygon.Vertices.Add(new Vector(0, 0));
polygon.Vertices.Add(new Vector(20, 0));
polygon.Vertices.Add(new Vector(20, 10));
polygon.Vertices.Add(new Vector(0, 10));
polygon.UpdateBounds();
var rotated = PolygonHelper.RotatePolygon(polygon, Angle.HalfPI);
rotated.UpdateBounds();
Assert.True(System.Math.Abs(rotated.BoundingBox.Width - 10) < 0.1);
Assert.True(System.Math.Abs(rotated.BoundingBox.Length - 20) < 0.1);
}
}

View File

@@ -0,0 +1,92 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class RemnantEngineTests
{
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
return new Drawing(name, pgm);
}
[Fact]
public void VerticalRemnantEngine_UsesVerticalRemnantComparer()
{
var plate = new Plate(60, 120);
var engine = new VerticalRemnantEngine(plate);
Assert.Equal("Vertical Remnant", engine.Name);
Assert.Equal(NestDirection.Horizontal, engine.PreferredDirection);
}
[Fact]
public void HorizontalRemnantEngine_UsesHorizontalRemnantComparer()
{
var plate = new Plate(60, 120);
var engine = new HorizontalRemnantEngine(plate);
Assert.Equal("Horizontal Remnant", engine.Name);
Assert.Equal(NestDirection.Vertical, engine.PreferredDirection);
}
[Fact]
public void VerticalRemnantEngine_Fill_ProducesResults()
{
var plate = new Plate(60, 120);
var engine = new VerticalRemnantEngine(plate);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(parts.Count > 0, "VerticalRemnantEngine should fill parts");
}
[Fact]
public void HorizontalRemnantEngine_Fill_ProducesResults()
{
var plate = new Plate(60, 120);
var engine = new HorizontalRemnantEngine(plate);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(parts.Count > 0, "HorizontalRemnantEngine should fill parts");
}
[Fact]
public void Registry_ContainsBothRemnantEngines()
{
var names = NestEngineRegistry.AvailableEngines.Select(e => e.Name).ToList();
Assert.Contains("Vertical Remnant", names);
Assert.Contains("Horizontal Remnant", names);
}
[Fact]
public void VerticalRemnantEngine_ProducesTighterXExtent_ThanDefault()
{
var plate = new Plate(60, 120);
var drawing = MakeRectDrawing(20, 10);
var item = new NestItem { Drawing = drawing };
var defaultEngine = new DefaultNestEngine(plate);
var remnantEngine = new VerticalRemnantEngine(plate);
var defaultParts = defaultEngine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
var remnantParts = remnantEngine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(defaultParts.Count > 0);
Assert.True(remnantParts.Count > 0);
var defaultXExtent = defaultParts.Max(p => p.BoundingBox.Right) - defaultParts.Min(p => p.BoundingBox.Left);
var remnantXExtent = remnantParts.Max(p => p.BoundingBox.Right) - remnantParts.Min(p => p.BoundingBox.Left);
Assert.True(remnantXExtent <= defaultXExtent + 0.01,
$"Remnant X-extent ({remnantXExtent:F1}) should be <= default ({defaultXExtent:F1})");
}
}

View File

@@ -1,3 +1,4 @@
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
namespace OpenNest.Tests.Strategies;
@@ -24,8 +25,8 @@ public class FillPipelineTests
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(engine.PhaseResults.Count >= 4,
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
Assert.True(engine.PhaseResults.Count >= FillStrategyRegistry.Strategies.Count,
$"Expected phase results from all active strategies, got {engine.PhaseResults.Count}");
}
[Fact]
@@ -41,7 +42,8 @@ public class FillPipelineTests
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
engine.WinnerPhase == NestPhase.Linear ||
engine.WinnerPhase == NestPhase.RectBestFit ||
engine.WinnerPhase == NestPhase.Extents);
engine.WinnerPhase == NestPhase.Extents ||
engine.WinnerPhase == NestPhase.Custom);
}
[Fact]

View File

@@ -1,3 +1,4 @@
using System.Linq;
using OpenNest.Engine.Strategies;
namespace OpenNest.Tests.Strategies;
@@ -9,11 +10,13 @@ public class FillStrategyRegistryTests
{
var strategies = FillStrategyRegistry.Strategies;
Assert.True(strategies.Count >= 4, $"Expected at least 4 built-in strategies, got {strategies.Count}");
Assert.True(strategies.Count >= 6, $"Expected at least 6 built-in strategies, got {strategies.Count}");
Assert.Contains(strategies, s => s.Name == "Pairs");
Assert.Contains(strategies, s => s.Name == "RectBestFit");
Assert.Contains(strategies, s => s.Name == "Extents");
Assert.Contains(strategies, s => s.Name == "Linear");
Assert.Contains(strategies, s => s.Name == "Row");
Assert.Contains(strategies, s => s.Name == "Column");
}
[Fact]
@@ -34,4 +37,19 @@ public class FillStrategyRegistryTests
Assert.Equal("Linear", last.Name);
}
[Fact]
public void Registry_RowAndColumnOrderedBetweenPairsAndRectBestFit()
{
var strategies = FillStrategyRegistry.Strategies;
var pairsOrder = strategies.First(s => s.Name == "Pairs").Order;
var rectOrder = strategies.First(s => s.Name == "RectBestFit").Order;
var rowOrder = strategies.First(s => s.Name == "Row").Order;
var colOrder = strategies.First(s => s.Name == "Column").Order;
Assert.True(rowOrder > pairsOrder, "Row should run after Pairs");
Assert.True(colOrder > pairsOrder, "Column should run after Pairs");
Assert.True(rowOrder < rectOrder, "Row should run before RectBestFit");
Assert.True(colOrder < rectOrder, "Column should run before RectBestFit");
}
}

View File

@@ -0,0 +1,217 @@
using System.Collections.Generic;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
namespace OpenNest.Tests.Strategies;
public class StripeFillerTests
{
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
return new Drawing(name, pgm);
}
private static Pattern MakeRectPattern(double w, double h)
{
var drawing = MakeRectDrawing(w, h);
var part = Part.CreateAtOrigin(drawing);
var pattern = new Pattern();
pattern.Parts.Add(part);
pattern.UpdateBounds();
return pattern;
}
/// <summary>
/// Builds a simple side-by-side pair BestFitResult for a rectangular drawing.
/// Places two copies next to each other along the X axis with the given spacing.
/// </summary>
private static List<BestFitResult> MakeSideBySideBestFits(
Drawing drawing, double spacing)
{
var bb = drawing.Program.BoundingBox();
var w = bb.Width;
var h = bb.Length;
var candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = 0,
Part2Offset = new Vector(w + spacing, 0),
Spacing = spacing,
};
var pairWidth = 2 * w + spacing;
var result = new BestFitResult
{
Candidate = candidate,
BoundingWidth = pairWidth,
BoundingHeight = h,
RotatedArea = pairWidth * h,
TrueArea = 2 * w * h,
OptimalRotation = 0,
Keep = true,
Reason = "Valid",
HullAngles = new List<double>(),
};
return new List<BestFitResult> { result };
}
[Fact]
public void FindAngleForTargetSpan_ZeroAngle_WhenAlreadyMatches()
{
var pattern = MakeRectPattern(20, 10);
var angle = StripeFiller.FindAngleForTargetSpan(
pattern.Parts, 20.0, NestDirection.Horizontal);
Assert.True(System.Math.Abs(angle) < 0.05,
$"Expected angle near 0, got {OpenNest.Math.Angle.ToDegrees(angle):F1}°");
}
[Fact]
public void FindAngleForTargetSpan_FindsLargerSpan()
{
var pattern = MakeRectPattern(20, 10);
var angle = StripeFiller.FindAngleForTargetSpan(
pattern.Parts, 22.0, NestDirection.Horizontal);
var rotated = FillHelpers.BuildRotatedPattern(pattern.Parts, angle);
var span = rotated.BoundingBox.Width;
Assert.True(System.Math.Abs(span - 22.0) < 0.5,
$"Expected span ~22, got {span:F2} at {OpenNest.Math.Angle.ToDegrees(angle):F1}°");
}
[Fact]
public void FindAngleForTargetSpan_ReturnsClosest_WhenUnreachable()
{
var pattern = MakeRectPattern(20, 10);
var angle = StripeFiller.FindAngleForTargetSpan(
pattern.Parts, 30.0, NestDirection.Horizontal);
Assert.True(angle >= 0 && angle <= System.Math.PI / 2);
}
[Fact]
public void ConvergeStripeAngle_ReducesWaste()
{
var pattern = MakeRectPattern(20, 10);
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
pattern.Parts, 120.0, 0.5, NestDirection.Horizontal);
Assert.True(count >= 5, $"Expected at least 5 pairs, got {count}");
Assert.True(waste < 18.0, $"Expected waste < 18, got {waste:F2}");
}
[Fact]
public void ConvergeStripeAngle_HandlesExactFit()
{
// 10x5 pattern: short side (5) oriented along axis, so more pairs fit
var pattern = MakeRectPattern(10, 5);
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
pattern.Parts, 100.0, 0.0, NestDirection.Horizontal);
Assert.True(count >= 10, $"Expected at least 10 pairs, got {count}");
Assert.True(waste < 1.0, $"Expected low waste, got {waste:F2}");
}
[Fact]
public void ConvergeStripeAngle_Vertical()
{
var pattern = MakeRectPattern(10, 20);
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
pattern.Parts, 120.0, 0.5, NestDirection.Vertical);
Assert.True(count >= 5, $"Expected at least 5 pairs, got {count}");
}
[Fact]
public void Fill_ProducesPartsForSimpleDrawing()
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
var item = new NestItem { Drawing = drawing };
var workArea = new Box(0, 0, 120, 60);
var bestFits = MakeSideBySideBestFits(drawing, 0.5);
var context = new OpenNest.Engine.Strategies.FillContext
{
Item = item,
WorkArea = workArea,
Plate = plate,
PlateNumber = 0,
Token = System.Threading.CancellationToken.None,
Progress = null,
};
context.SharedState["BestFits"] = bestFits;
var filler = new StripeFiller(context, NestDirection.Horizontal);
var parts = filler.Fill();
Assert.NotNull(parts);
Assert.True(parts.Count > 0, "Expected parts from stripe fill");
}
[Fact]
public void Fill_VerticalProducesParts()
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
var item = new NestItem { Drawing = drawing };
var workArea = new Box(0, 0, 120, 60);
var bestFits = MakeSideBySideBestFits(drawing, 0.5);
var context = new OpenNest.Engine.Strategies.FillContext
{
Item = item,
WorkArea = workArea,
Plate = plate,
PlateNumber = 0,
Token = System.Threading.CancellationToken.None,
Progress = null,
};
context.SharedState["BestFits"] = bestFits;
var filler = new StripeFiller(context, NestDirection.Vertical);
var parts = filler.Fill();
Assert.NotNull(parts);
Assert.True(parts.Count > 0, "Expected parts from column fill");
}
[Fact]
public void Fill_ReturnsEmpty_WhenNoBestFits()
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
var item = new NestItem { Drawing = drawing };
var workArea = new Box(0, 0, 120, 60);
var context = new OpenNest.Engine.Strategies.FillContext
{
Item = item,
WorkArea = workArea,
Plate = plate,
PlateNumber = 0,
Token = System.Threading.CancellationToken.None,
Progress = null,
};
context.SharedState["BestFits"] = new List<OpenNest.Engine.BestFit.BestFitResult>();
var filler = new StripeFiller(context, NestDirection.Horizontal);
var parts = filler.Fill();
Assert.NotNull(parts);
Assert.Empty(parts);
}
}

View File

@@ -24,4 +24,28 @@ internal static class TestHelpers
plate.Parts.Add(p);
return plate;
}
public static Drawing MakeSquareDrawing(double size = 10)
{
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)));
return new Drawing("square", pgm);
}
public static Drawing MakeLShapeDrawing()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 5)));
pgm.Codes.Add(new LinearMove(new Vector(5, 5)));
pgm.Codes.Add(new LinearMove(new Vector(5, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("lshape", pgm);
}
}

View File

@@ -40,7 +40,7 @@ namespace OpenNest.Controls
metadataLines = new[]
{
string.Format("#{0} {1:F1}x{2:F1} Area={3:F1}",
rank, result.BoundingWidth, result.BoundingHeight, result.RotatedArea),
rank, result.BoundingHeight, result.BoundingWidth, result.RotatedArea),
string.Format("Util={0:P1} Rot={1:F1}\u00b0",
result.Utilization,
Angle.ToDegrees(result.OptimalRotation)),

View File

@@ -59,16 +59,6 @@ namespace OpenNest.Controls
}
}
private static string GetDisplayName(NestPhase phase)
{
switch (phase)
{
case NestPhase.RectBestFit: return "BestFit";
case NestPhase.Nfp: return "NFP";
default: return phase.ToString();
}
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
@@ -134,7 +124,7 @@ namespace OpenNest.Controls
}
// Label
var label = GetDisplayName(phase);
var label = phase.ShortName();
var font = isVisited || isActive ? BoldLabelFont : LabelFont;
var brush = isVisited || isActive ? activeTextBrush : pendingTextBrush;
var labelSize = g.MeasureString(label, font);

View File

@@ -955,8 +955,13 @@ namespace OpenNest.Controls
try
{
var engine = NestEngineRegistry.Create(Plate);
var spacing = Plate.PartSpacing;
var parts = await Task.Run(() =>
engine.Fill(groupParts, workArea, progress, cts.Token));
{
var result = engine.Fill(groupParts, workArea, progress, cts.Token);
Compactor.Settle(result, workArea, spacing);
return result;
});
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
{
@@ -1093,23 +1098,16 @@ namespace OpenNest.Controls
var bounds = parts.GetBoundingBox();
var center = bounds.Center;
var anchor = bounds.Location;
var rotatedPrograms = new HashSet<Program>();
for (int i = 0; i < SelectedParts.Count; ++i)
for (var i = 0; i < SelectedParts.Count; ++i)
{
var part = SelectedParts[i];
var basePart = part.BasePart;
if (rotatedPrograms.Add(basePart.Program))
basePart.Program.Rotate(angle);
part.Location = part.Location.Rotate(angle, center);
basePart.UpdateBounds();
part.BasePart.Rotate(angle, center);
}
var diff = anchor - parts.GetBoundingBox().Location;
for (int i = 0; i < SelectedParts.Count; ++i)
for (var i = 0; i < SelectedParts.Count; ++i)
SelectedParts[i].Offset(diff);
}

View File

@@ -774,6 +774,9 @@ namespace OpenNest.Forms
private void drawingListUpdateTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
if (!drawingListBox1.IsHandleCreated)
return;
drawingListBox1.Invoke(new MethodInvoker(() =>
{
drawingListBox1.Refresh();

View File

@@ -131,6 +131,7 @@
plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
plateUtilStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
selectionStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
toolStrip1 = new System.Windows.Forms.ToolStrip();
@@ -829,7 +830,7 @@
//
// statusStrip1
//
statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { statusLabel1, locationStatusLabel, selectionStatusLabel, spacerLabel, plateIndexStatusLabel, plateSizeStatusLabel, plateQtyStatusLabel, gpuStatusLabel });
statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { statusLabel1, locationStatusLabel, selectionStatusLabel, spacerLabel, plateIndexStatusLabel, plateSizeStatusLabel, plateQtyStatusLabel, plateUtilStatusLabel, gpuStatusLabel });
statusStrip1.Location = new System.Drawing.Point(0, 630);
statusStrip1.Name = "statusStrip1";
statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0);
@@ -889,7 +890,15 @@
plateQtyStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19);
plateQtyStatusLabel.Text = "Qty : 0";
//
//
// plateUtilStatusLabel
//
plateUtilStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left;
plateUtilStatusLabel.Name = "plateUtilStatusLabel";
plateUtilStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
plateUtilStatusLabel.Size = new System.Drawing.Size(75, 19);
plateUtilStatusLabel.Text = "Util : 0.0%";
//
// gpuStatusLabel
//
gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left;
@@ -1128,6 +1137,7 @@
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem10;
private System.Windows.Forms.ToolStripMenuItem mnuCloseAll;
private System.Windows.Forms.ToolStripStatusLabel plateQtyStatusLabel;
private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel;
private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll;
private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem;

View File

@@ -204,6 +204,7 @@ namespace OpenNest.Forms
plateIndexStatusLabel.Text = string.Empty;
plateSizeStatusLabel.Text = string.Empty;
plateQtyStatusLabel.Text = string.Empty;
plateUtilStatusLabel.Text = string.Empty;
return;
}
@@ -219,6 +220,10 @@ namespace OpenNest.Forms
plateQtyStatusLabel.Text = string.Format(
"Qty: {0}",
activeForm.PlateView.Plate.Quantity);
plateUtilStatusLabel.Text = string.Format(
"Util: {0:P1}",
activeForm.PlateView.Plate.Utilization());
}
private void UpdateSelectionStatus()
@@ -342,6 +347,8 @@ namespace OpenNest.Forms
activeForm.PlateView.MouseClick -= PlateView_MouseClick;
activeForm.PlateView.StatusChanged -= PlateView_StatusChanged;
activeForm.PlateView.SelectionChanged -= PlateView_SelectionChanged;
activeForm.PlateView.PartAdded -= PlateView_PartAdded;
activeForm.PlateView.PartRemoved -= PlateView_PartRemoved;
}
// If nesting is in progress and the active form changed, cancel nesting
@@ -367,6 +374,8 @@ namespace OpenNest.Forms
UpdateSelectionStatus();
activeForm.PlateView.StatusChanged += PlateView_StatusChanged;
activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged;
activeForm.PlateView.PartAdded += PlateView_PartAdded;
activeForm.PlateView.PartRemoved += PlateView_PartRemoved;
mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid;
mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds;
statusLabel1.Text = activeForm.PlateView.Status;
@@ -1215,6 +1224,9 @@ namespace OpenNest.Forms
#region PlateView Events
private void PlateView_PartAdded(object sender, ItemAddedEventArgs<Part> e) => UpdatePlateStatus();
private void PlateView_PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => UpdatePlateStatus();
private void PlateView_MouseMove(object sender, MouseEventArgs e)
{
UpdateLocationStatus();

View File

@@ -85,13 +85,13 @@ namespace OpenNest.Forms
resultsTable.Controls.Add(nestedAreaLabel, 0, 2);
resultsTable.Controls.Add(nestedAreaValue, 1, 2);
resultsTable.Dock = System.Windows.Forms.DockStyle.Top;
resultsTable.Location = new System.Drawing.Point(14, 29);
resultsTable.Location = new System.Drawing.Point(14, 33);
resultsTable.Name = "resultsTable";
resultsTable.RowCount = 3;
resultsTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
resultsTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
resultsTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
resultsTable.Size = new System.Drawing.Size(422, 57);
resultsTable.Size = new System.Drawing.Size(422, 69);
resultsTable.TabIndex = 1;
//
// partsLabel
@@ -102,7 +102,7 @@ namespace OpenNest.Forms
partsLabel.Location = new System.Drawing.Point(0, 3);
partsLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
partsLabel.Name = "partsLabel";
partsLabel.Size = new System.Drawing.Size(36, 13);
partsLabel.Size = new System.Drawing.Size(43, 17);
partsLabel.TabIndex = 0;
partsLabel.Text = "Parts:";
//
@@ -110,10 +110,10 @@ namespace OpenNest.Forms
//
partsValue.AutoSize = true;
partsValue.Font = new System.Drawing.Font("Consolas", 9.75F);
partsValue.Location = new System.Drawing.Point(80, 3);
partsValue.Location = new System.Drawing.Point(90, 3);
partsValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
partsValue.Name = "partsValue";
partsValue.Size = new System.Drawing.Size(13, 13);
partsValue.Size = new System.Drawing.Size(13, 15);
partsValue.TabIndex = 1;
partsValue.Text = "<22>";
//
@@ -122,10 +122,10 @@ namespace OpenNest.Forms
densityLabel.AutoSize = true;
densityLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
densityLabel.ForeColor = System.Drawing.Color.FromArgb(51, 51, 51);
densityLabel.Location = new System.Drawing.Point(0, 22);
densityLabel.Location = new System.Drawing.Point(0, 26);
densityLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
densityLabel.Name = "densityLabel";
densityLabel.Size = new System.Drawing.Size(49, 13);
densityLabel.Size = new System.Drawing.Size(59, 17);
densityLabel.TabIndex = 2;
densityLabel.Text = "Density:";
//
@@ -134,10 +134,10 @@ namespace OpenNest.Forms
densityPanel.AutoSize = true;
densityPanel.Controls.Add(densityValue);
densityPanel.Controls.Add(densityBar);
densityPanel.Location = new System.Drawing.Point(80, 19);
densityPanel.Location = new System.Drawing.Point(90, 23);
densityPanel.Margin = new System.Windows.Forms.Padding(0);
densityPanel.Name = "densityPanel";
densityPanel.Size = new System.Drawing.Size(311, 19);
densityPanel.Size = new System.Drawing.Size(262, 21);
densityPanel.TabIndex = 3;
densityPanel.WrapContents = false;
//
@@ -148,7 +148,7 @@ namespace OpenNest.Forms
densityValue.Location = new System.Drawing.Point(0, 3);
densityValue.Margin = new System.Windows.Forms.Padding(0, 3, 8, 3);
densityValue.Name = "densityValue";
densityValue.Size = new System.Drawing.Size(13, 13);
densityValue.Size = new System.Drawing.Size(13, 15);
densityValue.TabIndex = 0;
densityValue.Text = "<22>";
//
@@ -157,7 +157,7 @@ namespace OpenNest.Forms
densityBar.Location = new System.Drawing.Point(21, 5);
densityBar.Margin = new System.Windows.Forms.Padding(0, 5, 0, 0);
densityBar.Name = "densityBar";
densityBar.Size = new System.Drawing.Size(290, 8);
densityBar.Size = new System.Drawing.Size(241, 8);
densityBar.TabIndex = 1;
densityBar.Value = 0D;
//
@@ -166,10 +166,10 @@ namespace OpenNest.Forms
nestedAreaLabel.AutoSize = true;
nestedAreaLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
nestedAreaLabel.ForeColor = System.Drawing.Color.FromArgb(51, 51, 51);
nestedAreaLabel.Location = new System.Drawing.Point(0, 41);
nestedAreaLabel.Location = new System.Drawing.Point(0, 49);
nestedAreaLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
nestedAreaLabel.Name = "nestedAreaLabel";
nestedAreaLabel.Size = new System.Drawing.Size(47, 13);
nestedAreaLabel.Size = new System.Drawing.Size(55, 17);
nestedAreaLabel.TabIndex = 4;
nestedAreaLabel.Text = "Nested:";
//
@@ -177,10 +177,10 @@ namespace OpenNest.Forms
//
nestedAreaValue.AutoSize = true;
nestedAreaValue.Font = new System.Drawing.Font("Consolas", 9.75F);
nestedAreaValue.Location = new System.Drawing.Point(80, 41);
nestedAreaValue.Location = new System.Drawing.Point(90, 49);
nestedAreaValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
nestedAreaValue.Name = "nestedAreaValue";
nestedAreaValue.Size = new System.Drawing.Size(13, 13);
nestedAreaValue.Size = new System.Drawing.Size(13, 15);
nestedAreaValue.TabIndex = 5;
nestedAreaValue.Text = "<22>";
//
@@ -193,7 +193,7 @@ namespace OpenNest.Forms
resultsHeader.Location = new System.Drawing.Point(14, 10);
resultsHeader.Name = "resultsHeader";
resultsHeader.Padding = new System.Windows.Forms.Padding(0, 0, 0, 4);
resultsHeader.Size = new System.Drawing.Size(56, 19);
resultsHeader.Size = new System.Drawing.Size(65, 23);
resultsHeader.TabIndex = 0;
resultsHeader.Text = "RESULTS";
//
@@ -203,7 +203,7 @@ namespace OpenNest.Forms
statusPanel.Controls.Add(statusTable);
statusPanel.Controls.Add(statusHeader);
statusPanel.Dock = System.Windows.Forms.DockStyle.Top;
statusPanel.Location = new System.Drawing.Point(0, 165);
statusPanel.Location = new System.Drawing.Point(0, 180);
statusPanel.Name = "statusPanel";
statusPanel.Padding = new System.Windows.Forms.Padding(14, 10, 14, 10);
statusPanel.Size = new System.Drawing.Size(450, 115);
@@ -222,13 +222,13 @@ namespace OpenNest.Forms
statusTable.Controls.Add(descriptionLabel, 0, 2);
statusTable.Controls.Add(descriptionValue, 1, 2);
statusTable.Dock = System.Windows.Forms.DockStyle.Top;
statusTable.Location = new System.Drawing.Point(14, 29);
statusTable.Location = new System.Drawing.Point(14, 33);
statusTable.Name = "statusTable";
statusTable.RowCount = 3;
statusTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
statusTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
statusTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
statusTable.Size = new System.Drawing.Size(422, 57);
statusTable.Size = new System.Drawing.Size(422, 69);
statusTable.TabIndex = 1;
//
// plateLabel
@@ -239,7 +239,7 @@ namespace OpenNest.Forms
plateLabel.Location = new System.Drawing.Point(0, 3);
plateLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
plateLabel.Name = "plateLabel";
plateLabel.Size = new System.Drawing.Size(36, 13);
plateLabel.Size = new System.Drawing.Size(43, 17);
plateLabel.TabIndex = 0;
plateLabel.Text = "Plate:";
//
@@ -247,10 +247,10 @@ namespace OpenNest.Forms
//
plateValue.AutoSize = true;
plateValue.Font = new System.Drawing.Font("Consolas", 9.75F);
plateValue.Location = new System.Drawing.Point(80, 3);
plateValue.Location = new System.Drawing.Point(90, 3);
plateValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
plateValue.Name = "plateValue";
plateValue.Size = new System.Drawing.Size(13, 13);
plateValue.Size = new System.Drawing.Size(13, 15);
plateValue.TabIndex = 1;
plateValue.Text = "<22>";
//
@@ -259,10 +259,10 @@ namespace OpenNest.Forms
elapsedLabel.AutoSize = true;
elapsedLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
elapsedLabel.ForeColor = System.Drawing.Color.FromArgb(51, 51, 51);
elapsedLabel.Location = new System.Drawing.Point(0, 22);
elapsedLabel.Location = new System.Drawing.Point(0, 26);
elapsedLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
elapsedLabel.Name = "elapsedLabel";
elapsedLabel.Size = new System.Drawing.Size(50, 13);
elapsedLabel.Size = new System.Drawing.Size(59, 17);
elapsedLabel.TabIndex = 2;
elapsedLabel.Text = "Elapsed:";
//
@@ -270,10 +270,10 @@ namespace OpenNest.Forms
//
elapsedValue.AutoSize = true;
elapsedValue.Font = new System.Drawing.Font("Consolas", 9.75F);
elapsedValue.Location = new System.Drawing.Point(80, 22);
elapsedValue.Location = new System.Drawing.Point(90, 26);
elapsedValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
elapsedValue.Name = "elapsedValue";
elapsedValue.Size = new System.Drawing.Size(31, 13);
elapsedValue.Size = new System.Drawing.Size(35, 15);
elapsedValue.TabIndex = 3;
elapsedValue.Text = "0:00";
//
@@ -282,10 +282,10 @@ namespace OpenNest.Forms
descriptionLabel.AutoSize = true;
descriptionLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
descriptionLabel.ForeColor = System.Drawing.Color.FromArgb(51, 51, 51);
descriptionLabel.Location = new System.Drawing.Point(0, 41);
descriptionLabel.Location = new System.Drawing.Point(0, 49);
descriptionLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
descriptionLabel.Name = "descriptionLabel";
descriptionLabel.Size = new System.Drawing.Size(40, 13);
descriptionLabel.Size = new System.Drawing.Size(49, 17);
descriptionLabel.TabIndex = 4;
descriptionLabel.Text = "Detail:";
//
@@ -293,10 +293,10 @@ namespace OpenNest.Forms
//
descriptionValue.AutoSize = true;
descriptionValue.Font = new System.Drawing.Font("Segoe UI", 9.75F);
descriptionValue.Location = new System.Drawing.Point(80, 41);
descriptionValue.Location = new System.Drawing.Point(90, 49);
descriptionValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
descriptionValue.Name = "descriptionValue";
descriptionValue.Size = new System.Drawing.Size(18, 13);
descriptionValue.Size = new System.Drawing.Size(20, 17);
descriptionValue.TabIndex = 5;
descriptionValue.Text = "<22>";
//
@@ -309,7 +309,7 @@ namespace OpenNest.Forms
statusHeader.Location = new System.Drawing.Point(14, 10);
statusHeader.Name = "statusHeader";
statusHeader.Padding = new System.Windows.Forms.Padding(0, 0, 0, 4);
statusHeader.Size = new System.Drawing.Size(50, 19);
statusHeader.Size = new System.Drawing.Size(59, 23);
statusHeader.TabIndex = 0;
statusHeader.Text = "STATUS";
//
@@ -320,7 +320,7 @@ namespace OpenNest.Forms
buttonPanel.Controls.Add(acceptButton);
buttonPanel.Dock = System.Windows.Forms.DockStyle.Top;
buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft;
buttonPanel.Location = new System.Drawing.Point(0, 265);
buttonPanel.Location = new System.Drawing.Point(0, 295);
buttonPanel.Name = "buttonPanel";
buttonPanel.Padding = new System.Windows.Forms.Padding(9, 6, 9, 6);
buttonPanel.Size = new System.Drawing.Size(450, 45);

View File

@@ -73,7 +73,7 @@ namespace OpenNest.Forms
descriptionValue.Text = !string.IsNullOrEmpty(progress.Description)
? progress.Description
: FormatPhase(progress.Phase);
: progress.Phase.DisplayName();
}
public void ShowCompleted()
@@ -196,18 +196,5 @@ namespace OpenNest.Forms
return DensityMidColor;
return DensityHighColor;
}
private static string FormatPhase(NestPhase phase)
{
switch (phase)
{
case NestPhase.Linear: return "Trying rotations...";
case NestPhase.RectBestFit: return "Trying best fit...";
case NestPhase.Pairs: return "Trying pairs...";
case NestPhase.Extents: return "Trying extents...";
case NestPhase.Nfp: return "Trying NFP...";
default: return phase.ToString();
}
}
}
}

View File

@@ -1,378 +0,0 @@
# Best-Fit Viewer Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a BestFitViewerForm that shows all pair candidates in a dense 5-column grid with metadata overlay, similar to PEP's best-fit viewer.
**Architecture:** A modal `Form` with a scrollable `TableLayoutPanel` (5 columns). Each cell is a read-only `PlateView` with the pair's two parts placed on it. Metadata is painted as overlay text on each cell. Dropped candidates use a different background color. Invoked from Tools menu when a drawing and plate are available.
**Tech Stack:** WinForms, `BestFitFinder` from `OpenNest.Engine.BestFit`, `PlateView` control.
---
### Task 1: Extract BuildPairParts to a static helper
`NestEngine.BuildPairParts` is private and contains the pair-building logic we need. Extract it to a public static method so both `NestEngine` and the new form can use it.
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs`
**Step 1: Make BuildPairParts internal static**
In `OpenNest.Engine/NestEngine.cs`, change the method signature from private instance to internal static. It doesn't use any instance state — only `BestFitResult` and `Drawing` parameters.
Change:
```csharp
private List<Part> BuildPairParts(BestFitResult bestFit, Drawing drawing)
```
To:
```csharp
internal static List<Part> BuildPairParts(BestFitResult bestFit, Drawing drawing)
```
**Step 2: Build and verify**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds with no errors.
**Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs
git commit -m "refactor: make BuildPairParts internal static for reuse"
```
---
### Task 2: Create BestFitViewerForm
**Files:**
- Create: `OpenNest/Forms/BestFitViewerForm.cs`
- Create: `OpenNest/Forms/BestFitViewerForm.Designer.cs`
**Step 1: Create the Designer file**
Create `OpenNest/Forms/BestFitViewerForm.Designer.cs` with a `TableLayoutPanel` (5 columns, auto-scroll, dock-fill) inside the form. Form should be sizable, start centered on parent, ~1200x800 default size, title "Best-Fit Viewer".
```csharp
namespace OpenNest.Forms
{
partial class BestFitViewerForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
components.Dispose();
base.Dispose(disposing);
}
private void InitializeComponent()
{
this.gridPanel = new System.Windows.Forms.TableLayoutPanel();
this.SuspendLayout();
//
// gridPanel
//
this.gridPanel.AutoScroll = true;
this.gridPanel.ColumnCount = 5;
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill;
this.gridPanel.Location = new System.Drawing.Point(0, 0);
this.gridPanel.Name = "gridPanel";
this.gridPanel.RowCount = 1;
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
this.gridPanel.Size = new System.Drawing.Size(1200, 800);
this.gridPanel.TabIndex = 0;
//
// BestFitViewerForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(1200, 800);
this.Controls.Add(this.gridPanel);
this.KeyPreview = true;
this.Name = "BestFitViewerForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Best-Fit Viewer";
this.ResumeLayout(false);
}
private System.Windows.Forms.TableLayoutPanel gridPanel;
}
}
```
**Step 2: Create the code-behind file**
Create `OpenNest/Forms/BestFitViewerForm.cs`. The constructor takes a `Drawing` and a `Plate`. It calls `BestFitFinder.FindBestFits()` to get all candidates, then for each result:
1. Creates a `PlateView` configured read-only (no pan/zoom/select/origin, no plate outline)
2. Sizes the PlateView's plate to the pair bounding box
3. Builds pair parts via `NestEngine.BuildPairParts()` and adds them to the plate
4. Sets background color based on `Keep` (kept = default, dropped = maroon)
5. Subscribes to `Paint` to overlay metadata text
```csharp
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Forms
{
public partial class BestFitViewerForm : Form
{
private static readonly Color KeptColor = Color.FromArgb(0, 0, 100);
private static readonly Color DroppedColor = Color.FromArgb(100, 0, 0);
public BestFitViewerForm(Drawing drawing, Plate plate)
{
InitializeComponent();
PopulateGrid(drawing, plate);
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (keyData == Keys.Escape)
{
Close();
return true;
}
return base.ProcessCmdKey(ref msg, keyData);
}
private void PopulateGrid(Drawing drawing, Plate plate)
{
var finder = new BestFitFinder(plate.Size.Width, plate.Size.Height);
var results = finder.FindBestFits(drawing, plate.PartSpacing);
var rows = (int)System.Math.Ceiling(results.Count / 5.0);
gridPanel.RowCount = rows;
gridPanel.RowStyles.Clear();
for (var i = 0; i < rows; i++)
gridPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 200));
for (var i = 0; i < results.Count; i++)
{
var result = results[i];
var view = CreateCellView(result, drawing);
gridPanel.Controls.Add(view, i % 5, i / 5);
}
}
private PlateView CreateCellView(BestFitResult result, Drawing drawing)
{
var bgColor = result.Keep ? KeptColor : DroppedColor;
var colorScheme = new ColorScheme
{
BackgroundColor = bgColor,
LayoutOutlineColor = bgColor,
LayoutFillColor = bgColor,
BoundingBoxColor = bgColor,
RapidColor = Color.DodgerBlue,
OriginColor = bgColor,
EdgeSpacingColor = bgColor
};
var view = new PlateView(colorScheme);
view.DrawOrigin = false;
view.DrawBounds = false;
view.AllowPan = false;
view.AllowSelect = false;
view.AllowZoom = false;
view.AllowDrop = false;
view.Dock = DockStyle.Fill;
view.Plate.Size = new Geometry.Size(
result.BoundingWidth,
result.BoundingHeight);
var parts = NestEngine.BuildPairParts(result, drawing);
foreach (var part in parts)
view.Plate.Parts.Add(part);
view.Paint += (sender, e) =>
{
PaintMetadata(e.Graphics, view, result);
};
view.HandleCreated += (sender, e) =>
{
view.ZoomToFit(true);
};
return view;
}
private void PaintMetadata(Graphics g, PlateView view, BestFitResult result)
{
var font = view.Font;
var brush = Brushes.White;
var y = 2f;
var lineHeight = font.GetHeight(g) + 1;
var lines = new[]
{
string.Format("RotatedArea={0:F4}", result.RotatedArea),
string.Format("{0:F4}x{1:F4}={2:F4}",
result.BoundingWidth, result.BoundingHeight, result.RotatedArea),
string.Format("Why={0}", result.Keep ? "0" : result.Reason),
string.Format("Type={0} Test={1} Spacing={2}",
result.Candidate.StrategyType,
result.Candidate.TestNumber,
result.Candidate.Spacing),
string.Format("Util={0:P0} Rot={1:F1}°",
result.Utilization,
Angle.ToDegrees(result.OptimalRotation))
};
foreach (var line in lines)
{
g.DrawString(line, font, brush, 2, y);
y += lineHeight;
}
}
}
}
```
**Step 3: Build and verify**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds.
**Step 4: Commit**
```bash
git add OpenNest/Forms/BestFitViewerForm.cs OpenNest/Forms/BestFitViewerForm.Designer.cs
git commit -m "feat: add BestFitViewerForm with pair candidate grid"
```
---
### Task 3: Add menu item to MainForm
**Files:**
- Modify: `OpenNest/Forms/MainForm.Designer.cs`
- Modify: `OpenNest/Forms/MainForm.cs`
**Step 1: Add the menu item field and wire it up in Designer**
In `MainForm.Designer.cs`:
1. Add field declaration near the other `mnuTools*` fields (~line 1198):
```csharp
private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer;
```
2. Add instantiation in `InitializeComponent()` near other mnuTools instantiations (~line 62):
```csharp
this.mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem();
```
3. Add to the Tools menu `DropDownItems` array (after `mnuToolsMeasureArea`, ~line 413-420). Insert `mnuToolsBestFitViewer` before the `toolStripMenuItem14` separator:
```csharp
this.mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.mnuToolsMeasureArea,
this.mnuToolsBestFitViewer,
this.mnuToolsAlign,
this.toolStripMenuItem14,
this.mnuSetOffsetIncrement,
this.mnuSetRotationIncrement,
this.toolStripMenuItem15,
this.mnuToolsOptions});
```
4. Add menu item configuration after the `mnuToolsMeasureArea` block (~line 431):
```csharp
//
// mnuToolsBestFitViewer
//
this.mnuToolsBestFitViewer.Name = "mnuToolsBestFitViewer";
this.mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22);
this.mnuToolsBestFitViewer.Text = "Best-Fit Viewer";
this.mnuToolsBestFitViewer.Click += new System.EventHandler(this.BestFitViewer_Click);
```
**Step 2: Add the click handler in MainForm.cs**
Add a method to `MainForm.cs` that opens the form. It needs the active `EditNestForm` to get the current plate and a selected drawing. If no drawing is available from the selected plate's parts, show a message.
```csharp
private void BestFitViewer_Click(object sender, EventArgs e)
{
if (activeForm == null)
return;
var plate = activeForm.PlateView.Plate;
var drawing = activeForm.Nest.Drawings.Count > 0
? activeForm.Nest.Drawings[0]
: null;
if (drawing == null)
{
MessageBox.Show("No drawings available.", "Best-Fit Viewer",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
using (var form = new BestFitViewerForm(drawing, plate))
{
form.ShowDialog(this);
}
}
```
**Step 3: Build and verify**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds.
**Step 4: Commit**
```bash
git add OpenNest/Forms/MainForm.Designer.cs OpenNest/Forms/MainForm.cs
git commit -m "feat: add Best-Fit Viewer menu item under Tools"
```
---
### Task 4: Manual smoke test
**Step 1: Run the application**
Run: `dotnet run --project OpenNest`
**Step 2: Test the flow**
1. Open or create a nest file
2. Import a DXF drawing
3. Go to Tools > Best-Fit Viewer
4. Verify the grid appears with pair candidates
5. Verify kept candidates have dark blue background
6. Verify dropped candidates have dark red/maroon background
7. Verify metadata text is readable on each cell
8. Verify ESC closes the dialog
9. Verify scroll works when many results exist
**Step 3: Fix any visual issues**
Adjust cell heights, font sizes, or zoom-to-fit timing if needed.
**Step 4: Final commit**
```bash
git add -A
git commit -m "fix: polish BestFitViewerForm layout and appearance"
```

View File

@@ -1,963 +0,0 @@
# Best-Fit Pair Finding Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a pair-finding engine that arranges two copies of a part in the tightest configuration, then tiles that pair across a plate.
**Architecture:** Strategy pattern where `RotationSlideStrategy` instances (parameterized by angle) generate candidate pair configurations by sliding one part against another using existing raycast collision. A `PairEvaluator` scores candidates by bounding area, a `BestFitFilter` prunes bad fits, and a `TileEvaluator` simulates tiling the best pairs onto a plate.
**Tech Stack:** .NET Framework 4.8, C# 7.3, OpenNest.Engine (class library referencing OpenNest.Core)
---
## Important Context
### Codebase Conventions
- **All angles are in radians** — use `Angle.ToRadians()`, `Angle.HalfPI`, `Angle.TwoPI`
- **Always use `var`** instead of explicit types
- **`OpenNest.Math` shadows `System.Math`** — use `System.Math` fully qualified
- **Legacy `.csproj`** — every new `.cs` file must be added to `OpenNest.Engine.csproj` `<Compile>` items
- **No test project exists** — skip TDD steps, verify by building
### Key Existing Types
- `Vector` (struct, `OpenNest.Geometry`) — 2D point, has `Rotate()`, `Offset()`, `DistanceTo()`, operators
- `Box` (class, `OpenNest.Geometry`) — AABB with `Left/Right/Top/Bottom/Width/Height`, `Contains()`, `Intersects()`
- `Part` (class, `OpenNest`) — wraps `Drawing` + `Program`, has `Location`, `Rotation`, `Rotate()`, `Offset()`, `Clone()`, `BoundingBox`
- `Drawing` (class, `OpenNest`) — has `Program`, `Area`, `Name`
- `Program` (class, `OpenNest.CNC`) — G-code program, has `BoundingBox()`, `Rotate()`, `Clone()`
- `Plate` (class, `OpenNest`) — has `Size` (Width/Height), `EdgeSpacing`, `PartSpacing`, `WorkArea()`
- `Shape` (class, `OpenNest.Geometry`) — closed contour, has `Intersects(Shape)`, `Area()`, `ToPolygon()`, `OffsetEntity()`
- `Polygon` (class, `OpenNest.Geometry`) — vertex list, has `FindBestRotation()`, `Rotate()`, `Offset()`
- `ConvexHull.Compute(IList<Vector>)` — returns closed `Polygon`
- `BoundingRectangleResult``Angle`, `Width`, `Height`, `Area` from rotating calipers
### Key Existing Methods (in `Helper`)
- `Helper.GetShapes(IEnumerable<Entity>)` — builds `Shape` list from geometry entities
- `Helper.GetPartLines(Part, PushDirection)` — gets polygon edges facing a direction (uses chord tolerance 0.01)
- `Helper.DirectionalDistance(movingLines, stationaryLines, PushDirection)` — raycasts to find minimum contact distance
- `Helper.OppositeDirection(PushDirection)` — flips direction
- `ConvertProgram.ToGeometry(Program)` — converts CNC program to geometry entities
### How Existing Push/Contact Works (in `FillLinear`)
```
1. Create partA at position
2. Clone to partB, offset by bounding box dimension along axis
3. Get facing lines: movingLines = GetPartLines(partB, pushDir)
4. Get facing lines: stationaryLines = GetPartLines(partA, oppositeDir)
5. slideDistance = DirectionalDistance(movingLines, stationaryLines, pushDir)
6. copyDistance = bboxDim - slideDistance + spacing
```
The best-fit system adapts this: part2 is rotated, offset perpendicular to the push axis, then pushed toward part1.
### Hull Edge Angles (existing pattern in `NestEngine`)
```
1. Convert part to polygon via ConvertProgram.ToGeometry → GetShapes → ToPolygonWithTolerance
2. Compute convex hull via ConvexHull.Compute(vertices)
3. Extract edge angles: atan2(dy, dx) for each hull edge
4. Deduplicate angles (within Tolerance.Epsilon)
```
---
## Task 1: PairCandidate Data Class
**Files:**
- Create: `OpenNest.Engine/BestFit/PairCandidate.cs`
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` (add Compile entry)
**Step 1: Create directory and file**
```csharp
using OpenNest.Geometry;
namespace OpenNest.Engine.BestFit
{
public class PairCandidate
{
public Drawing Drawing { get; set; }
public double Part1Rotation { get; set; }
public double Part2Rotation { get; set; }
public Vector Part2Offset { get; set; }
public int StrategyType { get; set; }
public int TestNumber { get; set; }
public double Spacing { get; set; }
}
}
```
**Step 2: Add to .csproj**
Add inside the `<ItemGroup>` that contains `<Compile>` entries, before `</ItemGroup>`:
```xml
<Compile Include="BestFit\PairCandidate.cs" />
```
**Step 3: Build to verify**
Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q`
Expected: Build succeeded
**Step 4: Commit**
```
feat: add PairCandidate data class for best-fit pair finding
```
---
## Task 2: BestFitResult Data Class
**Files:**
- Create: `OpenNest.Engine/BestFit/BestFitResult.cs`
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
**Step 1: Create file**
```csharp
namespace OpenNest.Engine.BestFit
{
public class BestFitResult
{
public PairCandidate Candidate { get; set; }
public double RotatedArea { get; set; }
public double BoundingWidth { get; set; }
public double BoundingHeight { get; set; }
public double OptimalRotation { get; set; }
public bool Keep { get; set; }
public string Reason { get; set; }
public double TrueArea { get; set; }
public double Utilization
{
get { return RotatedArea > 0 ? TrueArea / RotatedArea : 0; }
}
public double LongestSide
{
get { return System.Math.Max(BoundingWidth, BoundingHeight); }
}
public double ShortestSide
{
get { return System.Math.Min(BoundingWidth, BoundingHeight); }
}
}
public enum BestFitSortField
{
Area,
LongestSide,
ShortestSide,
Type,
OriginalSequence,
Keep,
WhyKeepDrop
}
}
```
**Step 2: Add to .csproj**
```xml
<Compile Include="BestFit\BestFitResult.cs" />
```
**Step 3: Build to verify**
Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q`
**Step 4: Commit**
```
feat: add BestFitResult data class and BestFitSortField enum
```
---
## Task 3: IBestFitStrategy Interface
**Files:**
- Create: `OpenNest.Engine/BestFit/IBestFitStrategy.cs`
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
**Step 1: Create file**
```csharp
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public interface IBestFitStrategy
{
int Type { get; }
string Description { get; }
List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
}
}
```
**Step 2: Add to .csproj**
```xml
<Compile Include="BestFit\IBestFitStrategy.cs" />
```
**Step 3: Build to verify**
**Step 4: Commit**
```
feat: add IBestFitStrategy interface
```
---
## Task 4: RotationSlideStrategy
This is the core algorithm. It generates pair candidates by:
1. Creating part1 at origin
2. Creating part2 with a specific rotation
3. For each push direction (Left, Down):
- For each perpendicular offset (stepping across the part):
- Place part2 far away along the push axis
- Use `DirectionalDistance` to find contact
- Record position as a candidate
**Files:**
- Create: `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
**Step 1: Create file**
```csharp
using System;
using System.Collections.Generic;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.BestFit
{
public class RotationSlideStrategy : IBestFitStrategy
{
private const double ChordTolerance = 0.01;
public RotationSlideStrategy(double part2Rotation, int type, string description)
{
Part2Rotation = part2Rotation;
Type = type;
Description = description;
}
public double Part2Rotation { get; }
public int Type { get; }
public string Description { get; }
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
{
var candidates = new List<PairCandidate>();
var part1 = new Part(drawing);
var bbox1 = part1.Program.BoundingBox();
part1.Offset(-bbox1.Location.X, -bbox1.Location.Y);
part1.UpdateBounds();
var part2Template = new Part(drawing);
if (!Part2Rotation.IsEqualTo(0))
part2Template.Rotate(Part2Rotation);
var bbox2 = part2Template.Program.BoundingBox();
part2Template.Offset(-bbox2.Location.X, -bbox2.Location.Y);
part2Template.UpdateBounds();
var testNumber = 0;
// Slide along horizontal axis (push left toward part1)
GenerateCandidatesForAxis(
part1, part2Template, drawing, spacing, stepSize,
PushDirection.Left, candidates, ref testNumber);
// Slide along vertical axis (push down toward part1)
GenerateCandidatesForAxis(
part1, part2Template, drawing, spacing, stepSize,
PushDirection.Down, candidates, ref testNumber);
return candidates;
}
private void GenerateCandidatesForAxis(
Part part1, Part part2Template, Drawing drawing,
double spacing, double stepSize, PushDirection pushDir,
List<PairCandidate> candidates, ref int testNumber)
{
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
// Determine perpendicular range based on push direction
double perpMin, perpMax, pushStartOffset;
bool isHorizontalPush = (pushDir == PushDirection.Left || pushDir == PushDirection.Right);
if (isHorizontalPush)
{
// Pushing horizontally: perpendicular axis is Y
perpMin = -(bbox2.Height + spacing);
perpMax = bbox1.Height + bbox2.Height + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
}
else
{
// Pushing vertically: perpendicular axis is X
perpMin = -(bbox2.Width + spacing);
perpMax = bbox1.Width + bbox2.Width + spacing;
pushStartOffset = bbox1.Height + bbox2.Height + spacing * 2;
}
var part1Lines = Helper.GetOffsetPartLines(part1, spacing / 2);
var opposite = Helper.OppositeDirection(pushDir);
for (var offset = perpMin; offset <= perpMax; offset += stepSize)
{
var part2 = (Part)part2Template.Clone();
if (isHorizontalPush)
part2.Offset(pushStartOffset, offset);
else
part2.Offset(offset, pushStartOffset);
var movingLines = Helper.GetOffsetPartLines(part2, spacing / 2);
var slideDist = Helper.DirectionalDistance(movingLines, part1Lines, pushDir);
if (slideDist >= double.MaxValue || slideDist < 0)
continue;
// Move part2 to contact position
var contactOffset = GetPushVector(pushDir, slideDist);
var finalPosition = part2.Location + contactOffset;
candidates.Add(new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = Part2Rotation,
Part2Offset = finalPosition,
StrategyType = Type,
TestNumber = testNumber++,
Spacing = spacing
});
}
}
private static Vector GetPushVector(PushDirection direction, double distance)
{
switch (direction)
{
case PushDirection.Left: return new Vector(-distance, 0);
case PushDirection.Right: return new Vector(distance, 0);
case PushDirection.Down: return new Vector(0, -distance);
case PushDirection.Up: return new Vector(0, distance);
default: return Vector.Zero;
}
}
}
}
```
**Step 2: Add to .csproj**
```xml
<Compile Include="BestFit\RotationSlideStrategy.cs" />
```
**Step 3: Build to verify**
Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q`
**Step 4: Commit**
```
feat: add RotationSlideStrategy with directional push contact algorithm
```
---
## Task 5: PairEvaluator
Scores each candidate by computing the combined bounding box, finding the optimal rotation (via rotating calipers on the convex hull), and checking for overlaps.
**Files:**
- Create: `OpenNest.Engine/BestFit/PairEvaluator.cs`
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
**Step 1: Create file**
```csharp
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Engine.BestFit
{
public class PairEvaluator
{
private const double ChordTolerance = 0.01;
public BestFitResult Evaluate(PairCandidate candidate)
{
var drawing = candidate.Drawing;
// Build part1 at origin
var part1 = new Part(drawing);
var bbox1 = part1.Program.BoundingBox();
part1.Offset(-bbox1.Location.X, -bbox1.Location.Y);
part1.UpdateBounds();
// Build part2 with rotation and offset
var part2 = new Part(drawing);
if (!candidate.Part2Rotation.IsEqualTo(0))
part2.Rotate(candidate.Part2Rotation);
var bbox2 = part2.Program.BoundingBox();
part2.Offset(-bbox2.Location.X, -bbox2.Location.Y);
part2.Location = candidate.Part2Offset;
part2.UpdateBounds();
// Check overlap via shape intersection
var overlaps = CheckOverlap(part1, part2, candidate.Spacing);
// Collect all polygon vertices for convex hull / optimal rotation
var allPoints = GetPartVertices(part1);
allPoints.AddRange(GetPartVertices(part2));
// Find optimal bounding rectangle via rotating calipers
double bestArea, bestWidth, bestHeight, bestRotation;
if (allPoints.Count >= 3)
{
var hull = ConvexHull.Compute(allPoints);
var result = RotatingCalipers.MinimumBoundingRectangle(hull);
bestArea = result.Area;
bestWidth = result.Width;
bestHeight = result.Height;
bestRotation = result.Angle;
}
else
{
var combinedBox = ((IEnumerable<IBoundable>)new[] { part1, part2 }).GetBoundingBox();
bestArea = combinedBox.Area();
bestWidth = combinedBox.Width;
bestHeight = combinedBox.Height;
bestRotation = 0;
}
var trueArea = drawing.Area * 2;
return new BestFitResult
{
Candidate = candidate,
RotatedArea = bestArea,
BoundingWidth = bestWidth,
BoundingHeight = bestHeight,
OptimalRotation = bestRotation,
TrueArea = trueArea,
Keep = !overlaps,
Reason = overlaps ? "Overlap detected" : "Valid"
};
}
private bool CheckOverlap(Part part1, Part part2, double spacing)
{
var shapes1 = GetPartShapes(part1);
var shapes2 = GetPartShapes(part2);
for (var i = 0; i < shapes1.Count; i++)
{
for (var j = 0; j < shapes2.Count; j++)
{
List<Vector> pts;
if (shapes1[i].Intersects(shapes2[j], out pts))
return true;
}
}
return false;
}
private List<Shape> GetPartShapes(Part part)
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
shapes.ForEach(s => s.Offset(part.Location));
return shapes;
}
private List<Vector> GetPartVertices(Part part)
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var points = new List<Vector>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
polygon.Offset(part.Location);
foreach (var vertex in polygon.Vertices)
points.Add(vertex);
}
return points;
}
}
}
```
**Step 2: Add to .csproj**
```xml
<Compile Include="BestFit\PairEvaluator.cs" />
```
**Step 3: Build to verify**
**Step 4: Commit**
```
feat: add PairEvaluator with overlap detection and optimal rotation
```
---
## Task 6: BestFitFilter
**Files:**
- Create: `OpenNest.Engine/BestFit/BestFitFilter.cs`
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
**Step 1: Create file**
```csharp
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public class BestFitFilter
{
public double MaxPlateWidth { get; set; }
public double MaxPlateHeight { get; set; }
public double MaxAspectRatio { get; set; } = 5.0;
public double MinUtilization { get; set; } = 0.3;
public void Apply(List<BestFitResult> results)
{
foreach (var result in results)
{
if (!result.Keep)
continue;
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight))
{
result.Keep = false;
result.Reason = "Exceeds plate dimensions";
continue;
}
var aspect = result.LongestSide / result.ShortestSide;
if (aspect > MaxAspectRatio)
{
result.Keep = false;
result.Reason = string.Format("Aspect ratio {0:F1} exceeds max {1}", aspect, MaxAspectRatio);
continue;
}
if (result.Utilization < MinUtilization)
{
result.Keep = false;
result.Reason = string.Format("Utilization {0:P0} below minimum", result.Utilization);
continue;
}
result.Reason = "Valid";
}
}
}
}
```
**Step 2: Add to .csproj**
```xml
<Compile Include="BestFit\BestFitFilter.cs" />
```
**Step 3: Build to verify**
**Step 4: Commit**
```
feat: add BestFitFilter with plate size, aspect ratio, and utilization rules
```
---
## Task 7: TileResult and TileEvaluator
**Files:**
- Create: `OpenNest.Engine/BestFit/Tiling/TileResult.cs`
- Create: `OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs`
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
**Step 1: Create TileResult.cs**
```csharp
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.BestFit.Tiling
{
public class TileResult
{
public BestFitResult BestFit { get; set; }
public int PairsNested { get; set; }
public int PartsNested { get; set; }
public int Rows { get; set; }
public int Columns { get; set; }
public double Utilization { get; set; }
public List<PairPlacement> Placements { get; set; }
public bool PairRotated { get; set; }
}
public class PairPlacement
{
public Vector Position { get; set; }
public double PairRotation { get; set; }
}
}
```
**Step 2: Create TileEvaluator.cs**
```csharp
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.BestFit.Tiling
{
public class TileEvaluator
{
public TileResult Evaluate(BestFitResult bestFit, Plate plate)
{
var plateWidth = plate.Size.Width - plate.EdgeSpacing.Left - plate.EdgeSpacing.Right;
var plateHeight = plate.Size.Height - plate.EdgeSpacing.Top - plate.EdgeSpacing.Bottom;
var result1 = TryTile(bestFit, plateWidth, plateHeight, false);
var result2 = TryTile(bestFit, plateWidth, plateHeight, true);
return result1.PartsNested >= result2.PartsNested ? result1 : result2;
}
private TileResult TryTile(BestFitResult bestFit, double plateWidth, double plateHeight, bool rotatePair)
{
var pairWidth = rotatePair ? bestFit.BoundingHeight : bestFit.BoundingWidth;
var pairHeight = rotatePair ? bestFit.BoundingWidth : bestFit.BoundingHeight;
var spacing = bestFit.Candidate.Spacing;
var cols = (int)System.Math.Floor((plateWidth + spacing) / (pairWidth + spacing));
var rows = (int)System.Math.Floor((plateHeight + spacing) / (pairHeight + spacing));
var pairsNested = cols * rows;
var partsNested = pairsNested * 2;
var usedArea = partsNested * (bestFit.TrueArea / 2);
var plateArea = plateWidth * plateHeight;
var placements = new List<PairPlacement>();
for (var row = 0; row < rows; row++)
{
for (var col = 0; col < cols; col++)
{
placements.Add(new PairPlacement
{
Position = new Vector(
col * (pairWidth + spacing),
row * (pairHeight + spacing)),
PairRotation = rotatePair ? Angle.HalfPI : 0
});
}
}
return new TileResult
{
BestFit = bestFit,
PairsNested = pairsNested,
PartsNested = partsNested,
Rows = rows,
Columns = cols,
Utilization = plateArea > 0 ? usedArea / plateArea : 0,
Placements = placements,
PairRotated = rotatePair
};
}
}
}
```
**Step 3: Add to .csproj**
```xml
<Compile Include="BestFit\Tiling\TileResult.cs" />
<Compile Include="BestFit\Tiling\TileEvaluator.cs" />
```
**Step 4: Build to verify**
**Step 5: Commit**
```
feat: add TileEvaluator and TileResult for pair tiling on plates
```
---
## Task 8: BestFitFinder (Orchestrator)
Computes hull edge angles from the drawing, builds `RotationSlideStrategy` instances for each angle in `{0, pi/2, pi, 3pi/2} + hull edges + hull edges + pi`, runs all strategies, evaluates, filters, and sorts.
**Files:**
- Create: `OpenNest.Engine/BestFit/BestFitFinder.cs`
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
**Step 1: Create file**
```csharp
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Engine.BestFit.Tiling;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.BestFit
{
public class BestFitFinder
{
private readonly PairEvaluator _evaluator;
private readonly BestFitFilter _filter;
public BestFitFinder(double maxPlateWidth, double maxPlateHeight)
{
_evaluator = new PairEvaluator();
_filter = new BestFitFilter
{
MaxPlateWidth = maxPlateWidth,
MaxPlateHeight = maxPlateHeight
};
}
public List<BestFitResult> FindBestFits(
Drawing drawing,
double spacing = 0.25,
double stepSize = 0.25,
BestFitSortField sortBy = BestFitSortField.Area)
{
var strategies = BuildStrategies(drawing);
var allCandidates = new List<PairCandidate>();
foreach (var strategy in strategies)
allCandidates.AddRange(strategy.GenerateCandidates(drawing, spacing, stepSize));
var results = allCandidates.Select(c => _evaluator.Evaluate(c)).ToList();
_filter.Apply(results);
results = SortResults(results, sortBy);
for (var i = 0; i < results.Count; i++)
results[i].Candidate.TestNumber = i;
return results;
}
public List<TileResult> FindAndTile(
Drawing drawing, Plate plate,
double spacing = 0.25, double stepSize = 0.25, int topN = 10)
{
var bestFits = FindBestFits(drawing, spacing, stepSize);
var tileEvaluator = new TileEvaluator();
return bestFits
.Where(r => r.Keep)
.Take(topN)
.Select(r => tileEvaluator.Evaluate(r, plate))
.OrderByDescending(t => t.PartsNested)
.ThenByDescending(t => t.Utilization)
.ToList();
}
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List<IBestFitStrategy>();
var type = 1;
foreach (var angle in angles)
{
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
strategies.Add(new RotationSlideStrategy(angle, type++, desc));
}
return strategies;
}
private List<double> GetRotationAngles(Drawing drawing)
{
var angles = new List<double>
{
0,
Angle.HalfPI,
System.Math.PI,
Angle.HalfPI * 3
};
// Add hull edge angles
var hullAngles = GetHullEdgeAngles(drawing);
foreach (var hullAngle in hullAngles)
{
AddUniqueAngle(angles, hullAngle);
AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI));
}
return angles;
}
private List<double> GetHullEdgeAngles(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var points = new List<Vector>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(0.1);
points.AddRange(polygon.Vertices);
}
if (points.Count < 3)
return new List<double>();
var hull = ConvexHull.Compute(points);
var vertices = hull.Vertices;
var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
var hullAngles = new List<double>();
for (var i = 0; i < n; i++)
{
var next = (i + 1) % n;
var dx = vertices[next].X - vertices[i].X;
var dy = vertices[next].Y - vertices[i].Y;
if (dx * dx + dy * dy < Tolerance.Epsilon)
continue;
var angle = Angle.NormalizeRad(System.Math.Atan2(dy, dx));
AddUniqueAngle(hullAngles, angle);
}
return hullAngles;
}
private static void AddUniqueAngle(List<double> angles, double angle)
{
angle = Angle.NormalizeRad(angle);
foreach (var existing in angles)
{
if (existing.IsEqualTo(angle))
return;
}
angles.Add(angle);
}
private List<BestFitResult> SortResults(List<BestFitResult> results, BestFitSortField sortBy)
{
switch (sortBy)
{
case BestFitSortField.Area:
return results.OrderBy(r => r.RotatedArea).ToList();
case BestFitSortField.LongestSide:
return results.OrderBy(r => r.LongestSide).ToList();
case BestFitSortField.ShortestSide:
return results.OrderBy(r => r.ShortestSide).ToList();
case BestFitSortField.Type:
return results.OrderBy(r => r.Candidate.StrategyType)
.ThenBy(r => r.Candidate.TestNumber).ToList();
case BestFitSortField.OriginalSequence:
return results.OrderBy(r => r.Candidate.TestNumber).ToList();
case BestFitSortField.Keep:
return results.OrderByDescending(r => r.Keep)
.ThenBy(r => r.RotatedArea).ToList();
case BestFitSortField.WhyKeepDrop:
return results.OrderBy(r => r.Reason)
.ThenBy(r => r.RotatedArea).ToList();
default:
return results;
}
}
}
}
```
**Step 2: Add to .csproj**
```xml
<Compile Include="BestFit\BestFitFinder.cs" />
```
**Step 3: Build full solution to verify all references resolve**
Run: `msbuild OpenNest.sln /p:Configuration=Debug /v:q`
**Step 4: Commit**
```
feat: add BestFitFinder orchestrator with hull edge angle strategies
```
---
## Task 9: Final Integration Build and Smoke Test
**Step 1: Clean build of entire solution**
Run: `msbuild OpenNest.sln /t:Rebuild /p:Configuration=Debug /v:q`
Expected: Build succeeded, 0 errors
**Step 2: Verify all new files are included**
Check that all 8 new files appear in the build output by reviewing the .csproj has these entries:
```xml
<Compile Include="BestFit\PairCandidate.cs" />
<Compile Include="BestFit\BestFitResult.cs" />
<Compile Include="BestFit\IBestFitStrategy.cs" />
<Compile Include="BestFit\RotationSlideStrategy.cs" />
<Compile Include="BestFit\PairEvaluator.cs" />
<Compile Include="BestFit\BestFitFilter.cs" />
<Compile Include="BestFit\Tiling\TileResult.cs" />
<Compile Include="BestFit\Tiling\TileEvaluator.cs" />
<Compile Include="BestFit\BestFitFinder.cs" />
```
**Step 3: Final commit**
If any build fixes were needed, commit them:
```
fix: resolve build issues in best-fit pair finding engine
```

View File

@@ -1,76 +0,0 @@
# GPU Bitmap Best Fit Evaluation
## Overview
Add GPU-accelerated bitmap-based overlap testing to the best fit pair evaluation pipeline using ILGPU. Parts are rasterized to integer grids; overlap detection becomes cell comparison on the GPU. Runs alongside the existing geometry-based evaluator, selectable via flag.
## Architecture
New project `OpenNest.Gpu` (class library, `net8.0-windows`). References `OpenNest.Core` and `OpenNest.Engine`. NuGet: `ILGPU`, `ILGPU.Algorithms`.
## Components
### 1. `Polygon.ContainsPoint(Vector pt)` (Core)
Ray-cast from point rightward past bounding box. Count edge intersections with polygon segments. Odd = inside, even = outside.
### 2. `PartBitmap` (OpenNest.Gpu)
- Rasterizes a `Drawing` to `int[]` grid
- Pipeline: `ConvertProgram.ToGeometry()` -> `Helper.GetShapes()` -> `Shape.ToPolygonWithTolerance()` -> `Polygon.ContainsPoint()` per cell center
- Dilates filled cells by `spacing / 2 / cellSize` pixels to bake in part spacing
- Default cell size: 0.05"
- Cached per drawing (rasterize once, reuse across all candidates)
### 3. `IPairEvaluator` (Engine)
```csharp
interface IPairEvaluator
{
List<BestFitResult> EvaluateAll(List<PairCandidate> candidates);
}
```
- `PairEvaluator` — existing geometry path (CPU parallel)
- `GpuPairEvaluator` — bitmap path (GPU batch)
### 4. `GpuPairEvaluator` (OpenNest.Gpu)
- Constructor takes `Drawing`, `cellSize`, `spacing`. Rasterizes `PartBitmap` once.
- `EvaluateAll()` uploads bitmap + candidate params to GPU, one kernel per candidate
- Kernel: for each cell, transform to part2 space (rotation + offset), check overlap, track bounding extent
- Results: overlap count (0 = valid), bounding width/height from min/max occupied cells
- `IDisposable` — owns ILGPU `Context` + `Accelerator`
### 5. `BestFitFinder` modification (Engine)
- Constructor accepts optional `IPairEvaluator`
- Falls back to `PairEvaluator` if none provided
- Candidate generation (strategies, rotation angles, slide) unchanged
- Calls `IPairEvaluator.EvaluateAll(candidates)` instead of inline `Parallel.ForEach`
### 6. Integration in `NestEngine`
- `FillWithPairs()` creates finder with either evaluator based on `UseGpu` flag
- UI layer toggles the flag
## Data Flow
```
Drawing -> PartBitmap (rasterize once, dilate for spacing)
|
Strategies -> PairCandidates[] (rotation angles x slide offsets)
|
GpuPairEvaluator.EvaluateAll():
- Upload bitmap + candidate float4[] to GPU
- Kernel per candidate: overlap check + bounding box
- Download results
|
BestFitFilter -> sort -> BestFitResults
```
## Unchanged
- `RotationSlideStrategy` and candidate generation
- `BestFitFilter`, `BestFitResult`, `TileEvaluator`
- `NestEngine.FillWithPairs()` flow (just swaps evaluator)

View File

@@ -1,769 +0,0 @@
# GPU Bitmap Best Fit Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add GPU-accelerated bitmap overlap testing to the best fit pair evaluator using ILGPU, alongside the existing geometry evaluator.
**Architecture:** New `OpenNest.Gpu` project holds `PartBitmap` and `GpuPairEvaluator`. Engine gets an `IPairEvaluator` interface that both geometry and GPU paths implement. `BestFitFinder` accepts the interface; `NestEngine` selects which evaluator via a `UseGpu` flag.
**Tech Stack:** .NET 8, ILGPU 1.5+, ILGPU.Algorithms
---
### Task 1: Add `Polygon.ContainsPoint` to Core
**Files:**
- Modify: `OpenNest.Core/Geometry/Polygon.cs:610` (before closing brace)
**Step 1: Add ContainsPoint method**
Insert before the closing `}` of the `Polygon` class (line 611):
```csharp
public bool ContainsPoint(Vector pt)
{
var n = IsClosed() ? Vertices.Count - 1 : Vertices.Count;
if (n < 3)
return false;
var inside = false;
for (var i = 0, j = n - 1; i < n; j = i++)
{
var vi = Vertices[i];
var vj = Vertices[j];
if ((vi.Y > pt.Y) != (vj.Y > pt.Y) &&
pt.X < (vj.X - vi.X) * (pt.Y - vi.Y) / (vj.Y - vi.Y) + vi.X)
{
inside = !inside;
}
}
return inside;
}
```
This is the standard even-odd ray casting algorithm. Casts a ray rightward from `pt`, toggles `inside` at each edge crossing.
**Step 2: Build to verify**
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
Expected: Build succeeded
**Step 3: Commit**
```bash
git add OpenNest.Core/Geometry/Polygon.cs
git commit -m "feat: add Polygon.ContainsPoint using ray casting"
```
---
### Task 2: Extract `IPairEvaluator` interface in Engine
**Files:**
- Create: `OpenNest.Engine/BestFit/IPairEvaluator.cs`
- Modify: `OpenNest.Engine/BestFit/PairEvaluator.cs`
**Step 1: Create the interface**
```csharp
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public interface IPairEvaluator
{
List<BestFitResult> EvaluateAll(List<PairCandidate> candidates);
}
}
```
**Step 2: Make `PairEvaluator` implement the interface**
In `PairEvaluator.cs`, change the class declaration (line 9) to:
```csharp
public class PairEvaluator : IPairEvaluator
```
Add the `EvaluateAll` method. This wraps the existing per-candidate `Evaluate` in a `Parallel.ForEach`, matching the current behavior in `BestFitFinder.FindBestFits()`:
```csharp
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
var resultBag = new System.Collections.Concurrent.ConcurrentBag<BestFitResult>();
System.Threading.Tasks.Parallel.ForEach(candidates, c =>
{
resultBag.Add(Evaluate(c));
});
return resultBag.ToList();
}
```
Add `using System.Linq;` if not already present (it is — line 2).
**Step 3: Update `BestFitFinder` to use `IPairEvaluator`**
In `BestFitFinder.cs`:
Change the field and constructor to accept an optional evaluator:
```csharp
public class BestFitFinder
{
private readonly IPairEvaluator _evaluator;
private readonly BestFitFilter _filter;
public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null)
{
_evaluator = evaluator ?? new PairEvaluator();
_filter = new BestFitFilter
{
MaxPlateWidth = maxPlateWidth,
MaxPlateHeight = maxPlateHeight
};
}
```
Replace the evaluation `Parallel.ForEach` block in `FindBestFits()` (lines 44-52) with:
```csharp
var results = _evaluator.EvaluateAll(allCandidates);
```
Remove the `ConcurrentBag<BestFitResult>` and the second `Parallel.ForEach` — those lines (44-52) are fully replaced by the single call above.
**Step 4: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
**Step 5: Build full solution to verify nothing broke**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded (NestEngine still creates BestFitFinder with 2 args — still valid)
**Step 6: Commit**
```bash
git add OpenNest.Engine/BestFit/IPairEvaluator.cs OpenNest.Engine/BestFit/PairEvaluator.cs OpenNest.Engine/BestFit/BestFitFinder.cs
git commit -m "refactor: extract IPairEvaluator interface from PairEvaluator"
```
---
### Task 3: Create `OpenNest.Gpu` project with `PartBitmap`
**Files:**
- Create: `OpenNest.Gpu/OpenNest.Gpu.csproj`
- Create: `OpenNest.Gpu/PartBitmap.cs`
- Modify: `OpenNest.sln` (add project)
**Step 1: Create project**
```bash
cd "C:\Users\aisaacs\Desktop\Projects\OpenNest"
dotnet new classlib -n OpenNest.Gpu --framework net8.0-windows
rm OpenNest.Gpu/Class1.cs
dotnet sln OpenNest.sln add OpenNest.Gpu/OpenNest.Gpu.csproj
```
**Step 2: Edit csproj**
Replace the generated `OpenNest.Gpu.csproj` with:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Gpu</RootNamespace>
<AssemblyName>OpenNest.Gpu</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ILGPU" Version="1.5.1" />
<PackageReference Include="ILGPU.Algorithms" Version="1.5.1" />
</ItemGroup>
</Project>
```
**Step 3: Create `PartBitmap.cs`**
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Gpu
{
public class PartBitmap
{
public int[] Cells { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public double CellSize { get; set; }
public double OriginX { get; set; }
public double OriginY { get; set; }
public static PartBitmap FromDrawing(Drawing drawing, double cellSize, double spacingDilation = 0)
{
var polygons = GetClosedPolygons(drawing);
if (polygons.Count == 0)
return new PartBitmap { Cells = Array.Empty<int>(), Width = 0, Height = 0, CellSize = cellSize };
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var poly in polygons)
{
poly.UpdateBounds();
var bb = poly.BoundingBox;
if (bb.Left < minX) minX = bb.Left;
if (bb.Bottom < minY) minY = bb.Bottom;
if (bb.Right > maxX) maxX = bb.Right;
if (bb.Top > maxY) maxY = bb.Top;
}
// Expand bounds by dilation amount
minX -= spacingDilation;
minY -= spacingDilation;
maxX += spacingDilation;
maxY += spacingDilation;
var width = (int)System.Math.Ceiling((maxX - minX) / cellSize);
var height = (int)System.Math.Ceiling((maxY - minY) / cellSize);
if (width <= 0 || height <= 0)
return new PartBitmap { Cells = Array.Empty<int>(), Width = 0, Height = 0, CellSize = cellSize };
var cells = new int[width * height];
var dilationCells = (int)System.Math.Ceiling(spacingDilation / cellSize);
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var px = minX + (x + 0.5) * cellSize;
var py = minY + (y + 0.5) * cellSize;
var pt = new Vector(px, py);
foreach (var poly in polygons)
{
if (poly.ContainsPoint(pt))
{
cells[y * width + x] = 1;
break;
}
}
}
}
// Dilate: expand filled cells outward by dilationCells
if (dilationCells > 0)
Dilate(cells, width, height, dilationCells);
return new PartBitmap
{
Cells = cells,
Width = width,
Height = height,
CellSize = cellSize,
OriginX = minX,
OriginY = minY
};
}
private static List<Polygon> GetClosedPolygons(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var polygons = new List<Polygon>();
foreach (var shape in shapes)
{
if (!shape.IsClosed())
continue;
var polygon = shape.ToPolygonWithTolerance(0.05);
polygon.Close();
polygons.Add(polygon);
}
return polygons;
}
private static void Dilate(int[] cells, int width, int height, int radius)
{
var source = (int[])cells.Clone();
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
if (source[y * width + x] != 1)
continue;
for (var dy = -radius; dy <= radius; dy++)
{
for (var dx = -radius; dx <= radius; dx++)
{
var nx = x + dx;
var ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height)
cells[ny * width + nx] = 1;
}
}
}
}
}
}
}
```
**Step 4: Build**
Run: `dotnet build OpenNest.Gpu/OpenNest.Gpu.csproj`
Expected: Build succeeded (ILGPU NuGet restored)
**Step 5: Commit**
```bash
git add OpenNest.Gpu/ OpenNest.sln
git commit -m "feat: add OpenNest.Gpu project with PartBitmap rasterizer"
```
---
### Task 4: Implement `GpuPairEvaluator` with ILGPU kernel
**Files:**
- Create: `OpenNest.Gpu/GpuPairEvaluator.cs`
**Step 1: Create the evaluator**
```csharp
using System;
using System.Collections.Generic;
using ILGPU;
using ILGPU.Runtime;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
namespace OpenNest.Gpu
{
public class GpuPairEvaluator : IPairEvaluator, IDisposable
{
private readonly Context _context;
private readonly Accelerator _accelerator;
private readonly Drawing _drawing;
private readonly PartBitmap _bitmap;
private readonly double _spacing;
public const double DefaultCellSize = 0.05;
public GpuPairEvaluator(Drawing drawing, double spacing, double cellSize = DefaultCellSize)
{
_drawing = drawing;
_spacing = spacing;
_context = Context.CreateDefault();
_accelerator = _context.GetPreferredDevice(preferCPU: false)
.CreateAccelerator(_context);
var dilation = spacing / 2.0;
_bitmap = PartBitmap.FromDrawing(drawing, cellSize, dilation);
}
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
if (_bitmap.Width == 0 || _bitmap.Height == 0 || candidates.Count == 0)
return new List<BestFitResult>();
var bitmapWidth = _bitmap.Width;
var bitmapHeight = _bitmap.Height;
var cellSize = (float)_bitmap.CellSize;
var candidateCount = candidates.Count;
// Pack candidate parameters: offsetX, offsetY, rotation, unused
var candidateParams = new float[candidateCount * 4];
for (var i = 0; i < candidateCount; i++)
{
candidateParams[i * 4 + 0] = (float)candidates[i].Part2Offset.X;
candidateParams[i * 4 + 1] = (float)candidates[i].Part2Offset.Y;
candidateParams[i * 4 + 2] = (float)candidates[i].Part2Rotation;
candidateParams[i * 4 + 3] = 0f;
}
// Results: overlapCount, minX, minY, maxX, maxY per candidate
var resultData = new int[candidateCount * 5];
// Initialize min to large, max to small
for (var i = 0; i < candidateCount; i++)
{
resultData[i * 5 + 0] = 0; // overlapCount
resultData[i * 5 + 1] = int.MaxValue; // minX
resultData[i * 5 + 2] = int.MaxValue; // minY
resultData[i * 5 + 3] = int.MinValue; // maxX
resultData[i * 5 + 4] = int.MinValue; // maxY
}
using var gpuBitmap = _accelerator.Allocate1D(_bitmap.Cells);
using var gpuParams = _accelerator.Allocate1D(candidateParams);
using var gpuResults = _accelerator.Allocate1D(resultData);
var kernel = _accelerator.LoadAutoGroupedStreamKernel<
Index1D,
ArrayView<int>,
ArrayView<float>,
ArrayView<int>,
int, int, float, float, float>(EvaluateKernel);
kernel(
candidateCount,
gpuBitmap.View,
gpuParams.View,
gpuResults.View,
bitmapWidth,
bitmapHeight,
cellSize,
(float)_bitmap.OriginX,
(float)_bitmap.OriginY);
_accelerator.Synchronize();
gpuResults.CopyToCPU(resultData);
var trueArea = _drawing.Area * 2;
var results = new List<BestFitResult>(candidateCount);
for (var i = 0; i < candidateCount; i++)
{
var overlapCount = resultData[i * 5 + 0];
var minX = resultData[i * 5 + 1];
var minY = resultData[i * 5 + 2];
var maxX = resultData[i * 5 + 3];
var maxY = resultData[i * 5 + 4];
var hasOverlap = overlapCount > 0;
var hasBounds = minX <= maxX && minY <= maxY;
double boundingWidth = 0, boundingHeight = 0, area = 0;
if (hasBounds)
{
boundingWidth = (maxX - minX + 1) * _bitmap.CellSize;
boundingHeight = (maxY - minY + 1) * _bitmap.CellSize;
area = boundingWidth * boundingHeight;
}
results.Add(new BestFitResult
{
Candidate = candidates[i],
RotatedArea = area,
BoundingWidth = boundingWidth,
BoundingHeight = boundingHeight,
OptimalRotation = 0,
TrueArea = trueArea,
Keep = !hasOverlap && hasBounds,
Reason = hasOverlap ? "Overlap detected" : hasBounds ? "Valid" : "No bounds"
});
}
return results;
}
private static void EvaluateKernel(
Index1D index,
ArrayView<int> bitmap,
ArrayView<float> candidateParams,
ArrayView<int> results,
int bitmapWidth, int bitmapHeight,
float cellSize, float originX, float originY)
{
var paramIdx = index * 4;
var offsetX = candidateParams[paramIdx + 0];
var offsetY = candidateParams[paramIdx + 1];
var rotation = candidateParams[paramIdx + 2];
// Convert world offset to cell offset relative to bitmap origin
var offsetCellsX = (offsetX - originX) / cellSize;
var offsetCellsY = (offsetY - originY) / cellSize;
var cosR = IntrinsicMath.Cos(rotation);
var sinR = IntrinsicMath.Sin(rotation);
var halfW = bitmapWidth * 0.5f;
var halfH = bitmapHeight * 0.5f;
var overlapCount = 0;
var minX = int.MaxValue;
var minY = int.MaxValue;
var maxX = int.MinValue;
var maxY = int.MinValue;
for (var y = 0; y < bitmapHeight; y++)
{
for (var x = 0; x < bitmapWidth; x++)
{
var cell1 = bitmap[y * bitmapWidth + x];
// Transform (x,y) to part2 space: rotate around center then offset
var cx = x - halfW;
var cy = y - halfH;
var rx = cx * cosR - cy * sinR;
var ry = cx * sinR + cy * cosR;
var bx = (int)(rx + halfW + offsetCellsX - x);
var by = (int)(ry + halfH + offsetCellsY - y);
// Lookup part2 bitmap cell at transformed position
var bx2 = x + bx;
var by2 = y + by;
var cell2 = 0;
if (bx2 >= 0 && bx2 < bitmapWidth && by2 >= 0 && by2 < bitmapHeight)
cell2 = bitmap[by2 * bitmapWidth + bx2];
if (cell1 == 1 && cell2 == 1)
overlapCount++;
if (cell1 == 1 || cell2 == 1)
{
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
var resultIdx = index * 5;
results[resultIdx + 0] = overlapCount;
results[resultIdx + 1] = minX;
results[resultIdx + 2] = minY;
results[resultIdx + 3] = maxX;
results[resultIdx + 4] = maxY;
}
public void Dispose()
{
_accelerator?.Dispose();
_context?.Dispose();
}
}
}
```
Note: The kernel uses `IntrinsicMath.Cos`/`Sin` which ILGPU compiles to GPU intrinsics. The `int.MaxValue`/`int.MinValue` initialization for bounds tracking is done CPU-side before upload.
**Step 2: Build**
Run: `dotnet build OpenNest.Gpu/OpenNest.Gpu.csproj`
Expected: Build succeeded
**Step 3: Commit**
```bash
git add OpenNest.Gpu/GpuPairEvaluator.cs
git commit -m "feat: add GpuPairEvaluator with ILGPU bitmap overlap kernel"
```
---
### Task 5: Wire GPU evaluator into `NestEngine`
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs`
- Modify: `OpenNest/OpenNest.csproj` (add reference to OpenNest.Gpu)
**Step 1: Add `UseGpu` property to `NestEngine`**
At the top of the `NestEngine` class (after the existing properties around line 23), add:
```csharp
public bool UseGpu { get; set; }
```
**Step 2: Update `FillWithPairs` to use GPU evaluator when enabled**
In `NestEngine.cs`, the `FillWithPairs(NestItem item, Box workArea)` method (line 268) creates a `BestFitFinder`. Change it to optionally pass a GPU evaluator.
Add at the top of the file:
```csharp
using OpenNest.Engine.BestFit;
```
(Already present — line 6.)
Replace the `FillWithPairs(NestItem item, Box workArea)` method body. The key change is lines 270-271 where the finder is created:
```csharp
private List<Part> FillWithPairs(NestItem item, Box workArea)
{
IPairEvaluator evaluator = null;
if (UseGpu)
{
try
{
evaluator = new Gpu.GpuPairEvaluator(item.Drawing, Plate.PartSpacing);
}
catch
{
// GPU not available, fall back to geometry
}
}
var finder = new BestFitFinder(Plate.Size.Width, Plate.Size.Height, evaluator);
var bestFits = finder.FindBestFits(item.Drawing, Plate.PartSpacing, stepSize: 0.25);
var keptResults = bestFits.Where(r => r.Keep).Take(50).ToList();
Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {keptResults.Count}");
var resultBag = new System.Collections.Concurrent.ConcurrentBag<(int count, List<Part> parts)>();
System.Threading.Tasks.Parallel.For(0, keptResults.Count, i =>
{
var result = keptResults[i];
var pairParts = BuildPairParts(result, item.Drawing);
var angles = FindHullEdgeAngles(pairParts);
var engine = new FillLinear(workArea, Plate.PartSpacing);
var filled = FillPattern(engine, pairParts, angles);
if (filled != null && filled.Count > 0)
resultBag.Add((filled.Count, filled));
});
List<Part> best = null;
foreach (var (count, parts) in resultBag)
{
if (best == null || count > best.Count)
best = parts;
}
(evaluator as IDisposable)?.Dispose();
Debug.WriteLine($"[FillWithPairs] Best pair result: {best?.Count ?? 0} parts");
return best ?? new List<Part>();
}
```
**Step 3: Add OpenNest.Gpu reference to UI project**
In `OpenNest/OpenNest.csproj`, add to the `<ItemGroup>` with other project references:
```xml
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
```
**Step 4: Build full solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded
**Step 5: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs OpenNest/OpenNest.csproj
git commit -m "feat: wire GpuPairEvaluator into NestEngine with UseGpu flag"
```
---
### Task 6: Add UI toggle for GPU mode
**Files:**
- Modify: `OpenNest/Forms/MainForm.cs`
- Modify: `OpenNest/Forms/MainForm.Designer.cs`
This task adds a "Use GPU" checkbox menu item under the Tools menu. The exact placement depends on the existing menu structure.
**Step 1: Check existing menu structure**
Read `MainForm.Designer.cs` to find the Tools menu items and their initialization to determine where to add the GPU toggle. Look for `mnuTools` items.
**Step 2: Add menu item field**
In `MainForm.Designer.cs`, add a field declaration near the other menu fields:
```csharp
private System.Windows.Forms.ToolStripMenuItem mnuToolsUseGpu;
```
**Step 3: Initialize menu item**
In the `InitializeComponent()` method, initialize the item and add it to the Tools menu `DropDownItems`:
```csharp
this.mnuToolsUseGpu = new System.Windows.Forms.ToolStripMenuItem();
this.mnuToolsUseGpu.Name = "mnuToolsUseGpu";
this.mnuToolsUseGpu.Text = "Use GPU for Best Fit";
this.mnuToolsUseGpu.CheckOnClick = true;
this.mnuToolsUseGpu.Click += new System.EventHandler(this.UseGpu_Click);
```
Add `this.mnuToolsUseGpu` to the Tools menu's `DropDownItems` array.
**Step 4: Add click handler in MainForm.cs**
```csharp
private void UseGpu_Click(object sender, EventArgs e)
{
// The CheckOnClick property handles toggling automatically
}
```
**Step 5: Pass the flag when creating NestEngine**
Find where `NestEngine` is created in the codebase (likely in auto-nest or fill actions) and set `UseGpu = mnuToolsUseGpu.Checked` on the engine after creation.
This requires reading the code to find the exact creation points. Search for `new NestEngine(` in the codebase.
**Step 6: Build and verify**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded
**Step 7: Commit**
```bash
git add OpenNest/Forms/MainForm.cs OpenNest/Forms/MainForm.Designer.cs
git commit -m "feat: add Use GPU toggle in Tools menu"
```
---
### Task 7: Smoke test
**Step 1: Run the application**
Run: `dotnet run --project OpenNest/OpenNest.csproj`
**Step 2: Manual verification**
1. Open a nest file with parts
2. Verify the geometry path still works (GPU unchecked) — auto-nest a plate
3. Enable "Use GPU for Best Fit" in Tools menu
4. Auto-nest the same plate with GPU enabled
5. Compare part counts — GPU results should be close to geometry results (not exact due to bitmap approximation)
6. Check Debug output for `[FillWithPairs]` timing differences
**Step 3: Commit any fixes**
If any issues found, fix and commit with appropriate message.

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +0,0 @@
# OpenNest MCP Service + IO Library Refactor
## Goal
Create an MCP server so Claude Code can load nest files, run nesting algorithms, and inspect results — enabling rapid iteration on nesting strategies without launching the WinForms app.
## Project Changes
```
OpenNest.Core (no external deps) — add Plate.GetRemnants()
OpenNest.Engine → Core
OpenNest.IO (NEW) → Core + ACadSharp — extracted from OpenNest/IO/
OpenNest.Mcp (NEW) → Core + Engine + IO
OpenNest (WinForms) → Core + Engine + IO (drops ACadSharp direct ref)
```
## OpenNest.IO Library
New class library. Move from the UI project (`OpenNest/IO/`):
- `DxfImporter`
- `DxfExporter`
- `NestReader`
- `NestWriter`
- `ProgramReader`
- ACadSharp NuGet dependency (3.1.32)
The WinForms project drops its direct ACadSharp reference and references OpenNest.IO instead.
## Plate.GetRemnants()
Add to `Plate` in Core. Simple strip-based scan:
1. Collect all part bounding boxes inflated by `PartSpacing`.
2. Scan the work area for clear rectangular strips along edges and between part columns/rows.
3. Return `List<Box>` of usable empty regions.
No engine dependency — uses only work area and part bounding boxes already available on Plate.
## MCP Tools
### Input
| Tool | Description |
|------|-------------|
| `load_nest` | Load a `.nest` zip file, returns nest summary (plates, drawings, part counts) |
| `import_dxf` | Import a DXF file as a drawing |
| `create_drawing` | Create from built-in shape primitive (rect, circle, L, T) or raw G-code string |
### Setup
| Tool | Description |
|------|-------------|
| `create_plate` | Define a plate with dimensions, spacing, edge spacing, quadrant |
| `clear_plate` | Remove all parts from a plate |
### Nesting
| Tool | Description |
|------|-------------|
| `fill_plate` | Fill entire plate with a single drawing (NestEngine.Fill) |
| `fill_area` | Fill a specific box region on a plate |
| `fill_remnants` | Auto-detect remnants via Plate.GetRemnants(), fill each with a drawing |
| `pack_plate` | Multi-drawing bin packing (NestEngine.Pack) |
### Inspection
| Tool | Description |
|------|-------------|
| `get_plate_info` | Dimensions, part count, utilization %, remnant boxes |
| `get_parts` | List placed parts with location, rotation, bounding box |
| `check_overlaps` | Run overlap detection, return collision points |
## Example Workflow
```
load_nest("N0308-008.zip")
→ 1 plate (36x36), 75 parts, 1 drawing (Converto 3 YRD DUMPER), utilization 80.2%
get_plate_info(plate: 0)
→ utilization: 80.2%, remnants: [{x:33.5, y:0, w:2.5, h:36}]
fill_remnants(plate: 0, drawing: "Converto 3 YRD DUMPER")
→ added 3 parts, new utilization: 83.1%
check_overlaps(plate: 0)
→ no overlaps
```
## MCP Server Implementation
- .NET 8 console app using stdio transport
- Published to `~/.claude/mcp/OpenNest.Mcp/`
- Registered in `~/.claude/settings.local.json`
- In-memory state: holds the current `Nest` object across tool calls

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +0,0 @@
# Remnant Fill Optimization — Investigation & Fix
## Status: Both fixes done
## Problem 1 (FIXED): N0308-008 hinge plate remnant
`NestEngine.Fill(NestItem, Box)` got 7 parts in a 4.7x35.0 remnant strip where manual nesting gets 8 using a staggered brick pattern with alternating rotations.
### Test Case
```
load_nest("C:/Users/AJ/Desktop/N0308-008.zip")
fill_remnants(0, "Converto 3 YRD DUMPERSTER HINGE PLATE #2") → was 7, now 9
```
Reference: `C:/Users/AJ/Desktop/N0308-008 - Copy.zip` (83 parts total, 8 in remnant).
### Root Cause Found
- FillLinear rotation sweep works correctly — tested at 1° resolution, max is always 7
- The reference uses a **staggered pair pattern** (alternating 90°/270° rotations with horizontal offset)
- `FillWithPairs` generates ~2572 pair candidates but only tried top 50 sorted by minimum bounding area
- The winning pair ranked ~882nd — excluded by the `Take(50)` cutoff
- Top-50-by-area favors compact pairs for full-plate tiling, not narrow pairs suited for remnant strips
### Fix Applied (in `OpenNest.Engine/NestEngine.cs`)
Added `SelectPairCandidates()` method:
1. Always includes standard top 50 pairs by area (no change for full-plate fills)
2. When work area is narrow (`shortSide < plateShortSide * 0.5`), includes **all** pairs whose shortest side fits the strip width
3. Updated `FillWithPairs()` to call `SelectPairCandidates()` instead of `Take(50)`
### Results
- Remnant fill: 7 → **9 parts** (beats reference of 8, with partial pattern fill)
- Full-plate fill: 75 parts (unchanged, no regression)
- Remnant fill time: ~440ms
- Overlap check: PASS
---
## Problem 2 (FIXED): N0308-017 PT02 remnant
`N0308-017.zip` — 54 parts on a 144x72 plate. Two remnant areas:
- Remnant 0: `(119.57, 0.75) 24.13x70.95` — end-of-sheet strip
- Remnant 1: `(0.30, 66.15) 143.40x5.55` — bottom strip
Drawing "4980 A24 PT02" has bbox 10.562x15.406. Engine filled 8 parts (2 cols × 4 rows) in remnant 0. Reference (`N0308-017 - Copy.zip`) has 10 parts using alternating 0°/180° rows.
### Investigation
1. Tested PT02 in remnant isolation → still 8 parts (not a multi-drawing ordering issue)
2. Brute-forced all 7224 pair candidates → max was 8 (no pair yields >8 with full-pattern-only tiling)
3. Tried finer offset resolution (0.05" step) across 0°/90°/180°/270° → still max 8
4. Analyzed reference nest (`N0308-017 - Copy.zip`): **64 PT02 parts on full plate, 10 in remnant area**
### Root Cause Found
The reference uses a 0°/170° staggered pair pattern that tiles in 5 rows × 2 columns:
- Rows at y: 0.75, 14.88, 28.40, 42.53, 56.06 (alternating 0° and 170°)
- Pattern copy distance: ~27.65" (pair tiling distance)
- 2 full pairs = 8 parts, top at ~58.56"
- Remaining height: 71.70 - 58.56 = ~13.14" — enough for 1 more row of 0° parts (height 15.41)
- **But `FillLinear.TilePattern` only placed complete pattern copies**, so the partial 3rd pair (just the 0° row) was never attempted
The pair candidate DID exist in the candidate set and was being tried. The issue was entirely in `FillLinear.TilePattern` — it tiled 2 complete pairs (8 parts) and stopped, even though 2 more individual parts from the next incomplete pair would still fit within the work area.
### Fix Applied (in `OpenNest.Engine/FillLinear.cs`)
Added **partial pattern fill** to `TilePattern()`:
- After tiling complete pattern copies, if the pattern has multiple parts, clone the next would-be copy
- Check each individual part's bounding box against the work area
- Add any that fit — guaranteed no overlaps by the copy distance computation
This is safe because:
- The copy distance ensures no overlaps between adjacent full copies → partial (subset) is also safe
- Parts within the same pattern copy don't overlap by construction
- Individual bounds checking catches parts that exceed the work area
### Results
- PT02 remnant fill: 8 → **10 parts** (matches reference)
- Hinge remnant fill: 8 → **9 parts** (bonus improvement from same fix)
- Full-plate fill: 75 parts (unchanged, no regression)
- All overlap checks: PASS
- PT02 fill time: ~32s (unchanged, dominated by pair candidate evaluation)
---
## Files Modified
- `OpenNest.Engine/NestEngine.cs` — Added `SelectPairCandidates()`, updated `FillWithPairs()`, rotation sweep (pre-existing change)
- `OpenNest.Engine/FillLinear.cs` — Added partial pattern fill to `TilePattern()`
## Temp Files to Clean Up
- `OpenNest.Test/` — temporary test console project (can be deleted or kept for debugging)

View File

@@ -1,382 +0,0 @@
# Remainder Strip Re-Fill Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** After the main fill, detect oddball last column/row, remove it, and re-fill the remainder strip independently to maximize part count (30 -> 32 for the test case).
**Architecture:** Extract the strategy selection logic from `Fill(NestItem, Box)` into a reusable `FindBestFill` method. Add `TryRemainderImprovement` that clusters placed parts, detects oddball last cluster, computes the remainder strip box, and calls `FindBestFill` on it. Only used when it improves the count.
**Tech Stack:** C# / .NET 8, OpenNest.Engine
---
### Task 1: Extract FindBestFill from Fill(NestItem, Box)
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs:32-105`
**Step 1: Create `FindBestFill` by extracting the strategy logic**
Move lines 34-95 (everything except the quantity check and `Plate.Parts.AddRange`) into a new private method. `Fill` delegates to it.
```csharp
private List<Part> FindBestFill(NestItem item, Box workArea)
{
var bestRotation = RotationAnalysis.FindBestRotation(item);
var engine = new FillLinear(workArea, Plate.PartSpacing);
// Build candidate rotation angles — always try the best rotation and +90°.
var angles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
// When the work area is narrow relative to the part, sweep rotation
// angles so we can find one that fits the part into the tight strip.
var testPart = new Part(item.Drawing);
if (!bestRotation.IsEqualTo(0))
testPart.Rotate(bestRotation);
testPart.UpdateBounds();
var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Height);
var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Height);
if (workAreaShortSide < partLongestSide)
{
// Try every 5° from 0 to 175° to find rotations that fit.
var step = Angle.ToRadians(5);
for (var a = 0.0; a < System.Math.PI; a += step)
{
if (!angles.Any(existing => existing.IsEqualTo(a)))
angles.Add(a);
}
}
List<Part> best = null;
foreach (var angle in angles)
{
var h = engine.Fill(item.Drawing, angle, NestDirection.Horizontal);
var v = engine.Fill(item.Drawing, angle, NestDirection.Vertical);
if (IsBetterFill(h, best))
best = h;
if (IsBetterFill(v, best))
best = v;
}
Debug.WriteLine($"[FindBestFill] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
// Try rectangle best-fit (mixes orientations to fill remnant strips).
var rectResult = FillRectangleBestFit(item, workArea);
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts");
if (IsBetterFill(rectResult, best))
best = rectResult;
// Try pair-based approach.
var pairResult = FillWithPairs(item, workArea);
Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts");
if (IsBetterFill(pairResult, best))
best = pairResult;
return best;
}
```
**Step 2: Simplify `Fill(NestItem, Box)` to delegate**
```csharp
public bool Fill(NestItem item, Box workArea)
{
var best = FindBestFill(item, workArea);
if (best == null || best.Count == 0)
return false;
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
Plate.Parts.AddRange(best);
return true;
}
```
**Step 3: Build and verify no regressions**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds, no errors.
**Step 4: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs
git commit -m "refactor: extract FindBestFill from Fill(NestItem, Box)"
```
---
### Task 2: Add ClusterParts helper
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs`
**Step 1: Add the `ClusterParts` method**
Place after `IsBetterValidFill` (around line 287). Groups parts into positional clusters (columns or rows) based on center position gaps.
```csharp
/// <summary>
/// Groups parts into positional clusters along the given axis.
/// Parts whose center positions are separated by more than half
/// the part dimension start a new cluster.
/// </summary>
private static List<List<Part>> ClusterParts(List<Part> parts, bool horizontal)
{
var sorted = horizontal
? parts.OrderBy(p => p.BoundingBox.Center.X).ToList()
: parts.OrderBy(p => p.BoundingBox.Center.Y).ToList();
var refDim = horizontal
? sorted.Max(p => p.BoundingBox.Width)
: sorted.Max(p => p.BoundingBox.Height);
var gapThreshold = refDim * 0.5;
var clusters = new List<List<Part>>();
var current = new List<Part> { sorted[0] };
for (var i = 1; i < sorted.Count; i++)
{
var prevCenter = horizontal
? sorted[i - 1].BoundingBox.Center.X
: sorted[i - 1].BoundingBox.Center.Y;
var currCenter = horizontal
? sorted[i].BoundingBox.Center.X
: sorted[i].BoundingBox.Center.Y;
if (currCenter - prevCenter > gapThreshold)
{
clusters.Add(current);
current = new List<Part>();
}
current.Add(sorted[i]);
}
clusters.Add(current);
return clusters;
}
```
**Step 2: Build**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds.
**Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat: add ClusterParts helper for positional grouping"
```
---
### Task 3: Add TryStripRefill and TryRemainderImprovement
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs`
**Step 1: Add `TryStripRefill`**
This method analyzes one axis: clusters parts, checks if last cluster is an oddball, computes the strip, and fills it.
```csharp
/// <summary>
/// Checks whether the last column (horizontal) or row (vertical) is an
/// oddball with fewer parts than the main grid. If so, removes those parts,
/// computes the remainder strip, and fills it independently.
/// Returns null if no improvement is possible.
/// </summary>
private List<Part> TryStripRefill(NestItem item, Box workArea, List<Part> parts, bool horizontal)
{
var clusters = ClusterParts(parts, horizontal);
if (clusters.Count < 2)
return null;
var lastCluster = clusters[clusters.Count - 1];
var otherClusters = clusters.Take(clusters.Count - 1).ToList();
// Find the most common cluster size (mode).
var modeCount = otherClusters
.Select(c => c.Count)
.GroupBy(x => x)
.OrderByDescending(g => g.Count())
.First().Key;
// Only proceed if last cluster is smaller (it's the oddball).
if (lastCluster.Count >= modeCount)
return null;
var mainParts = otherClusters.SelectMany(c => c).ToList();
var mainBbox = ((IEnumerable<IBoundable>)mainParts).GetBoundingBox();
Box strip;
if (horizontal)
{
var stripLeft = mainBbox.Right + Plate.PartSpacing;
var stripWidth = workArea.Right - stripLeft;
if (stripWidth < 1)
return null;
strip = new Box(stripLeft, workArea.Y, stripWidth, workArea.Height);
}
else
{
var stripBottom = mainBbox.Top + Plate.PartSpacing;
var stripHeight = workArea.Top - stripBottom;
if (stripHeight < 1)
return null;
strip = new Box(workArea.X, stripBottom, workArea.Width, stripHeight);
}
Debug.WriteLine($"[TryStripRefill] {(horizontal ? "H" : "V")} strip: {strip.Width:F1}x{strip.Height:F1} | Main: {mainParts.Count} | Oddball: {lastCluster.Count}");
var stripParts = FindBestFill(item, strip);
if (stripParts == null || stripParts.Count <= lastCluster.Count)
return null;
Debug.WriteLine($"[TryStripRefill] Strip fill: {stripParts.Count} parts (was {lastCluster.Count} oddball)");
var combined = new List<Part>(mainParts);
combined.AddRange(stripParts);
return combined;
}
```
**Step 2: Add `TryRemainderImprovement`**
Tries both horizontal and vertical strip analysis.
```csharp
/// <summary>
/// Attempts to improve a fill result by detecting an oddball last
/// column or row and re-filling the remainder strip independently.
/// Returns null if no improvement is found.
/// </summary>
private List<Part> TryRemainderImprovement(NestItem item, Box workArea, List<Part> currentBest)
{
if (currentBest == null || currentBest.Count < 3)
return null;
List<Part> bestImproved = null;
var hImproved = TryStripRefill(item, workArea, currentBest, horizontal: true);
if (IsBetterFill(hImproved, bestImproved))
bestImproved = hImproved;
var vImproved = TryStripRefill(item, workArea, currentBest, horizontal: false);
if (IsBetterFill(vImproved, bestImproved))
bestImproved = vImproved;
return bestImproved;
}
```
**Step 3: Build**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds.
**Step 4: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat: add TryStripRefill and TryRemainderImprovement"
```
---
### Task 4: Wire remainder improvement into Fill
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs` — the `Fill(NestItem, Box)` method
**Step 1: Add remainder improvement call**
Update `Fill(NestItem, Box)` to try improving the result after the initial fill:
```csharp
public bool Fill(NestItem item, Box workArea)
{
var best = FindBestFill(item, workArea);
// Try improving by filling the remainder strip separately.
var improved = TryRemainderImprovement(item, workArea, best);
if (IsBetterFill(improved, best))
{
Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
best = improved;
}
if (best == null || best.Count == 0)
return false;
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
Plate.Parts.AddRange(best);
return true;
}
```
**Step 2: Build**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds.
**Step 3: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat: wire remainder strip re-fill into Fill(NestItem, Box)"
```
---
### Task 5: Verify with MCP tools
**Step 1: Publish MCP server**
```bash
dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"
```
**Step 2: Test fill**
Use MCP tools to:
1. Import the DXF drawing from `30pcs Fill.zip` (or create equivalent plate + drawing)
2. Create a 96x48 plate with the same spacing (part=0.25, edges L=0.25 B=0.75 R=0.25 T=0.25)
3. Fill the plate
4. Verify part count is 32 (up from 30)
5. Check for overlaps
**Step 3: Compare against 32pcs reference**
Verify the layout matches the 32pcs.zip reference — 24 parts in the main grid + 8 in the remainder strip.
**Step 4: Final commit if any fixups needed**

View File

@@ -1,417 +0,0 @@
# OpenNest xUnit Test Suite Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Convert the ad-hoc OpenNest.Test console harness into a proper xUnit test suite with test data hosted in a separate git repo.
**Architecture:** Replace the console app with an xUnit test project. A `TestData` helper resolves the test data path from env var `OPENNEST_TEST_DATA` or fallback `../OpenNest.Test.Data/`. Tests skip with a message if data is missing. Test classes: `FillTests` (full plate fills), `RemnantFillTests` (filling remnant areas). All tests assert part count >= target and zero overlaps.
**Tech Stack:** C# / .NET 8, xUnit, OpenNest.Core + Engine + IO
---
### Task 1: Set up test data repo and push fixture files
**Step 1: Clone the empty repo next to OpenNest**
```bash
cd C:/Users/AJ/Desktop/Projects
git clone https://git.thecozycat.net/aj/OpenNest.Test.git OpenNest.Test.Data
```
**Step 2: Copy fixture files into the repo**
```bash
cp ~/Desktop/"N0308-017.zip" OpenNest.Test.Data/
cp ~/Desktop/"N0308-008.zip" OpenNest.Test.Data/
cp ~/Desktop/"30pcs Fill.zip" OpenNest.Test.Data/
```
**Step 3: Commit and push**
```bash
cd OpenNest.Test.Data
git add .
git commit -m "feat: add initial test fixture nest files"
git push
```
---
### Task 2: Convert OpenNest.Test from console app to xUnit project
**Files:**
- Modify: `OpenNest.Test/OpenNest.Test.csproj`
- Delete: `OpenNest.Test/Program.cs`
- Create: `OpenNest.Test/TestData.cs`
**Step 1: Replace the csproj with xUnit configuration**
Overwrite `OpenNest.Test/OpenNest.Test.csproj`:
```xml
<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="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
</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>
```
**Step 2: Delete Program.cs**
```bash
rm OpenNest.Test/Program.cs
```
**Step 3: Create TestData helper**
Create `OpenNest.Test/TestData.cs`:
```csharp
using OpenNest.IO;
namespace OpenNest.Test;
public static class TestData
{
private static readonly string? BasePath = ResolveBasePath();
public static bool IsAvailable => BasePath != null;
public static string SkipReason =>
"Test data not found. Set OPENNEST_TEST_DATA env var or clone " +
"https://git.thecozycat.net/aj/OpenNest.Test.git to ../OpenNest.Test.Data/";
public static string GetPath(string filename)
{
if (BasePath == null)
throw new InvalidOperationException(SkipReason);
var path = Path.Combine(BasePath, filename);
if (!File.Exists(path))
throw new FileNotFoundException($"Test fixture not found: {path}");
return path;
}
public static Nest LoadNest(string filename)
{
var reader = new NestReader(GetPath(filename));
return reader.Read();
}
public static Plate CleanPlateFrom(Plate reference)
{
var plate = new Plate();
plate.Size = reference.Size;
plate.PartSpacing = reference.PartSpacing;
plate.EdgeSpacing = reference.EdgeSpacing;
plate.Quadrant = reference.Quadrant;
return plate;
}
private static string? ResolveBasePath()
{
// 1. Environment variable
var envPath = Environment.GetEnvironmentVariable("OPENNEST_TEST_DATA");
if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
return envPath;
// 2. Sibling directory (../OpenNest.Test.Data/ relative to solution root)
var dir = AppContext.BaseDirectory;
// Walk up from bin/Debug/net8.0-windows to find the solution root.
for (var i = 0; i < 6; i++)
{
var parent = Directory.GetParent(dir);
if (parent == null)
break;
dir = parent.FullName;
var candidate = Path.Combine(dir, "OpenNest.Test.Data");
if (Directory.Exists(candidate))
return candidate;
}
return null;
}
}
```
**Step 4: Build**
Run: `dotnet build OpenNest.Test/OpenNest.Test.csproj`
Expected: Build succeeds.
**Step 5: Commit**
```bash
git add OpenNest.Test/
git commit -m "refactor: convert OpenNest.Test to xUnit project with TestData helper"
```
---
### Task 3: Add FillTests
**Files:**
- Create: `OpenNest.Test/FillTests.cs`
**Step 1: Create FillTests.cs**
```csharp
using OpenNest.Geometry;
using Xunit;
using Xunit.Abstractions;
namespace OpenNest.Test;
public class FillTests
{
private readonly ITestOutputHelper _output;
public FillTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
[Trait("Category", "Fill")]
public void N0308_008_HingePlate_FillsAtLeast75()
{
Skip.IfNot(TestData.IsAvailable, TestData.SkipReason);
var nest = TestData.LoadNest("N0308-008.zip");
var hinge = nest.Drawings.First(d => d.Name.Contains("HINGE PLATE #2"));
var plate = TestData.CleanPlateFrom(nest.Plates[0]);
var engine = new NestEngine(plate);
var sw = System.Diagnostics.Stopwatch.StartNew();
engine.Fill(new NestItem { Drawing = hinge, Quantity = 0 });
sw.Stop();
_output.WriteLine($"Parts: {plate.Parts.Count} | Time: {sw.ElapsedMilliseconds}ms");
Assert.True(plate.Parts.Count >= 75,
$"Expected >= 75 parts, got {plate.Parts.Count}");
AssertNoOverlaps(plate.Parts.ToList());
}
[Fact]
[Trait("Category", "Fill")]
public void RemainderStripRefill_30pcs_FillsAtLeast32()
{
Skip.IfNot(TestData.IsAvailable, TestData.SkipReason);
var nest = TestData.LoadNest("30pcs Fill.zip");
var drawing = nest.Drawings.First();
var plate = TestData.CleanPlateFrom(nest.Plates[0]);
var engine = new NestEngine(plate);
var sw = System.Diagnostics.Stopwatch.StartNew();
engine.Fill(new NestItem { Drawing = drawing, Quantity = 0 });
sw.Stop();
_output.WriteLine($"Parts: {plate.Parts.Count} | Time: {sw.ElapsedMilliseconds}ms");
Assert.True(plate.Parts.Count >= 32,
$"Expected >= 32 parts, got {plate.Parts.Count}");
AssertNoOverlaps(plate.Parts.ToList());
}
private void AssertNoOverlaps(List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
for (var j = i + 1; j < parts.Count; j++)
{
if (parts[i].Intersects(parts[j], out _))
Assert.Fail($"Overlap detected: part [{i}] vs [{j}]");
}
}
}
}
```
**Step 2: Run tests**
Run: `dotnet test OpenNest.Test/ -v normal`
Expected: 2 tests pass (or skip if data missing).
**Step 3: Commit**
```bash
git add OpenNest.Test/FillTests.cs
git commit -m "test: add FillTests for full plate fill and remainder strip refill"
```
---
### Task 4: Add RemnantFillTests
**Files:**
- Create: `OpenNest.Test/RemnantFillTests.cs`
**Step 1: Create RemnantFillTests.cs**
```csharp
using OpenNest.Geometry;
using Xunit;
using Xunit.Abstractions;
namespace OpenNest.Test;
public class RemnantFillTests
{
private readonly ITestOutputHelper _output;
public RemnantFillTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
[Trait("Category", "Remnant")]
public void N0308_017_PT02_RemnantFillsAtLeast10()
{
Skip.IfNot(TestData.IsAvailable, TestData.SkipReason);
var nest = TestData.LoadNest("N0308-017.zip");
var plate = nest.Plates[0];
var pt02 = nest.Drawings.First(d => d.Name.Contains("PT02"));
var remnant = plate.GetRemnants()[0];
_output.WriteLine($"Remnant: ({remnant.X:F2},{remnant.Y:F2}) {remnant.Width:F2}x{remnant.Height:F2}");
var countBefore = plate.Parts.Count;
var engine = new NestEngine(plate);
var sw = System.Diagnostics.Stopwatch.StartNew();
engine.Fill(new NestItem { Drawing = pt02, Quantity = 0 }, remnant);
sw.Stop();
var added = plate.Parts.Count - countBefore;
_output.WriteLine($"Added: {added} parts | Time: {sw.ElapsedMilliseconds}ms");
Assert.True(added >= 10, $"Expected >= 10 parts in remnant, got {added}");
var newParts = plate.Parts.Skip(countBefore).ToList();
AssertNoOverlaps(newParts);
AssertNoCrossOverlaps(plate.Parts.Take(countBefore).ToList(), newParts);
}
[Fact]
[Trait("Category", "Remnant")]
public void N0308_008_HingePlate_RemnantFillsAtLeast8()
{
Skip.IfNot(TestData.IsAvailable, TestData.SkipReason);
var nest = TestData.LoadNest("N0308-008.zip");
var plate = nest.Plates[0];
var hinge = nest.Drawings.First(d => d.Name.Contains("HINGE PLATE #2"));
var remnants = plate.GetRemnants();
_output.WriteLine($"Remnant 0: ({remnants[0].X:F2},{remnants[0].Y:F2}) {remnants[0].Width:F2}x{remnants[0].Height:F2}");
var countBefore = plate.Parts.Count;
var engine = new NestEngine(plate);
var sw = System.Diagnostics.Stopwatch.StartNew();
engine.Fill(new NestItem { Drawing = hinge, Quantity = 0 }, remnants[0]);
sw.Stop();
var added = plate.Parts.Count - countBefore;
_output.WriteLine($"Added: {added} parts | Time: {sw.ElapsedMilliseconds}ms");
Assert.True(added >= 8, $"Expected >= 8 parts in remnant, got {added}");
var newParts = plate.Parts.Skip(countBefore).ToList();
AssertNoOverlaps(newParts);
AssertNoCrossOverlaps(plate.Parts.Take(countBefore).ToList(), newParts);
}
private void AssertNoOverlaps(List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
for (var j = i + 1; j < parts.Count; j++)
{
if (parts[i].Intersects(parts[j], out _))
Assert.Fail($"Overlap detected: part [{i}] vs [{j}]");
}
}
}
private void AssertNoCrossOverlaps(List<Part> existing, List<Part> added)
{
for (var i = 0; i < existing.Count; i++)
{
for (var j = 0; j < added.Count; j++)
{
if (existing[i].Intersects(added[j], out _))
Assert.Fail($"Cross-overlap: existing [{i}] vs added [{j}]");
}
}
}
}
```
**Step 2: Run all tests**
Run: `dotnet test OpenNest.Test/ -v normal`
Expected: 4 tests pass.
**Step 3: Commit**
```bash
git add OpenNest.Test/RemnantFillTests.cs
git commit -m "test: add RemnantFillTests for remnant area filling"
```
---
### Task 5: Add test project to solution and final verification
**Step 1: Add to solution**
```bash
cd C:/Users/AJ/Desktop/Projects/OpenNest
dotnet sln OpenNest.sln add OpenNest.Test/OpenNest.Test.csproj
```
**Step 2: Build entire solution**
Run: `dotnet build OpenNest.sln`
Expected: All projects build, 0 errors.
**Step 3: Run all tests**
Run: `dotnet test OpenNest.sln -v normal`
Expected: 4 tests pass, 0 failures.
**Step 4: Commit**
```bash
git add OpenNest.sln
git commit -m "chore: add OpenNest.Test to solution"
```

View File

@@ -1,110 +0,0 @@
# GPU Pair Evaluator — Overlap Detection Bug
**Date**: 2026-03-10
**Status**: RESOLVED — commit b55aa7a
## Problem
The `GpuPairEvaluator` reports "Overlap detected" for ALL best-fit candidates, even though the parts are clearly not overlapping. The CPU `PairEvaluator` works correctly (screenshot comparison: GPU = all red/overlap, CPU = blue with valid results like 93.9% utilization).
## Root Cause (identified but not yet fully fixed)
The bitmap coordinate system doesn't match the `Part2Offset` coordinate system.
### How Part2Offset is computed
`RotationSlideStrategy` creates parts using `Part.CreateAtOrigin(drawing, rotation)` which:
1. Clones the drawing's program
2. Rotates it
3. Calls `Program.BoundingBox()` to get the bbox
4. Offsets by `-bbox.Location` to normalize to origin
`Part2Offset` is the final position of Part2 in this **normalized** coordinate space.
### How bitmaps are rasterized
`PartBitmap.FromDrawing` / `FromDrawingRotated`:
1. Extracts closed polygons from the drawing (filters out rapids, open shapes)
2. Rotates them (for B)
3. Rasterizes with `OriginX/Y = polygon min`
### The mismatch
`Program.BoundingBox()` initializes `minX=0, minY=0, maxX=0, maxY=0` (line 289-292 in Program.cs), so (0,0) is **always** included in the bbox. This means:
- For geometry at (5,3)-(10,8): bbox.Location = (0,0), CreateAtOrigin shifts by (0,0) = no change
- But polygon min = (5,3), so bitmap OriginX=5, OriginY=3
- Part2Offset is in the (0,0)-based normalized space, bitmap is in the (5,3)-based polygon space
For rotated geometry, the discrepancy is even worse because rotation changes the polygon min dramatically while the bbox may or may not include (0,0).
## What we tried
### Attempt 1: BlitPair approach (correct but too slow)
- Added `PartBitmap.BlitPair()` that places both bitmaps into a shared world-space grid
- Eliminated all offset math from the kernel (trivial element-wise AND)
- **Problem**: Per-candidate grid allocation. 21K candidates × large grids = massive memory + GPU transfer. Took minutes instead of seconds.
### Attempt 2: Integer offsets with gap correction
- Kept shared-bitmap approach (one A + one B per rotation group)
- Changed offsets from `float` to `int` with `Math.Round()` on CPU
- Added gap correction: `offset = (Part2Offset - gapA + gapB) / cellSize` where `gapA = bitmapOriginA - bboxA.Location`, `gapB = bitmapOriginB - bboxB.Location`
- **Problem**: Still false positives. The formula is mathematically correct in derivation but something is wrong in practice.
### Attempt 3: Normalize bitmaps to match CreateAtOrigin (current state)
- Added `PartBitmap.FromDrawingAtOrigin()` and `FromDrawingAtOriginRotated()`
- These shift polygons by `-bbox.Location` before rasterizing, exactly like `CreateAtOrigin`
- Offset formula: `(Part2Offset.X - bitmapA.OriginX + bitmapB.OriginX) / cellSize`
- **Problem**: STILL showing false overlaps for all candidates (see gpu.png). 33.8s compute, 3942 kept but all marked overlap.
## Current state of code
### Files modified
**`OpenNest.Gpu/PartBitmap.cs`**:
- Added `BlitPair()` static method (from attempt 1, still present but unused)
- Added `FromDrawingAtOrigin()` — normalizes polygons by `-bbox.Location` before rasterize
- Added `FromDrawingAtOriginRotated()` — rotates polygons, clones+rotates program for bbox, normalizes, rasterizes
**`OpenNest.Gpu/GpuPairEvaluator.cs`**:
- Uses `FromDrawingAtOrigin` / `FromDrawingAtOriginRotated` instead of raw `FromDrawing` / `FromDrawingRotated`
- Offsets are `int[]` (not `float[]`) computed with `Math.Round()` on CPU
- Kernel is `OverlapKernel` — uses integer offsets, early-exit on `cellA != 1`
- `PadBitmap` helper restored
- Removed the old `NestingKernel` with float offsets
**`OpenNest/Forms/MainForm.cs`**:
- Added `using OpenNest.Engine.BestFit;`
- Wired up GPU evaluator: `BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);`
## Next steps to debug
1. **Add diagnostic logging** to compare GPU vs CPU for a single candidate:
- Print bitmapA: OriginX, OriginY, Width, Height
- Print bitmapB: OriginX, OriginY, Width, Height
- Print the computed integer offset
- Print the overlap count from the kernel
- Compare with CPU `PairEvaluator.CheckOverlap()` result for the same candidate
2. **Verify Program.Clone() + Rotate() produces same geometry as Polygon.Rotate()**:
- `FromDrawingAtOriginRotated` rotates polygons with `poly.Rotate(rotation)` then normalizes using `prog.Clone().Rotate(rotation).BoundingBox()`
- If `Program.Rotate` and `Polygon.Rotate` use different rotation centers or conventions, the normalization would be wrong
- Check: does `Program.Rotate` rotate around (0,0)? Does `Polygon.Rotate` rotate around (0,0)?
3. **Try rasterizing from the Part directly**: Instead of extracting polygons from the raw drawing and manually rotating/normalizing, create `Part.CreateAtOrigin(drawing, rotation)` and extract polygons from the Part's already-normalized program. This guarantees exact coordinate system match.
4. **Consider that the kernel grid might be too small**: `gridWidth = max(A.Width, B.Width)` only works if offset is small. If Part2Offset places B far from A, the B cells at `bx = x - offset` could all be out of bounds (negative), leading the kernel to find zero overlaps (false negative). But we're seeing false POSITIVES, so this isn't the issue unless the offset sign is wrong.
5. **Check offset sign**: Verify that when offset is positive, `bx = x - offset` correctly maps A cells to B cells. A positive offset should mean B is shifted right relative to A.
## Performance notes
- CPU evaluator: 25.0s compute, 5954 kept, correct results
- GPU evaluator (current): 33.8s compute, 3942 kept, all false overlaps
- GPU is actually SLOWER because `FromDrawingAtOriginRotated` clones+rotates the full program per rotation group
- Once overlap detection is fixed, performance optimization should focus on avoiding the Program.Clone().Rotate() per rotation group
## Key files to reference
- `OpenNest.Gpu/GpuPairEvaluator.cs` — the GPU evaluator
- `OpenNest.Gpu/PartBitmap.cs` — bitmap rasterization
- `OpenNest.Engine/BestFit/PairEvaluator.cs` — CPU evaluator (working reference)
- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — generates Part2Offset values
- `OpenNest.Core/Part.cs:109``Part.CreateAtOrigin()`
- `OpenNest.Core/CNC/Program.cs:281-342``Program.BoundingBox()` (note min init at 0,0)
- `OpenNest.Engine/BestFit/BestFitCache.cs` — where evaluator is plugged in
- `OpenNest/Forms/MainForm.cs` — where GPU evaluator is wired up

View File

@@ -1,475 +0,0 @@
# FillScore 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:** Replace raw part-count comparisons with a structured FillScore (count → largest usable remnant → density) and expand remainder strip rotation coverage so denser pair patterns can win.
**Architecture:** New `FillScore` readonly struct with lexicographic comparison. Thread `workArea` parameter through `NestEngine` comparison methods. Expand `FillLinear.FillRemainingStrip` to try 0° and 90° in addition to seed rotations.
**Tech Stack:** .NET 8, C#, OpenNest.Engine
---
## Chunk 1: FillScore and NestEngine Integration
### Task 1: Create FillScore struct
**Files:**
- Create: `OpenNest.Engine/FillScore.cs`
- [ ] **Step 1: Create FillScore.cs**
```csharp
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
public readonly struct FillScore : System.IComparable<FillScore>
{
/// <summary>
/// Minimum short-side dimension for a remnant to be considered usable.
/// </summary>
public const double MinRemnantDimension = 12.0;
public int Count { get; }
/// <summary>
/// Area of the largest remnant whose short side >= MinRemnantDimension.
/// Zero if no usable remnant exists.
/// </summary>
public double UsableRemnantArea { get; }
/// <summary>
/// Total part area / bounding box area of all placed parts.
/// </summary>
public double Density { get; }
public FillScore(int count, double usableRemnantArea, double density)
{
Count = count;
UsableRemnantArea = usableRemnantArea;
Density = density;
}
/// <summary>
/// Computes a fill score from placed parts and the work area they were placed in.
/// </summary>
public static FillScore Compute(List<Part> parts, Box workArea)
{
if (parts == null || parts.Count == 0)
return default;
var totalPartArea = 0.0;
foreach (var part in parts)
totalPartArea += part.BaseDrawing.Area;
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var bboxArea = bbox.Area();
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
var usableRemnantArea = ComputeUsableRemnantArea(parts, workArea);
return new FillScore(parts.Count, usableRemnantArea, density);
}
/// <summary>
/// Finds the largest usable remnant (short side >= MinRemnantDimension)
/// by checking right and top edge strips between placed parts and the work area boundary.
/// </summary>
private static double ComputeUsableRemnantArea(List<Part> parts, Box workArea)
{
var maxRight = double.MinValue;
var maxTop = double.MinValue;
foreach (var part in parts)
{
var bb = part.BoundingBox;
if (bb.Right > maxRight)
maxRight = bb.Right;
if (bb.Top > maxTop)
maxTop = bb.Top;
}
var largest = 0.0;
// Right strip
if (maxRight < workArea.Right)
{
var width = workArea.Right - maxRight;
var height = workArea.Height;
if (System.Math.Min(width, height) >= MinRemnantDimension)
largest = System.Math.Max(largest, width * height);
}
// Top strip
if (maxTop < workArea.Top)
{
var width = workArea.Width;
var height = workArea.Top - maxTop;
if (System.Math.Min(width, height) >= MinRemnantDimension)
largest = System.Math.Max(largest, width * height);
}
return largest;
}
/// <summary>
/// Lexicographic comparison: count, then usable remnant area, then density.
/// </summary>
public int CompareTo(FillScore other)
{
var c = Count.CompareTo(other.Count);
if (c != 0)
return c;
c = UsableRemnantArea.CompareTo(other.UsableRemnantArea);
if (c != 0)
return c;
return Density.CompareTo(other.Density);
}
public static bool operator >(FillScore a, FillScore b) => a.CompareTo(b) > 0;
public static bool operator <(FillScore a, FillScore b) => a.CompareTo(b) < 0;
public static bool operator >=(FillScore a, FillScore b) => a.CompareTo(b) >= 0;
public static bool operator <=(FillScore a, FillScore b) => a.CompareTo(b) <= 0;
}
}
```
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.Engine`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/FillScore.cs
git commit -m "feat: add FillScore struct with lexicographic comparison"
```
---
### Task 2: Update NestEngine to use FillScore
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs`
This task threads `workArea` through the comparison methods and replaces the inline logic with `FillScore`.
- [ ] **Step 1: Replace IsBetterFill**
Replace the existing `IsBetterFill` method (lines 299-315) with:
```csharp
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);
}
```
- [ ] **Step 2: Replace IsBetterValidFill**
Replace the existing `IsBetterValidFill` method (lines 317-323) with:
```csharp
private bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
return false;
return IsBetterFill(candidate, current, workArea);
}
```
- [ ] **Step 3: Update all IsBetterFill call sites in FindBestFill**
In `FindBestFill` (lines 55-121), the `workArea` parameter is already available. Update each call:
```csharp
// Line 95 — was: if (IsBetterFill(h, best))
if (IsBetterFill(h, best, workArea))
// Line 98 — was: if (IsBetterFill(v, best))
if (IsBetterFill(v, best, workArea))
// Line 109 — was: if (IsBetterFill(rectResult, best))
if (IsBetterFill(rectResult, best, workArea))
// Line 117 — was: if (IsBetterFill(pairResult, best))
if (IsBetterFill(pairResult, best, workArea))
```
- [ ] **Step 4: Update IsBetterFill call sites in Fill(NestItem, Box)**
In `Fill(NestItem item, Box workArea)` (lines 32-53):
```csharp
// Line 39 — was: if (IsBetterFill(improved, best))
if (IsBetterFill(improved, best, workArea))
```
- [ ] **Step 5: Update call sites in Fill(List\<Part\>, Box)**
In `Fill(List<Part> groupParts, Box workArea)` (lines 123-166):
```csharp
// Line 141 — was: if (IsBetterFill(rectResult, best))
if (IsBetterFill(rectResult, best, workArea))
// Line 148 — was: if (IsBetterFill(pairResult, best))
if (IsBetterFill(pairResult, best, workArea))
// Line 154 — was: if (IsBetterFill(improved, best))
if (IsBetterFill(improved, best, workArea))
```
- [ ] **Step 6: Update FillPattern to accept and pass workArea**
Change the signature and update calls inside:
```csharp
private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
{
List<Part> best = null;
foreach (var angle in angles)
{
var pattern = BuildRotatedPattern(groupParts, angle);
if (pattern.Parts.Count == 0)
continue;
var h = engine.Fill(pattern, NestDirection.Horizontal);
var v = engine.Fill(pattern, NestDirection.Vertical);
if (IsBetterValidFill(h, best, workArea))
best = h;
if (IsBetterValidFill(v, best, workArea))
best = v;
}
return best;
}
```
- [ ] **Step 7: Update FillPattern call sites**
Two call sites — both have `workArea` available:
In `Fill(List<Part> groupParts, Box workArea)` (line 130):
```csharp
// was: var best = FillPattern(engine, groupParts, angles);
var best = FillPattern(engine, groupParts, angles, workArea);
```
In `FillWithPairs` (line 216):
```csharp
// was: var filled = FillPattern(engine, pairParts, angles);
var filled = FillPattern(engine, pairParts, angles, workArea);
```
- [ ] **Step 8: Update FillWithPairs to use FillScore**
Replace the `ConcurrentBag` and comparison logic (lines 208-228):
```csharp
var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List<Part> parts)>();
System.Threading.Tasks.Parallel.For(0, candidates.Count, i =>
{
var result = candidates[i];
var pairParts = result.BuildParts(item.Drawing);
var angles = RotationAnalysis.FindHullEdgeAngles(pairParts);
var engine = new FillLinear(workArea, Plate.PartSpacing);
var filled = FillPattern(engine, pairParts, angles, workArea);
if (filled != null && filled.Count > 0)
resultBag.Add((FillScore.Compute(filled, workArea), filled));
});
List<Part> best = null;
var bestScore = default(FillScore);
foreach (var (score, parts) in resultBag)
{
if (best == null || score > bestScore)
{
best = parts;
bestScore = score;
}
}
```
- [ ] **Step 9: Update TryRemainderImprovement call sites**
In `TryRemainderImprovement` (lines 438-456), the method already receives `workArea` — just update the internal `IsBetterFill` calls:
```csharp
// Line 447 — was: if (IsBetterFill(hResult, best))
if (IsBetterFill(hResult, best, workArea))
// Line 452 — was: if (IsBetterFill(vResult, best))
if (IsBetterFill(vResult, best, workArea))
```
- [ ] **Step 10: Update FillWithPairs debug logging**
Update the debug line after the `foreach` loop over `resultBag` (line 230):
```csharp
// was: Debug.WriteLine($"[FillWithPairs] Best pair result: {best?.Count ?? 0} parts");
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
```
Also update `FindBestFill` debug line (line 102):
```csharp
// was: Debug.WriteLine($"[FindBestFill] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default;
Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
```
- [ ] **Step 11: Build to verify compilation**
Run: `dotnet build OpenNest.Engine`
Expected: Build succeeded
- [ ] **Step 12: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat: use FillScore for fill result comparisons in NestEngine"
```
**Note — deliberately excluded comparisons:**
- `TryStripRefill` (line 424): `stripParts.Count <= lastCluster.Count` — this is a threshold check ("did the strip refill find more parts than the ragged cluster it replaced?"), not a quality comparison between two complete fills. FillScore is not meaningful here because we're comparing a fill result against a subset of existing parts.
- `FillLinear.FillRemainingStrip` (line 436): internal sub-fill within a strip where remnant quality doesn't apply. Count-only is correct at this level.
---
## Chunk 2: Expanded Remainder Rotations
### Task 3: Expand FillRemainingStrip rotation coverage
**Files:**
- Modify: `OpenNest.Engine/FillLinear.cs`
This is the change that fixes the 45→47 case. Currently `FillRemainingStrip` only tries rotations from the seed pattern. Adding 0° and 90° ensures the remainder strip can discover better orientations.
- [ ] **Step 1: Update FillRemainingStrip rotation loop**
Replace the rotation loop in `FillRemainingStrip` (lines 409-441) with:
```csharp
// Build rotation set: always try cardinal orientations (0° and 90°),
// plus any unique rotations from the seed pattern.
var filler = new FillLinear(remainingStrip, PartSpacing);
List<Part> best = null;
var rotations = new List<(Drawing drawing, double rotation)>();
// Cardinal rotations for each unique drawing.
var drawings = new List<Drawing>();
foreach (var seedPart in seedPattern.Parts)
{
var found = false;
foreach (var d in drawings)
{
if (d == seedPart.BaseDrawing)
{
found = true;
break;
}
}
if (!found)
drawings.Add(seedPart.BaseDrawing);
}
foreach (var drawing in drawings)
{
rotations.Add((drawing, 0));
rotations.Add((drawing, Angle.HalfPI));
}
// Add seed pattern rotations that aren't already covered.
foreach (var seedPart in seedPattern.Parts)
{
var skip = false;
foreach (var (d, r) in rotations)
{
if (d == seedPart.BaseDrawing && r.IsEqualTo(seedPart.Rotation))
{
skip = true;
break;
}
}
if (!skip)
rotations.Add((seedPart.BaseDrawing, seedPart.Rotation));
}
foreach (var (drawing, rotation) in rotations)
{
var h = filler.Fill(drawing, rotation, NestDirection.Horizontal);
var v = filler.Fill(drawing, rotation, NestDirection.Vertical);
if (h != null && h.Count > 0 && (best == null || h.Count > best.Count))
best = h;
if (v != null && v.Count > 0 && (best == null || v.Count > best.Count))
best = v;
}
```
Note: The comparison inside `FillRemainingStrip` stays as count-only. This is an internal sub-fill within a strip — remnant quality doesn't apply at this level.
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.Engine`
Expected: Build succeeded
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Engine/FillLinear.cs
git commit -m "feat: try cardinal rotations in FillRemainingStrip for better strip fills"
```
---
### Task 4: Full build and manual verification
- [ ] **Step 1: Build entire solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded with no errors
- [ ] **Step 2: Manual test with 4980 A24 PT02 nest**
Open the application, load the 4980 A24 PT02 drawing on a 60×120" plate, run Ctrl+F fill. Check Debug output for:
1. Pattern #1 (89.7°) should now get 47 parts via expanded remainder rotations
2. FillScore comparison should pick 47 over 45
3. Verify no overlaps in the result

View File

@@ -1,367 +0,0 @@
# OpenNest Test Harness 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:** Create a console app + MCP tool that builds and runs OpenNest.Engine against a nest file, writing debug output to a file for grepping and saving the resulting nest.
**Architecture:** A new `OpenNest.TestHarness` console app references Core, Engine, and IO. It loads a nest file, clears a plate, runs `NestEngine.Fill()`, writes `Debug.WriteLine` output to a timestamped log file via `TextWriterTraceListener`, prints a summary to stdout, and saves the nest. An MCP tool `test_engine` in OpenNest.Mcp shells out to `dotnet run --project OpenNest.TestHarness` and returns the summary + log file path.
**Tech Stack:** .NET 8, System.Diagnostics tracing, OpenNest.Core/Engine/IO
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Create | `OpenNest.TestHarness/OpenNest.TestHarness.csproj` | Console app project, references Core + Engine + IO. Forces `DEBUG` constant. |
| Create | `OpenNest.TestHarness/Program.cs` | Entry point: parse args, load nest, run fill, write debug to file, save nest |
| Modify | `OpenNest.sln` | Add new project to solution |
| Create | `OpenNest.Mcp/Tools/TestTools.cs` | MCP `test_engine` tool that shells out to the harness |
---
## Chunk 1: Console App + MCP Tool
### Task 1: Create the OpenNest.TestHarness project
**Files:**
- Create: `OpenNest.TestHarness/OpenNest.TestHarness.csproj`
- [ ] **Step 1: Create the project file**
Note: `DEBUG` is defined for all configurations so `Debug.WriteLine` output is always captured — that's the whole point of this tool.
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.TestHarness</RootNamespace>
<AssemblyName>OpenNest.TestHarness</AssemblyName>
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>
```
- [ ] **Step 2: Add project to solution**
```bash
dotnet sln OpenNest.sln add OpenNest.TestHarness/OpenNest.TestHarness.csproj
```
- [ ] **Step 3: Verify it builds**
```bash
dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj
```
Expected: Build succeeded (with warning about empty Program.cs — that's fine, we create it next).
---
### Task 2: Write the TestHarness Program.cs
**Files:**
- Create: `OpenNest.TestHarness/Program.cs`
The console app does:
1. Parse command-line args for nest file path, optional drawing name, plate index, output path
2. Create a timestamped log file and attach a `TextWriterTraceListener` so `Debug.WriteLine` goes to the file
3. Load the nest file via `NestReader`
4. Find the drawing and plate
5. Clear existing parts from the plate
6. Run `NestEngine.Fill()`
7. Print summary (part count, utilization, log file path) to stdout
8. Save the nest via `NestWriter`
- [ ] **Step 1: Write Program.cs**
```csharp
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.IO;
// Parse arguments.
var nestFile = args.Length > 0 ? args[0] : null;
var drawingName = (string)null;
var plateIndex = 0;
var outputFile = (string)null;
for (var i = 1; i < args.Length; i++)
{
switch (args[i])
{
case "--drawing" when i + 1 < args.Length:
drawingName = args[++i];
break;
case "--plate" when i + 1 < args.Length:
plateIndex = int.Parse(args[++i]);
break;
case "--output" when i + 1 < args.Length:
outputFile = args[++i];
break;
}
}
if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile))
{
Console.Error.WriteLine("Usage: OpenNest.TestHarness <nest-file> [--drawing <name>] [--plate <index>] [--output <path>]");
Console.Error.WriteLine(" nest-file Path to a .zip nest file");
Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)");
Console.Error.WriteLine(" --plate Plate index to fill (default: 0)");
Console.Error.WriteLine(" --output Output nest file path (default: <input>-result.zip)");
return 1;
}
// Set up debug log file.
var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs");
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
var logWriter = new StreamWriter(logFile) { AutoFlush = true };
Trace.Listeners.Add(new TextWriterTraceListener(logWriter));
// Load nest.
var reader = new NestReader(nestFile);
var nest = reader.Read();
if (nest.Plates.Count == 0)
{
Console.Error.WriteLine("Error: nest file contains no plates");
return 1;
}
if (plateIndex >= nest.Plates.Count)
{
Console.Error.WriteLine($"Error: plate index {plateIndex} out of range (0-{nest.Plates.Count - 1})");
return 1;
}
var plate = nest.Plates[plateIndex];
// Find drawing.
var drawing = drawingName != null
? nest.Drawings.FirstOrDefault(d => d.Name == drawingName)
: nest.Drawings.FirstOrDefault();
if (drawing == null)
{
Console.Error.WriteLine(drawingName != null
? $"Error: drawing '{drawingName}' not found"
: "Error: nest file contains no drawings");
return 1;
}
// Clear existing parts.
var existingCount = plate.Parts.Count;
plate.Parts.Clear();
Console.WriteLine($"Nest: {nest.Name}");
Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Height:F1}), spacing={plate.PartSpacing:F2}");
Console.WriteLine($"Drawing: {drawing.Name}");
Console.WriteLine($"Cleared {existingCount} existing parts");
Console.WriteLine("---");
// Run fill.
var sw = Stopwatch.StartNew();
var engine = new NestEngine(plate);
var item = new NestItem { Drawing = drawing, Quantity = 0 };
var success = engine.Fill(item);
sw.Stop();
// Flush and close the log.
Trace.Flush();
logWriter.Dispose();
// Print results.
Console.WriteLine($"Result: {(success ? "success" : "failed")}");
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms");
Console.WriteLine($"Debug log: {logFile}");
// Save output.
if (outputFile == null)
{
var dir = Path.GetDirectoryName(nestFile);
var name = Path.GetFileNameWithoutExtension(nestFile);
outputFile = Path.Combine(dir, $"{name}-result.zip");
}
var writer = new NestWriter(nest);
writer.Write(outputFile);
Console.WriteLine($"Saved: {outputFile}");
return 0;
```
- [ ] **Step 2: Build the project**
```bash
dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj
```
Expected: Build succeeded with 0 errors.
- [ ] **Step 3: Run a smoke test with the real nest file**
```bash
dotnet run --project OpenNest.TestHarness -- "C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip"
```
Expected: Prints nest info and results to stdout, writes debug log file, saves a `-result.zip` file.
- [ ] **Step 4: Commit**
```bash
git add OpenNest.TestHarness/ OpenNest.sln
git commit -m "feat: add OpenNest.TestHarness console app for engine testing"
```
---
### Task 3: Add the MCP test_engine tool
**Files:**
- Create: `OpenNest.Mcp/Tools/TestTools.cs`
The MCP tool:
1. Accepts optional `nestFile`, `drawingName`, `plateIndex` parameters
2. Runs `dotnet run --project <path> -- <args>` capturing stdout (results) and stderr (errors only)
3. Returns the summary + debug log file path (Claude can then Grep the log file)
Note: The solution root is hard-coded because the MCP server is published to `~/.claude/mcp/OpenNest.Mcp/`, far from the source tree.
- [ ] **Step 1: Create TestTools.cs**
```csharp
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Text;
using ModelContextProtocol.Server;
namespace OpenNest.Mcp.Tools
{
[McpServerToolType]
public class TestTools
{
private const string SolutionRoot = @"C:\Users\AJ\Desktop\Projects\OpenNest";
private static readonly string HarnessProject = Path.Combine(
SolutionRoot, "OpenNest.TestHarness", "OpenNest.TestHarness.csproj");
[McpServerTool(Name = "test_engine")]
[Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")]
public string TestEngine(
[Description("Path to the nest .zip file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip",
[Description("Drawing name to fill with (default: first drawing)")] string drawingName = null,
[Description("Plate index to fill (default: 0)")] int plateIndex = 0,
[Description("Output nest file path (default: <input>-result.zip)")] string outputFile = null)
{
if (!File.Exists(nestFile))
return $"Error: nest file not found: {nestFile}";
var processArgs = new StringBuilder();
processArgs.Append($"\"{nestFile}\"");
if (!string.IsNullOrEmpty(drawingName))
processArgs.Append($" --drawing \"{drawingName}\"");
processArgs.Append($" --plate {plateIndex}");
if (!string.IsNullOrEmpty(outputFile))
processArgs.Append($" --output \"{outputFile}\"");
var psi = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"run --project \"{HarnessProject}\" -- {processArgs}",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = SolutionRoot
};
var sb = new StringBuilder();
try
{
using var process = Process.Start(psi);
var stderrTask = process.StandardError.ReadToEndAsync();
var stdout = process.StandardOutput.ReadToEnd();
process.WaitForExit(120_000);
var stderr = stderrTask.Result;
if (!string.IsNullOrWhiteSpace(stdout))
sb.Append(stdout.TrimEnd());
if (!string.IsNullOrWhiteSpace(stderr))
{
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("=== Errors ===");
sb.Append(stderr.TrimEnd());
}
if (process.ExitCode != 0)
{
sb.AppendLine();
sb.AppendLine($"Process exited with code {process.ExitCode}");
}
}
catch (System.Exception ex)
{
sb.AppendLine($"Error running test harness: {ex.Message}");
}
return sb.ToString();
}
}
}
```
- [ ] **Step 2: Build the MCP project**
```bash
dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj
```
Expected: Build succeeded.
- [ ] **Step 3: Republish the MCP server**
```bash
dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"
```
Expected: Publish succeeded. The MCP server now has the `test_engine` tool.
- [ ] **Step 4: Commit**
```bash
git add OpenNest.Mcp/Tools/TestTools.cs
git commit -m "feat: add test_engine MCP tool for iterative engine testing"
```
---
## Usage
After implementation, the workflow for iterating on FillLinear becomes:
1. **Other session** makes changes to `FillLinear.cs` or `NestEngine.cs`
2. **This session** calls `test_engine` (no args needed — defaults to the test nest file)
3. The tool builds the latest code and runs it in a fresh process
4. Returns: part count, utilization, timing, and **debug log file path**
5. Grep the log file for specific patterns (e.g., `[FillLinear]`, `[FindBestFill]`)
6. Repeat

View File

@@ -1,281 +0,0 @@
# Contour Re-Indexing 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 entity-splitting primitives (`Line.SplitAt`, `Arc.SplitAt`), a `Shape.ReindexAt` method, and wire them into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs.
**Architecture:** Bottom-up — build splitting primitives first, then the reindexing algorithm on top, then wire into the strategy. Each layer depends only on the one below it.
**Tech Stack:** C# / .NET 8, OpenNest.Core (Geometry + CNC namespaces)
**Spec:** `docs/superpowers/specs/2026-03-12-contour-reindexing-design.md`
---
## File Structure
| File | Change | Responsibility |
|------|--------|----------------|
| `OpenNest.Core/Geometry/Line.cs` | Add method | `SplitAt(Vector)` — split a line at a point into two halves |
| `OpenNest.Core/Geometry/Arc.cs` | Add method | `SplitAt(Vector)` — split an arc at a point into two halves |
| `OpenNest.Core/Geometry/Shape.cs` | Add method | `ReindexAt(Vector, Entity)` — reorder a closed contour to start at a given point |
| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add method + modify | `ConvertShapeToMoves` + replace two `NotImplementedException` blocks |
---
## Chunk 1: Splitting Primitives
### Task 1: Add `Line.SplitAt(Vector)`
**Files:**
- Modify: `OpenNest.Core/Geometry/Line.cs`
- [ ] **Step 1: Add `SplitAt` method to `Line`**
Add the following method to the `Line` class (after the existing `ClosestPointTo` method):
```csharp
public (Line first, Line second) SplitAt(Vector point)
{
var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon
? null
: new Line(StartPoint, point);
var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon
? null
: new Line(point, EndPoint);
return (first, second);
}
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
Expected: Build succeeded, 0 errors
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Core/Geometry/Line.cs
git commit -m "feat: add Line.SplitAt(Vector) splitting primitive"
```
### Task 2: Add `Arc.SplitAt(Vector)`
**Files:**
- Modify: `OpenNest.Core/Geometry/Arc.cs`
- [ ] **Step 1: Add `SplitAt` method to `Arc`**
Add the following method to the `Arc` class (after the existing `EndPoint` method):
```csharp
public (Arc first, Arc second) SplitAt(Vector point)
{
if (point.DistanceTo(StartPoint()) < Tolerance.Epsilon)
return (null, new Arc(Center, Radius, StartAngle, EndAngle, IsReversed));
if (point.DistanceTo(EndPoint()) < Tolerance.Epsilon)
return (new Arc(Center, Radius, StartAngle, EndAngle, IsReversed), null);
var splitAngle = Angle.NormalizeRad(Center.AngleTo(point));
var firstArc = new Arc(Center, Radius, StartAngle, splitAngle, IsReversed);
var secondArc = new Arc(Center, Radius, splitAngle, EndAngle, IsReversed);
return (firstArc, secondArc);
}
```
Key details from spec:
- Compare distances to `StartPoint()`/`EndPoint()` rather than comparing angles (avoids 0/2π wrap-around issues).
- `splitAngle` is computed from `Center.AngleTo(point)`, normalized.
- Both halves preserve center, radius, and `IsReversed` direction.
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
Expected: Build succeeded, 0 errors
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Core/Geometry/Arc.cs
git commit -m "feat: add Arc.SplitAt(Vector) splitting primitive"
```
---
## Chunk 2: Shape.ReindexAt
### Task 3: Add `Shape.ReindexAt(Vector, Entity)`
**Files:**
- Modify: `OpenNest.Core/Geometry/Shape.cs`
- [ ] **Step 1: Add `ReindexAt` method to `Shape`**
Add the following method to the `Shape` class (after the existing `ClosestPointTo(Vector, out Entity)` method around line 201):
```csharp
public Shape ReindexAt(Vector point, Entity entity)
{
// Circle case: return a new shape with just the circle
if (entity is Circle)
{
var result = new Shape();
result.Entities.Add(entity);
return result;
}
var i = Entities.IndexOf(entity);
if (i < 0)
throw new ArgumentException("Entity not found in shape", nameof(entity));
// Split the entity at the point
Entity firstHalf = null;
Entity secondHalf = null;
if (entity is Line line)
{
var (f, s) = line.SplitAt(point);
firstHalf = f;
secondHalf = s;
}
else if (entity is Arc arc)
{
var (f, s) = arc.SplitAt(point);
firstHalf = f;
secondHalf = s;
}
// Build reindexed entity list
var entities = new List<Entity>();
// secondHalf of split entity (if not null)
if (secondHalf != null)
entities.Add(secondHalf);
// Entities after the split index (wrapping)
for (var j = i + 1; j < Entities.Count; j++)
entities.Add(Entities[j]);
// Entities before the split index (wrapping)
for (var j = 0; j < i; j++)
entities.Add(Entities[j]);
// firstHalf of split entity (if not null)
if (firstHalf != null)
entities.Add(firstHalf);
var reindexed = new Shape();
reindexed.Entities.AddRange(entities);
return reindexed;
}
```
The `Shape` class already imports `System` and `System.Collections.Generic`, so no new usings needed.
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
Expected: Build succeeded, 0 errors
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Core/Geometry/Shape.cs
git commit -m "feat: add Shape.ReindexAt(Vector, Entity) for contour reordering"
```
---
## Chunk 3: Wire into ContourCuttingStrategy
### Task 4: Add `ConvertShapeToMoves` and replace stubs
**Files:**
- Modify: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs`
- [ ] **Step 1: Add `ConvertShapeToMoves` private method**
Add the following private method to `ContourCuttingStrategy` (after the existing `SelectLeadOut` method, before the closing brace of the class):
```csharp
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint)
{
var moves = new List<ICode>();
foreach (var entity in shape.Entities)
{
if (entity is Line line)
{
moves.Add(new LinearMove(line.EndPoint));
}
else if (entity is Arc arc)
{
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW));
}
else if (entity is Circle circle)
{
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation));
}
else
{
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}");
}
}
return moves;
}
```
This matches the `ConvertGeometry.AddArc`/`AddCircle`/`AddLine` patterns but without `RapidMove` between entities (they are contiguous in a reindexed shape).
- [ ] **Step 2: Replace cutout `NotImplementedException` (line 41)**
In the `Apply` method, replace:
```csharp
// Contour re-indexing: split shape entities at closestPt so cutting
// starts there, convert to ICode, and add to result.Codes
throw new System.NotImplementedException("Contour re-indexing not yet implemented");
```
With:
```csharp
var reindexed = cutout.ReindexAt(closestPt, entity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
```
- [ ] **Step 3: Replace perimeter `NotImplementedException` (line 57)**
In the `Apply` method, replace:
```csharp
throw new System.NotImplementedException("Contour re-indexing not yet implemented");
```
With:
```csharp
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
```
- [ ] **Step 4: Build to verify**
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
Expected: Build succeeded, 0 errors
- [ ] **Step 5: Build full solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded, 0 errors
- [ ] **Step 6: Commit**
```bash
git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
git commit -m "feat: wire contour re-indexing into ContourCuttingStrategy.Apply()"
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,767 +0,0 @@
# Nest File Format v2 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:** Replace the XML+G-code nest file format with a single `nest.json` metadata file plus `programs/` folder inside the ZIP archive.
**Architecture:** Add a `NestFormat` static class containing DTO records and shared JSON options. Rewrite `NestWriter` to serialize DTOs to JSON and write programs under `programs/`. Rewrite `NestReader` to deserialize JSON and read programs from `programs/`. Public API unchanged.
**Tech Stack:** `System.Text.Json` (built into .NET 8, no new packages needed)
**Spec:** `docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md`
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Create | `OpenNest.IO/NestFormat.cs` | DTO records for JSON serialization + shared `JsonSerializerOptions` |
| Rewrite | `OpenNest.IO/NestWriter.cs` | Serialize nest to JSON + write programs to `programs/` folder |
| Rewrite | `OpenNest.IO/NestReader.cs` | Deserialize JSON + read programs from `programs/` folder |
No other files change. `ProgramReader.cs`, `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs`, all domain model classes, and all caller sites remain untouched.
---
## Chunk 1: DTO Records and JSON Options
### Task 1: Create NestFormat.cs with DTO records
**Files:**
- Create: `OpenNest.IO/NestFormat.cs`
These DTOs are the JSON shape — flat records that map 1:1 with the spec's JSON schema. They live in `OpenNest.IO` because they're serialization concerns, not domain model.
- [ ] **Step 1: Create `NestFormat.cs`**
```csharp
using System.Text.Json;
namespace OpenNest.IO
{
public static class NestFormat
{
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public record NestDto
{
public int Version { get; init; } = 2;
public string Name { get; init; } = "";
public string Units { get; init; } = "Inches";
public string Customer { get; init; } = "";
public string DateCreated { get; init; } = "";
public string DateLastModified { get; init; } = "";
public string Notes { get; init; } = "";
public PlateDefaultsDto PlateDefaults { get; init; } = new();
public List<DrawingDto> Drawings { get; init; } = new();
public List<PlateDto> Plates { get; init; } = new();
}
public record PlateDefaultsDto
{
public SizeDto Size { get; init; } = new();
public double Thickness { get; init; }
public int Quadrant { get; init; } = 1;
public double PartSpacing { get; init; }
public MaterialDto Material { get; init; } = new();
public SpacingDto EdgeSpacing { get; init; } = new();
}
public record DrawingDto
{
public int Id { get; init; }
public string Name { get; init; } = "";
public string Customer { get; init; } = "";
public ColorDto Color { get; init; } = new();
public QuantityDto Quantity { get; init; } = new();
public int Priority { get; init; }
public ConstraintsDto Constraints { get; init; } = new();
public MaterialDto Material { get; init; } = new();
public SourceDto Source { get; init; } = new();
}
public record PlateDto
{
public int Id { get; init; }
public SizeDto Size { get; init; } = new();
public double Thickness { get; init; }
public int Quadrant { get; init; } = 1;
public int Quantity { get; init; } = 1;
public double PartSpacing { get; init; }
public MaterialDto Material { get; init; } = new();
public SpacingDto EdgeSpacing { get; init; } = new();
public List<PartDto> Parts { get; init; } = new();
}
public record PartDto
{
public int DrawingId { get; init; }
public double X { get; init; }
public double Y { get; init; }
public double Rotation { get; init; }
}
public record SizeDto
{
public double Width { get; init; }
public double Height { get; init; }
}
public record MaterialDto
{
public string Name { get; init; } = "";
public string Grade { get; init; } = "";
public double Density { get; init; }
}
public record SpacingDto
{
public double Left { get; init; }
public double Top { get; init; }
public double Right { get; init; }
public double Bottom { get; init; }
}
public record ColorDto
{
public int A { get; init; } = 255;
public int R { get; init; }
public int G { get; init; }
public int B { get; init; }
}
public record QuantityDto
{
public int Required { get; init; }
}
public record ConstraintsDto
{
public double StepAngle { get; init; }
public double StartAngle { get; init; }
public double EndAngle { get; init; }
public bool Allow180Equivalent { get; init; }
}
public record SourceDto
{
public string Path { get; init; } = "";
public OffsetDto Offset { get; init; } = new();
}
public record OffsetDto
{
public double X { get; init; }
public double Y { get; init; }
}
}
}
```
- [ ] **Step 2: Build to verify DTOs compile**
Run: `dotnet build OpenNest.IO/OpenNest.IO.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add OpenNest.IO/NestFormat.cs
git commit -m "feat: add NestFormat DTOs for JSON nest file format v2"
```
---
## Chunk 2: Rewrite NestWriter
### Task 2: Rewrite NestWriter to use JSON serialization
**Files:**
- Rewrite: `OpenNest.IO/NestWriter.cs`
The writer keeps the same public API: `NestWriter(Nest nest)` constructor and `bool Write(string file)`. Internally it builds a `NestDto` from the domain model, serializes it to `nest.json`, and writes each drawing's program to `programs/program-N`.
The G-code writing methods (`WriteDrawing`, `GetCodeString`, `GetLayerString`) are preserved exactly — they write program G-code to streams, which is unchanged. The `WritePlate` method and all XML methods (`AddNestInfo`, `AddPlateInfo`, `AddDrawingInfo`) are removed.
- [ ] **Step 1: Rewrite `NestWriter.cs`**
Replace the entire file. Key changes:
- Remove `using System.Xml`
- Add `using System.Text.Json`
- Remove `AddNestInfo()`, `AddPlateInfo()`, `AddDrawingInfo()`, `AddPlates()`, `WritePlate()` methods
- Add `BuildNestDto()` method that maps domain model → DTOs
- `Write()` now serializes `NestDto` to `nest.json` and writes programs to `programs/program-N`
- Keep `WriteDrawing()`, `GetCodeString()`, `GetLayerString()` exactly as-is
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using OpenNest.CNC;
using OpenNest.Math;
using static OpenNest.IO.NestFormat;
namespace OpenNest.IO
{
public sealed class NestWriter
{
private const int OutputPrecision = 10;
private const string CoordinateFormat = "0.##########";
private readonly Nest nest;
private Dictionary<int, Drawing> drawingDict;
public NestWriter(Nest nest)
{
this.drawingDict = new Dictionary<int, Drawing>();
this.nest = nest;
}
public bool Write(string file)
{
nest.DateLastModified = DateTime.Now;
SetDrawingIds();
using var fileStream = new FileStream(file, FileMode.Create);
using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create);
WriteNestJson(zipArchive);
WritePrograms(zipArchive);
return true;
}
private void SetDrawingIds()
{
var id = 1;
foreach (var drawing in nest.Drawings)
{
drawingDict.Add(id, drawing);
id++;
}
}
private void WriteNestJson(ZipArchive zipArchive)
{
var dto = BuildNestDto();
var json = JsonSerializer.Serialize(dto, JsonOptions);
var entry = zipArchive.CreateEntry("nest.json");
using var stream = entry.Open();
using var writer = new StreamWriter(stream, Encoding.UTF8);
writer.Write(json);
}
private NestDto BuildNestDto()
{
return new NestDto
{
Version = 2,
Name = nest.Name ?? "",
Units = nest.Units.ToString(),
Customer = nest.Customer ?? "",
DateCreated = nest.DateCreated.ToString("o"),
DateLastModified = nest.DateLastModified.ToString("o"),
Notes = nest.Notes ?? "",
PlateDefaults = BuildPlateDefaultsDto(),
Drawings = BuildDrawingDtos(),
Plates = BuildPlateDtos()
};
}
private PlateDefaultsDto BuildPlateDefaultsDto()
{
var pd = nest.PlateDefaults;
return new PlateDefaultsDto
{
Size = new SizeDto { Width = pd.Size.Width, Height = pd.Size.Height },
Thickness = pd.Thickness,
Quadrant = pd.Quadrant,
PartSpacing = pd.PartSpacing,
Material = new MaterialDto
{
Name = pd.Material.Name ?? "",
Grade = pd.Material.Grade ?? "",
Density = pd.Material.Density
},
EdgeSpacing = new SpacingDto
{
Left = pd.EdgeSpacing.Left,
Top = pd.EdgeSpacing.Top,
Right = pd.EdgeSpacing.Right,
Bottom = pd.EdgeSpacing.Bottom
}
};
}
private List<DrawingDto> BuildDrawingDtos()
{
var list = new List<DrawingDto>();
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{
var d = kvp.Value;
list.Add(new DrawingDto
{
Id = kvp.Key,
Name = d.Name ?? "",
Customer = d.Customer ?? "",
Color = new ColorDto { A = d.Color.A, R = d.Color.R, G = d.Color.G, B = d.Color.B },
Quantity = new QuantityDto { Required = d.Quantity.Required },
Priority = d.Priority,
Constraints = new ConstraintsDto
{
StepAngle = d.Constraints.StepAngle,
StartAngle = d.Constraints.StartAngle,
EndAngle = d.Constraints.EndAngle,
Allow180Equivalent = d.Constraints.Allow180Equivalent
},
Material = new MaterialDto
{
Name = d.Material.Name ?? "",
Grade = d.Material.Grade ?? "",
Density = d.Material.Density
},
Source = new SourceDto
{
Path = d.Source.Path ?? "",
Offset = new OffsetDto { X = d.Source.Offset.X, Y = d.Source.Offset.Y }
}
});
}
return list;
}
private List<PlateDto> BuildPlateDtos()
{
var list = new List<PlateDto>();
for (var i = 0; i < nest.Plates.Count; i++)
{
var plate = nest.Plates[i];
var parts = new List<PartDto>();
foreach (var part in plate.Parts)
{
var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault();
parts.Add(new PartDto
{
DrawingId = match.Key,
X = part.Location.X,
Y = part.Location.Y,
Rotation = part.Rotation
});
}
list.Add(new PlateDto
{
Id = i + 1,
Size = new SizeDto { Width = plate.Size.Width, Height = plate.Size.Height },
Thickness = plate.Thickness,
Quadrant = plate.Quadrant,
Quantity = plate.Quantity,
PartSpacing = plate.PartSpacing,
Material = new MaterialDto
{
Name = plate.Material.Name ?? "",
Grade = plate.Material.Grade ?? "",
Density = plate.Material.Density
},
EdgeSpacing = new SpacingDto
{
Left = plate.EdgeSpacing.Left,
Top = plate.EdgeSpacing.Top,
Right = plate.EdgeSpacing.Right,
Bottom = plate.EdgeSpacing.Bottom
},
Parts = parts
});
}
return list;
}
private void WritePrograms(ZipArchive zipArchive)
{
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{
var name = $"programs/program-{kvp.Key}";
var stream = new MemoryStream();
WriteDrawing(stream, kvp.Value);
var entry = zipArchive.CreateEntry(name);
using var entryStream = entry.Open();
stream.CopyTo(entryStream);
}
}
private void WriteDrawing(Stream stream, Drawing drawing)
{
var program = drawing.Program;
var writer = new StreamWriter(stream);
writer.AutoFlush = true;
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
for (var i = 0; i < drawing.Program.Length; ++i)
{
var code = drawing.Program[i];
writer.WriteLine(GetCodeString(code));
}
stream.Position = 0;
}
private string GetCodeString(ICode code)
{
switch (code.Type)
{
case CodeType.ArcMove:
{
var sb = new StringBuilder();
var arcMove = (ArcMove)code;
var x = System.Math.Round(arcMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat);
var y = System.Math.Round(arcMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat);
var i = System.Math.Round(arcMove.CenterPoint.X, OutputPrecision).ToString(CoordinateFormat);
var j = System.Math.Round(arcMove.CenterPoint.Y, OutputPrecision).ToString(CoordinateFormat);
if (arcMove.Rotation == RotationType.CW)
sb.Append(string.Format("G02X{0}Y{1}I{2}J{3}", x, y, i, j));
else
sb.Append(string.Format("G03X{0}Y{1}I{2}J{3}", x, y, i, j));
if (arcMove.Layer != LayerType.Cut)
sb.Append(GetLayerString(arcMove.Layer));
return sb.ToString();
}
case CodeType.Comment:
{
var comment = (Comment)code;
return ":" + comment.Value;
}
case CodeType.LinearMove:
{
var sb = new StringBuilder();
var linearMove = (LinearMove)code;
sb.Append(string.Format("G01X{0}Y{1}",
System.Math.Round(linearMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
System.Math.Round(linearMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat)));
if (linearMove.Layer != LayerType.Cut)
sb.Append(GetLayerString(linearMove.Layer));
return sb.ToString();
}
case CodeType.RapidMove:
{
var rapidMove = (RapidMove)code;
return string.Format("G00X{0}Y{1}",
System.Math.Round(rapidMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
System.Math.Round(rapidMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat));
}
case CodeType.SetFeedrate:
{
var setFeedrate = (Feedrate)code;
return "F" + setFeedrate.Value;
}
case CodeType.SetKerf:
{
var setKerf = (Kerf)code;
switch (setKerf.Value)
{
case KerfType.None: return "G40";
case KerfType.Left: return "G41";
case KerfType.Right: return "G42";
}
break;
}
case CodeType.SubProgramCall:
{
var subProgramCall = (SubProgramCall)code;
break;
}
}
return string.Empty;
}
private string GetLayerString(LayerType layer)
{
switch (layer)
{
case LayerType.Display:
return ":DISPLAY";
case LayerType.Leadin:
return ":LEADIN";
case LayerType.Leadout:
return ":LEADOUT";
case LayerType.Scribe:
return ":SCRIBE";
default:
return string.Empty;
}
}
}
}
```
- [ ] **Step 2: Build to verify NestWriter compiles**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add OpenNest.IO/NestWriter.cs
git commit -m "feat: rewrite NestWriter to use JSON format v2"
```
---
## Chunk 3: Rewrite NestReader
### Task 3: Rewrite NestReader to use JSON deserialization
**Files:**
- Rewrite: `OpenNest.IO/NestReader.cs`
The reader keeps the same public API: `NestReader(string file)`, `NestReader(Stream stream)`, and `Nest Read()`. Internally it reads `nest.json`, deserializes to `NestDto`, reads programs from `programs/program-N`, and assembles the domain model.
All XML parsing, plate G-code parsing, dictionary-linking (`LinkProgramsToDrawings`, `LinkPartsToPlates`), and the helper enums/methods are removed.
- [ ] **Step 1: Rewrite `NestReader.cs`**
Replace the entire file:
```csharp
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using OpenNest.CNC;
using OpenNest.Geometry;
using static OpenNest.IO.NestFormat;
namespace OpenNest.IO
{
public sealed class NestReader
{
private readonly Stream stream;
private readonly ZipArchive zipArchive;
public NestReader(string file)
{
stream = new FileStream(file, FileMode.Open, FileAccess.Read);
zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
}
public NestReader(Stream stream)
{
this.stream = stream;
zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
}
public Nest Read()
{
var nestJson = ReadEntry("nest.json");
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
var programs = ReadPrograms(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs);
var nest = BuildNest(dto, drawingMap);
zipArchive.Dispose();
stream.Close();
return nest;
}
private string ReadEntry(string name)
{
var entry = zipArchive.GetEntry(name)
?? throw new InvalidDataException($"Nest file is missing required entry '{name}'.");
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
return reader.ReadToEnd();
}
private Dictionary<int, Program> ReadPrograms(int count)
{
var programs = new Dictionary<int, Program>();
for (var i = 1; i <= count; i++)
{
var entry = zipArchive.GetEntry($"programs/program-{i}");
if (entry == null) continue;
using var entryStream = entry.Open();
var memStream = new MemoryStream();
entryStream.CopyTo(memStream);
memStream.Position = 0;
var reader = new ProgramReader(memStream);
programs[i] = reader.Read();
}
return programs;
}
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs)
{
var map = new Dictionary<int, Drawing>();
foreach (var d in dto.Drawings)
{
var drawing = new Drawing(d.Name);
drawing.Customer = d.Customer;
drawing.Color = Color.FromArgb(d.Color.A, d.Color.R, d.Color.G, d.Color.B);
drawing.Quantity.Required = d.Quantity.Required;
drawing.Priority = d.Priority;
drawing.Constraints.StepAngle = d.Constraints.StepAngle;
drawing.Constraints.StartAngle = d.Constraints.StartAngle;
drawing.Constraints.EndAngle = d.Constraints.EndAngle;
drawing.Constraints.Allow180Equivalent = d.Constraints.Allow180Equivalent;
drawing.Material = new Material(d.Material.Name, d.Material.Grade, d.Material.Density);
drawing.Source.Path = d.Source.Path;
drawing.Source.Offset = new Vector(d.Source.Offset.X, d.Source.Offset.Y);
if (programs.TryGetValue(d.Id, out var pgm))
drawing.Program = pgm;
map[d.Id] = drawing;
}
return map;
}
private Nest BuildNest(NestDto dto, Dictionary<int, Drawing> drawingMap)
{
var nest = new Nest();
nest.Name = dto.Name;
Units units;
if (Enum.TryParse(dto.Units, true, out units))
nest.Units = units;
nest.Customer = dto.Customer;
nest.DateCreated = DateTime.Parse(dto.DateCreated);
nest.DateLastModified = DateTime.Parse(dto.DateLastModified);
nest.Notes = dto.Notes;
// Plate defaults
var pd = dto.PlateDefaults;
nest.PlateDefaults.Size = new Size(pd.Size.Width, pd.Size.Height);
nest.PlateDefaults.Thickness = pd.Thickness;
nest.PlateDefaults.Quadrant = pd.Quadrant;
nest.PlateDefaults.PartSpacing = pd.PartSpacing;
nest.PlateDefaults.Material = new Material(pd.Material.Name, pd.Material.Grade, pd.Material.Density);
nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top);
// Drawings
foreach (var d in drawingMap.OrderBy(k => k.Key))
nest.Drawings.Add(d.Value);
// Plates
foreach (var p in dto.Plates.OrderBy(p => p.Id))
{
var plate = new Plate();
plate.Size = new Size(p.Size.Width, p.Size.Height);
plate.Thickness = p.Thickness;
plate.Quadrant = p.Quadrant;
plate.Quantity = p.Quantity;
plate.PartSpacing = p.PartSpacing;
plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density);
plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top);
foreach (var partDto in p.Parts)
{
if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg))
continue;
var part = new Part(dwg);
part.Rotate(partDto.Rotation);
part.Offset(new Vector(partDto.X, partDto.Y));
plate.Parts.Add(part);
}
nest.Plates.Add(plate);
}
return nest;
}
}
}
```
- [ ] **Step 2: Build to verify NestReader compiles**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add OpenNest.IO/NestReader.cs
git commit -m "feat: rewrite NestReader to use JSON format v2"
```
---
## Chunk 4: Smoke Test
### Task 4: Manual smoke test via OpenNest.Console
**Files:** None modified — this is a verification step.
Use the `OpenNest.Console` project (or the MCP server) to verify round-trip: create a nest, save it, reload it, confirm data is intact.
- [ ] **Step 1: Build the full solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded with no errors.
- [ ] **Step 2: Round-trip test via MCP tools**
Use the OpenNest MCP tools to:
1. Create a drawing (e.g. a rectangle via `create_drawing`)
2. Create a plate via `create_plate`
3. Fill the plate via `fill_plate`
4. Save the nest via the console app or verify `get_plate_info` shows parts
5. If a nest file exists on disk, load it with `load_nest` and verify `get_plate_info` returns the same data
- [ ] **Step 3: Inspect the ZIP contents**
Unzip a saved nest file and verify:
- `nest.json` exists with correct structure
- `programs/program-1` (etc.) exist with G-code content
- No `info`, `drawing-info`, `plate-info`, or `plate-NNN` files exist
- [ ] **Step 4: Commit any fixes**
If any issues were found and fixed, commit them:
```bash
git add -u
git commit -m "fix: address issues found during nest format v2 smoke test"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More