Compare commits

..

250 Commits

Author SHA1 Message Date
f46bcd4e4b feat: add filter toggle to remnant viewer for showing all remnants
The remnant viewer previously always filtered by smallest part dimension,
hiding large remnants that were narrower than the smallest part. Added a
"Filter by part size" checkbox (on by default) so users can toggle this
off to see all remnants regardless of size.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:39:03 -04:00
f29f086080 feat: add pierce point visualization and rename shape dimensions to Length/Width
Add toggleable pierce point drawing to PlateView that shows small red
filled circles at each rapid move endpoint (where cutting begins). Wire
through View menu, EditNestForm toggle, and MainForm handler.

Also rename RectangleShape/RoundedRectangleShape Width/Height to
Length/Width for consistency with CNC conventions, update MCP tools and
tests accordingly. Fix SplitDrawingForm designer layout ordering and
EntityView bend line selection styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:26:49 -04:00
19001ea5be fix: prevent GeometryOptimizer from merging semicircular arcs into invalid arc
After splitting a drawing with a circular hole, CadConverterForm writes
the split piece to DXF and re-imports it. The circle (decomposed into
two semicircular arcs by DrawingSplitter) was being incorrectly merged
back into a single zero-sweep arc by GeometryOptimizer.TryJoinArcs
during reimport.

Root cause: TryJoinArcs mutated input arc angles in-place and didn't
guard against merging two arcs that together form a full circle. When
arc2 had startAngle=π, endAngle=0 (DXF wrap-around from 360°→0°), the
mutation produced startAngle=-π, and the merge created an arc with
startAngle=π, endAngle=π (zero sweep), losing half the hole.

Fix: use local variables instead of mutating inputs, require arcs to be
adjacent (endpoints touching) rather than just overlapping, and refuse
to merge when the combined sweep would be a full circle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:34:38 -04:00
269746b8a4 feat: fit-to-plate splits use full plate work area with preview line
FitToPlate now places split lines at usable-width intervals so each
piece (except the last) fills the entire plate work area. Also adds a
live yellow preview line that follows the cursor during manual split
line placement, and piece dimension labels in the preview regions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:27:15 -04:00
35218a7435 feat: wire manual bend line pick → dialog → promote flow in CadConverterForm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:38:12 -04:00
bd973c5f79 feat: add 'Add Bend Line' toggle and pick mode UI to FilterPanel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:36:40 -04:00
d042bd1844 feat: add bend line pick mode with hit-testing to EntityView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:35:30 -04:00
ebdd489fdc feat: add BendLineDialog for manual bend line property entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:34:14 -04:00
885dec5f0e feat: add SourceEntity property to Bend for manual pick tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:33:24 -04:00
6106df929e feat: add F key shortcut for zoom-to-fit on EntityView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:53:38 -04:00
965b9c8c1a feat: change nest name format to N{YY}-{base30} for brevity and readability
Uses 2-digit year + 3-char base-30 sequence (ambiguous chars 0OI1l8B excluded),
supporting ~27k nests/year. E.g. N26-4E2 instead of N0325-126.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:14:46 -04:00
98e90cc176 fix: preserve bend lines through drawing split — clip, offset, and carry metadata
DrawingSplitter now clips bend lines to each piece's region using
Liang-Barsky line clipping and offsets them to the new origin. Bend
properties (direction, angle, radius, note text) are preserved through
the entire split pipeline instead of being lost during re-import.

CadConverterForm applies the same origin offset to bends before passing
them to the splitter, and creates FileListItems directly from split
results to avoid re-detection overwriting the bend metadata.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:24:41 -04:00
d9005cccc3 fix: improve split drawing UX — shorter suffix, piece numbers, axis fix
- Change split file suffix from _split# to -# (e.g., PartName-1.dxf)
- Add numbered labels at the center of each split region in the preview
- Fix fit-to-plate axis calculation to use correct plate dimension
  instead of min(width, height) for single-axis splits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:49:02 -04:00
f208569e72 fix: improve CadConverter sidebar layout and bend line visibility
Replace sidebar Panel+Splitter with SplitContainer for resizable file
list / filter panel. Sort file list alphabetically on insert. Widen bend
line dash spacing and draw ETCH layer entities on top of bend lines so
etch marks are visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:51:21 -04:00
1ffe904892 fix: exclude BEND layer entities from DXF geometry import
BEND layer lines were being imported as cut geometry alongside the
separate Bend object detection, causing duplicate dark lines in nests.
Skip BEND layer entities in DxfImporter since bend detection reads
directly from the raw CadDocument.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:17:33 -04:00
4cc8b8f9b7 chore: remove one-off NestBuilder tool
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:09:35 -04:00
1f159d5dcc fix: prevent PushBoundingBox from making obstacles invisible to geometry push
PushBoundingBox left exactly partSpacing between bounding boxes, but the
geometry push inflates offset lines by halfSpacing + chordTolerance per
side (totaling partSpacing + 2*chordTolerance). The 0.002 overlap caused
RayEdgeDistance to return MaxValue for negative t, making the nearest
obstacle invisible and allowing the part to push through it.

Subtract 2*ChordTolerance from the BB push distance so the gap is wide
enough for the offset geometry lines to remain non-overlapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:07:46 -04:00
f626fbe063 fix: auto-select first part and refresh quantity in CadConverter file list
Fire SelectedIndexChanged when the first item is added so the preview
loads automatically. Invalidate the file list after quantity changes so
the badge repaints immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:01:51 -04:00
d5b5ab57e3 fix: assign colors to SpecialLayers and ConvertProgram entities
ConvertProgram.ToGeometry() created entities without setting Color,
defaulting to Color.Empty (alpha=0). After ededc7b switched from
Pens.White to per-entity colors, these rendered fully transparent.

- Add explicit colors to all SpecialLayers (Cut=White, Rapid=Gray, etc.)
- Set entity Color from layer in ConvertProgram for lines, arcs, circles
- Add GetEntityPen fallback: treat Empty/alpha-0 as White
- Add bend line rendering and selection in EntityView/CadConverterForm
- Fix SolidWorksBendDetector MText formatting strip for bend notes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:57:50 -04:00
6916f5ecca fix: replace SplitContainer with Panel+Splitter layout in CadConverterForm
SplitContainer wasn't sizing children correctly. Switched to the same
Panel(Dock.Left) + Splitter + Fill pattern used by SplitDrawingForm,
with explicit Size on all controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:30:48 -04:00
e1bcb7498f feat: add SplitDxfWriter for split DXF output with bend data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:43:40 -04:00
a7f8972722 feat: add bend line rendering and grain warning in PlateView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:39:29 -04:00
6d1a3f5e2c feat: rebuild CadConverterForm with sidebar+preview layout
Replace the old DataGridView+TabControl layout with a sidebar containing
FileListControl and FilterPanel on the left, and EntityView with a detail
bar on the right. Adds drag-and-drop support, thread-safe parallel file
import, bend detection integration, and split-to-DXF workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:36:20 -04:00
52eca5f5c2 feat: add FilterPanel with layers, colors, linetypes, bend lines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:32:27 -04:00
3bce45be5f feat: add FileListControl owner-drawn file list 2026-03-24 20:30:32 -04:00
3f0a4c57b5 feat: add CollapsiblePanel reusable control
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:28:49 -04:00
ededc7b6b4 feat: draw entities in actual DXF colors with pen caching
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:27:46 -04:00
5f74afeda1 feat: add IBendDetector interface, SolidWorks implementation, and registry
Introduces a pluggable bend detection system in OpenNest.IO.Bending:
- IBendDetector takes CadDocument directly to preserve MText/layer/linetype info
- SolidWorksBendDetector finds lines on BEND layer with CENTER linetype and matches nearby MText notes
- BendDetectorRegistry auto-registers SolidWorks detector and supports AutoDetect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:26:27 -04:00
574a8f2c38 feat: add DxfImporter.Import returning entities + CadDocument
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:23:53 -04:00
dd2892a9fe feat: serialize/deserialize bends in nest file format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:22:55 -04:00
7056f8816f feat: add Bends property to Drawing, GrainAngle to Plate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:19:53 -04:00
c2a470f79c feat: add Bend domain model and BendDirection enum to OpenNest.Core
Introduces OpenNest.Core/Bending/ with Bend and BendDirection types as
the foundation for bend line detection. Includes 6 passing unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:18:52 -04:00
39f8a79cfd feat: replace Clipper2 with direct entity splitting in DrawingSplitter
Replace polygon boolean clipping with direct entity splitting using
bounding box filtering and exact intersection math. Eliminates Clipper2
precision drift that caused contour gaps (0.0035") breaking area
calculation and ShapeBuilder chaining.

Also fixes SpikeGrooveSplit: spike depth is now grooveDepth + weldGap
(spike protrudes past groove), both V-shapes use same angle formula,
and weldGap no longer double-subtracted from tip depth.

SplitDrawingForm: fix parameter mapping (GrooveDepth direct from nud,
not inflated), remove redundant Spike Depth display, add feature
contour preview and trimmed split lines at feature positions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:19:47 -04:00
df18b72881 feat: add SplitLineIntersect helper for entity-splitline intersection
Add ToLine() to SplitLine and create SplitLineIntersect static class with
FindIntersection, CrossesSplitLine, and SideOf methods for testing entity
intersections against split lines. These helpers support the upcoming
Clipper2-free DrawingSplitter rewrite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:04:21 -04:00
cd8adc97d6 feat: overhaul SplitDrawingForm — EntityView, draggable feature handles, UI fixes
- Replace raw Panel with EntityView (via SplitPreview subclass) for proper
  zoom-to-point, middle-button pan, and double-buffered rendering
- Add draggable handles for tab/spike positions along split lines; positions
  flow through to WeldGapTabSplit and SpikeGrooveSplit via SplitLine.FeaturePositions
- Fix OK/Cancel buttons hidden off-screen by putting them in a bottom-docked panel
- Fix DrawControl not invalidating on resize
- Swap plate Width/Length label order, default edge spacing to 0.5
- Rename tab labels: Tab Width→Tab Length, Tab Height→Weld Gap, default count 2
- Spike depth now calculated (read-only), groove depth means positioning depth
  beyond spike tip (default 0.125), converted to total depth internally
- Set entity layers visible so EntityView renders them

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:26:43 -04:00
ba7aa39941 feat: add groove depth and weld gap options to spike-groove split
- SpikeParameters: added GrooveDepth (how deep groove cuts into
  receiving part) and SpikeWeldGap (gap between spike tip and groove)
- SpikeGrooveSplit: groove uses its own depth (wider/deeper than spike),
  spike tip stops short by weld gap amount
- UI: added Groove Depth and Weld Gap fields to spike parameters panel
- Changed default pair count to 2 (one near each end)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:30:39 -04:00
5d93ddb2c4 fix: swap plate dimension labels — Length first, Width second 2026-03-24 13:21:11 -04:00
15b2043048 fix: rename Plate Height label to Plate Length 2026-03-24 13:20:09 -04:00
aa8b6f3d9e fix: use plate size correctly when split axis is forced
When user selects Vertical Only or Horizontal Only, use the smaller
plate dimension as the constraint for that axis. Previously it filtered
results from FitToPlate which produced no lines when the part already
fit in the plate width but not height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:18:57 -04:00
3686d074e6 feat: add split axis selector to auto-fit options
Adds a "Split Axis" dropdown (Auto / Vertical Only / Horizontal Only)
to the Fit to Plate options so users can control which direction the
part is split when weld direction matters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:14:54 -04:00
8f1a3fb6b7 fix: filter rapid-move entities from SplitDrawingForm preview
Rapid-layer entities (connecting lines between cutouts and perimeter)
were being rendered in the split preview. Filter them out, matching
the same pattern used in DrawingSplitter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:07:37 -04:00
60ce297d6a feat: add split drawing feature for oversized parts 2026-03-24 12:47:39 -04:00
addd7acc3c docs: add split drawing feature to architecture documentation 2026-03-24 12:21:45 -04:00
d91ffccfa3 feat: add Split button to CadConverterForm to open SplitDrawingForm
Adds a Split button column to the DXF converter grid. Clicking it
builds a temporary Drawing from the item's visible entities and opens
SplitDrawingForm; if the user confirms, the split results replace the
original item in GetDrawings().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:20:22 -04:00
adb8ed12d7 feat: add SplitDrawingForm UI dialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:18:31 -04:00
4acd8b8bad feat: add DrawingSplitter core split pipeline
Implements the main drawing splitting algorithm that orchestrates splitting
a Drawing into multiple pieces along split lines using Clipper2 polygon
clipping. After clipping, recovers original arcs by matching clipped edges
back to perimeter entities, stitches in feature edges from ISplitFeature
where polygon edges lie on split lines, and normalizes each piece's origin.

Key fix from plan: filters rapid-layer entities before ShapeProfile
construction so cutouts are properly separated from perimeters.

Includes 7 integration tests covering vertical/horizontal splits, three-way
splits, property copying, origin normalization, cutout assignment, and
grid (cross) splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:13:37 -04:00
d7b095cf2d feat: add AutoSplitCalculator for fit-to-plate and split-by-count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:03:18 -04:00
499e0425b5 feat: add SpikeGrooveSplit implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:01:40 -04:00
c2c3e23024 feat: add WeldGapTabSplit implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:59:45 -04:00
5afb311ac7 feat: add ISplitFeature interface and StraightSplit implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:58:00 -04:00
765a862440 feat: add SplitLine and SplitParameters models
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:56:00 -04:00
b970629a59 feat: add material library resolution, assist gas support, and UI fixes
- Add MaterialLibraryResolver for Cincinnati post processor to resolve
  G89 library files from material/thickness/gas configuration
- Add Nest.AssistGas property with serialization support in nest format
- Add etch library support with separate gas configuration
- Fix CutOff tests to match AwayFromOrigin default cut direction
- Fix plate info label not updating after ResizePlateToFitParts
- Add cutoff and remnants toolbar button icons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:00:57 -04:00
072915abf2 fix: detect winding direction for correct part spacing offset
PolygonHelper.ExtractPerimeterPolygon always used OffsetSide.Right
assuming CCW winding, but DXF imports can produce CW winding. This
caused the spacing polygon to shrink inward instead of expanding
outward, making parts overlap during nesting.

Now detects winding direction via polygon signed area and selects
the correct OffsetSide accordingly.

Also adds save_nest MCP tool and a BOM-to-nest builder utility
(tools/NestBuilder) for batch-creating nest files from Excel BOMs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:57:23 -04:00
aeeb2e4074 fix: treat cut-offs as area selection boundaries with proper spacing
Cut-off parts use absolute coordinates in their programs, causing
Program.BoundingBox() to span from the origin to the cut-off position.
This made cut-offs invisible to GetLargestBoxVertically/Horizontally
since the oversized box straddled the cursor instead of acting as a
boundary. Derive thin obstacle boxes directly from CutOff definitions
and apply PartSpacing offset so fills respect spacing from cut lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:14:58 -04:00
a2f7219db3 fix: add proper spacing between G-code words in Cincinnati post output
G-code output was concatenated without spaces (e.g. N1005G0X1.4375Y-0.6562).
Now emits standard spacing (N1005 G0 X1.4375 Y-0.6562) across all motion
commands, line numbers, kerf comp, feedrates, M-codes, and comments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 05:46:46 -04:00
7e4040ba08 feat: add post processor support to console app
Load IPostProcessor plugin DLLs from Posts/ directory (same convention
as the WinForms app) and run them after nesting via --post <name>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:51:45 -04:00
0246073b31 fix: reduce default cut-off part clearance from 0.125 to 0.02
The previous default of 0.125 was too large for typical use, causing
cut-off lines to be pushed unnecessarily far from parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:07:33 -04:00
4801895321 chore: add Cincinnati post processor build dependency to solution
Adds project dependency in .sln and project reference in OpenNest.csproj
so the Cincinnati post processor DLL builds before the main app, ensuring
it's always available in the Posts/ directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:07:28 -04:00
833abfe72e feat: add optional M98 part sub-programs to Cincinnati post processor
Each unique part geometry (drawing + rotation) is written once as a
reusable sub-program called via M98, reducing output size for nests
with repeated parts. G92 coordinate repositioning handles per-instance
plate placement with restore after each call. Cut-offs remain inline.

Controlled by UsePartSubprograms (default false) and PartSubprogramStart
config properties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:43:44 -04:00
379000bbd8 feat: auto-copy Cincinnati post processor DLL to Posts/ on build
The WinForms app's LoadPosts() scans Posts/ for IPostProcessor DLLs.
A build target copies the Cincinnati DLL there so it appears in the
Nest > Post menu automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:12:47 -04:00
5936272ce4 refactor: move ProgramVariableManager to Core, add config file support
- Move ProgramVariable and ProgramVariableManager from
  OpenNest.Posts.Cincinnati to OpenNest.Core/CNC (namespace OpenNest.CNC)
  so they can be used internally in nest programs
- Add parameterless constructor to CincinnatiPostProcessor that loads
  config from a .json file next to the DLL (creates defaults on first run)
- Enums serialize as readable strings (e.g., "Inches", "ControllerSide")
- Config file: OpenNest.Posts.Cincinnati.json in the Posts/ directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:08:31 -04:00
da8e7e6fd3 feat: interactive cut-off selection and drag via line hit-testing
Select cut-offs by clicking their lines instead of a grip point.
Drag is axis-constrained with live regeneration during movement.
Selected cut-off highlighted with bright blue 3.5px line.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:49:27 -04:00
53d24ddaf1 feat: implement Polygon.OffsetEntity and use geometric offset for cut-off clearance
Polygon.OffsetEntity now computes proper miter-join offsets using edge
normals and winding direction, with self-intersection cleanup. CutOff
exclusion zones use geometric perimeter offset instead of scalar padding,
giving uniform clearance around parts regardless of cut angle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:46:18 -04:00
8efdc8720c fix: review fixes — culture-invariant formatting, sealed config, threshold boundary
- Use CultureInfo.InvariantCulture in CoordinateFormatter, SpeedClassifier,
  and CincinnatiPreambleWriter to prevent locale-dependent G-code output
- Make CincinnatiPostConfig sealed per spec
- Fix SpeedClassifier.Classify threshold to >= (matching spec)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:45:22 -04:00
ca8a0942ab feat: add CincinnatiPostProcessor implementing IPostProcessor
Orchestrates CincinnatiPreambleWriter and CincinnatiSheetWriter to produce
a complete Cincinnati CNC output file from a Nest; includes 4 integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:41:06 -04:00
8c3659a439 feat: add CincinnatiSheetWriter for per-plate subprogram emission
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:38:24 -04:00
95a0815484 feat: add CincinnatiPreambleWriter for main program and variable declaration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:35:27 -04:00
e9caa9b8eb feat: add CincinnatiFeatureWriter for per-feature G-code emission
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:33:06 -04:00
95a0db1983 feat: add SpeedClassifier for FAST/MEDIUM/SLOW cut distance classification
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:29:14 -04:00
a323dcc230 feat: add ProgramVariable and ProgramVariableManager for macro variable declarations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:29:10 -04:00
24cd18da88 feat: add CoordinateFormatter for Cincinnati G-code coordinate output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:29:06 -04:00
5d26efb552 feat: add CincinnatiPostConfig and supporting enums 2026-03-22 23:26:16 -04:00
60c4545a17 feat: add OpenNest.Posts.Cincinnati project for Cincinnati CL-707 post processor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:25:16 -04:00
4db51b8cdf fix: regenerate cut-offs on part rotation and default cut direction
- Regenerate cut-off programs after RotateSelectedParts so cut lines
  update when parts are rotated, not just moved
- Change default CutDirection from TowardOrigin to AwayFromOrigin so
  cuts start at the origin axis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:58:47 -04:00
1c561d880e refactor: simplify CutOff methods and fix ActionCutOff issues
- Extract MakePoint/AxisBounds/CrossAxisBounds helpers in CutOff to
  eliminate repeated axis-dependent branching
- Simplify BuildProgram loop from 4 code paths to 2
- Use static EmptyExclusions to avoid per-part list allocations
- Fix double event subscription in ActionCutOff constructor
- Dispose debounce timer in DisconnectEvents
- Remove redundant BuildPerimeterCache call in OnMouseDown
- Extract TotalCutLength test helper, remove duplicate test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:51:52 -04:00
17fc9c6cab feat: RegenerateCutOffs uses geometry-based perimeter cache
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:44:15 -04:00
4287c5fa46 refactor: CutOff uses Dictionary<Part, Entity> instead of index-based list
Replace CutOff.BuildPerimeterCache (List<Shape>) with Plate.BuildPerimeterCache
(Dictionary<Part, Entity>) throughout. Consolidate two Regenerate overloads into
a single method with optional cache parameter. Fix Shape intersection bug where
non-intersecting entities added spurious Vector.Zero points to results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:42:32 -04:00
a735884ee9 feat: add Plate.BuildPerimeterCache with ShapeProfile and convex hull fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:34:14 -04:00
22554b0fa3 refactor: extract CollectPoints from FindBestRotation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:30:59 -04:00
48b4849a88 fix: ShapeProfile selects perimeter by largest bounding box area
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:29:00 -04:00
f79df4d426 refactor: remove NfpNestEngine
The NFP engine was a thin wrapper that delegated all single-drawing
operations to DefaultNestEngine. Remove the engine and its registry
entry to reduce dead code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:11:53 -04:00
ebb18d9b49 fix: prevent RemnantFiller interleaving and PairFiller recursion
RemnantFiller: add placed parts as a single envelope obstacle instead
of individual bounding boxes to prevent the next drawing from filling
into inter-row gaps. Remove the topmost bounding-box part to create a
clean rectangular boundary.

PairsFillStrategy: guard against recursive invocation — remnant fills
within PairFiller create a new engine that runs the full pipeline,
which would invoke PairsFillStrategy again causing deep recursion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:11:47 -04:00
31a9e6dbad feat: replace best-fit viewer dropdown with DrawingListBox in split view
Swap the ComboBox drawing selector with the same DrawingListBox control
used in EditNestForm, placed in a resizable SplitContainer. Add selection
highlighting and HideQuantity option to DrawingListBox. Show a centered
loading message while computing, and allow switching drawings mid-compute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:53:43 -04:00
a576f9fafa fix: correct test helper winding and edge sequencer test data
Test drawings used CCW winding, causing OffsetSide.Left to produce
inward offsets. The BestFit pipeline then positioned pairs so actual
shapes overlapped, failing all 1232 candidates. Changed to CW winding
to match CNC convention where OffsetSide.Left = outward.

Also fixed EdgeStartSequencer test: centerPart at (25,55) was only 4.5
from the top edge (plate Y=60), closer than midPart at (10,10). Moved
to (25,25) for correct ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:31:50 -04:00
9453bb51ce docs: update CLAUDE.md with cut-off feature documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:02:42 -04:00
ad58332a5d feat: regenerate cut-offs after part drag and fill operations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:01:03 -04:00
d4f60d5e8e feat: add cut-off selection, drag-to-reposition, and delete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:57:26 -04:00
3ea05257eb feat: wire up cut-off action in MainForm menu
Add "Sheet Cut-Off" menu item to the Plate menu that activates
ActionCutOff placement mode on the active PlateView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:54:09 -04:00
7e49ed620b feat: add ActionCutOff for placing sheet cut-offs
Adds a new action that lets users place cut-off lines on plates by
clicking. The action auto-detects horizontal vs vertical axis based on
mouse proximity to plate edges, shows a dashed preview line that
follows the cursor, and adds the cut-off on left click. Pressing
Escape returns to selection mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:50:38 -04:00
57bd0447e9 feat: add PlateView cut-off rendering with grip handle
Add DrawCutOffs and DrawCutOffGrip methods to PlateView for rendering
cut-off lines and selection grip handles. Includes CutOffSettings and
SelectedCutOff properties, plus GetCutOffAtPoint for hit-testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:46:49 -04:00
07d6f08e8b feat: engine-specific TrimAxis and rename ShrinkAxis.Height to Length
Make quantity trimming direction-aware: DefaultNestEngine uses TrimAxis
(virtual property on NestEngineBase) so HorizontalRemnantEngine removes
topmost parts instead of rightmost. Rename ShrinkAxis.Height → Length
for consistency with Width/Length naming used throughout the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:43:29 -04:00
2f19f47a85 feat: serialize/deserialize cut-off definitions in nest file format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:40:01 -04:00
d58a446eac feat: add Plate.CutOffs collection with materialization and transform support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:36:05 -04:00
5fc7d1989a feat: add CutOff and CutOffSettings domain classes with segment generation
CutOff computes cut segments along a vertical or horizontal axis,
excluding zones around existing parts with configurable clearance.
CutOffSettings controls part clearance, overtravel, minimum segment
length, and cut direction (toward/away from origin).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:32:33 -04:00
3f6bc2b2a1 feat: guard Plate quantity/utilization/overlap for IsCutOff parts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:14:28 -04:00
7681a1bad0 feat: add Drawing.IsCutOff flag for cut-off support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:12:49 -04:00
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
2ed02c2dae feat: show selected part bounding box in status bar
Add SelectionChanged event to PlateView and display the selected part's
location and size in a new status bar label. Shows combined bounding box
when multiple parts are selected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:38:48 -04:00
3756ea255e fix(test): plate size 2026-03-20 00:32:45 -04:00
33ba40e203 refactor: use TrimToCount instead of blind Take(N) in DefaultNestEngine.Fill 2026-03-20 00:09:53 -04:00
6d66636e3d refactor: replace ShrinkFiller shrink loop with TrimToCount
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:06:58 -04:00
85278bbb75 feat: add ShrinkFiller.TrimToCount for axis-aware edge trimming
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:03:57 -04:00
f0a3547bd1 docs: add trim-to-count implementation plan
Three tasks: add TrimToCount with tests, replace shrink loop, replace
Take(N) in DefaultNestEngine.Fill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:01:39 -04:00
fe2a293128 docs: address spec review feedback for trim-to-count
Clarify sort direction (ascending, keep nearest to origin), document
parameter changes, MeasureDimension behavior, and behavioral trade-off.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:56:14 -04:00
11f605801f docs: add trim-to-count design spec
Replace expensive ShrinkFiller re-fill loop with axis-aware edge-sorted
trim. Also replaces blind Take(N) in DefaultNestEngine.Fill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:53:35 -04:00
8dc12972f5 feat(ui): add drawing selector, color scheme, and async loading to BestFitViewer
Add drawing dropdown to switch between drawings without reopening the
form. Change color scheme to light backgrounds with blue/red part fills
and auto-detect text color. Fix swapped bounding box width/length. Run
best-fit computation on a background thread so the UI stays responsive
during long calculations, with cancellation on drawing switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:44:21 -04:00
8a0ebf8c18 feat(ui): improve BestFitViewerForm navigation and reduce flicker
Add third row (5x3 grid, 15 items/page), remove 50-result cap so all
candidates are pageable, start maximized, replace page label with
editable textbox for direct page entry, center nav controls, and
eliminate flicker on page change via DoubleBuffered + WM_SETREDRAW.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:23:20 -04:00
c552372f81 fix(core): copy-on-write for shared Program in tiled parts
CloneAtOffset shares the Program instance for tiling performance,
but rotating a part on the plate mutated the shared Program, causing
all parts from the same tile template to rotate together.

Added ownsProgram flag with EnsureOwnedProgram() that clones the
Program before first mutation, preserving tiling performance while
making user rotations independent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:10:20 -04:00
683cb3c180 docs: fix file format section in CLAUDE.md to match v2 nest format
The documented entries (info.json, drawing-info.json, plate-info.json,
plate-NNN) were from the old v1 format. Updated to reflect the actual v2
structure: nest.json, programs/program-N, and bestfits/bestfit-N.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:52:07 -04:00
2cb2808c79 docs: add lead item rotation design spec for strip nesting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:15:50 -04:00
e969260f3d refactor(engine): introduce PairFillResult and remove FillRemainingStrip
PairFiller now returns PairFillResult (Parts + BestFits) instead of
using a mutable BestFits property. Extracted EvaluateCandidates,
TryReduceWorkArea, and BuildTilingAngles for clarity. Simplified the
candidate loop by leveraging FillScore comparison semantics.

Removed FillRemainingStrip and all its helpers (FindPlacedEdge,
BuildRemainingStrip, BuildRotationSet, FindBestFill, TryFewerRows,
RemainderPatterns) from FillLinear — these were a major bottleneck in
strip nesting, running expensive fills on undersized remnant strips.
ShrinkFiller + RemnantFiller already handle space optimization, making
the remainder strip fill redundant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:53:23 -04:00
8bfc13d529 fix(engine): move progress reporting from inner fills to ShrinkFiller
StripNestEngine was passing progress directly to DefaultNestEngine.Fill
inside the ShrinkFiller loop, causing every per-angle/per-strategy report
to update the UI with overlapping layouts in the same work area.

Now inner fills are silent (null progress) and ShrinkFiller reports its
own progress when the best layout improves. IterativeShrinkFiller tracks
placed parts across items and includes them in reports. The trial box is
reported before the fill starts so the work area border updates immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:43:35 -04:00
ca35945c13 fix(ui): show active or stationary preview parts, not both overlapping
Draw only one set of preview parts at a time — active (current strategy)
takes precedence over stationary (overall best). Also clears active
parts when setting new stationary parts to prevent stale previews.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:50:56 -04:00
fab2214149 perf(engine): reduce PairFiller work area when count exceeds target
When the first pair candidate places more parts than needed (e.g., 17
when target is 10), sort by BoundingBox.Top, trim from the top until
exactly targetCount remain, and use that Top as the new work area
height. All subsequent candidates fill this smaller area, dramatically
reducing fill time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:35:15 -04:00
e3b89f2660 perf(engine): add target count to ShrinkFiller with FillBestFit estimate
When a target count is known, ShrinkFiller now uses FillBestFit (fast
rectangle packing) to estimate how many parts fit on the full area,
then scales the shrink axis proportionally to avoid an expensive
full-area fill. Falls back to full box if estimate is too aggressive.

Also shrinks to targetCount (not full count) to produce tighter boxes
when fewer parts are needed than the area can hold.

IterativeShrinkFiller passes NestItem.Quantity as the target count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:55:01 -04:00
1e9640d4fc feat(engine): include rotating calipers angle in pair nesting tiling
PairEvaluator already computes OptimalRotation via RotatingCalipers on
the pair's convex hull, but PairFiller.EvaluateCandidate only passed
hull edge angles to FillPattern. Now includes the optimal rotation
angle (and +90°) so tiling can use the mathematically tightest fit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:42:29 -04:00
116a386152 refactor(engine): delete obsolete StripNestResult and StripDirection
Both types were only used internally by the old StripNestEngine.Nest
strip-orientation logic, which has been replaced by IterativeShrinkFiller.
No references remain outside of these files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:38:12 -04:00
8957b20bac feat(engine): rewrite StripNestEngine.Nest with iterative shrink-fill
Replaces the old orientation-based strip nesting (TryOrientation,
SelectStripItemIndex, EstimateStripDimension, ShrinkFill helpers) with
a call to IterativeShrinkFiller.Fill for multi-quantity items, plus a
RemnantFinder-based PackArea pass for singles and leftovers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:37:21 -04:00
c31ef9f80c test(engine): add multi-item, leftover, unlimited qty, and cancellation tests for IterativeShrinkFiller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:34:12 -04:00
3b6e4bdd3a fix(engine): remove dead unlimitedDrawings set, fix comment accuracy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:32:46 -04:00
ef737ffa6d feat(engine): add IterativeShrinkFiller with dual-direction shrink selection
Introduces IterativeShrinkFiller.Fill, which composes RemnantFiller and
ShrinkFiller by wrapping the caller's fill function in a closure that tries
both ShrinkAxis.Height and ShrinkAxis.Width and picks the better FillScore.
Adds IterativeShrinkResult (Parts + Leftovers). Covers null/empty inputs and
single-item placement with three passing xUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:30:10 -04:00
1bc635acde docs: add iterative shrink-fill implementation plan
Includes fix for unlimited qty items (Quantity <= 0) that
RemnantFiller.FillItems silently skips. Workaround: convert
to estimated max capacity before passing in.

Also removes caliper angle sections from spec — RotationAnalysis
already feeds the caliper angle via FindBestRotation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:26:08 -04:00
ed555ba56a docs: clarify data flow, FillScore context, and quantity semantics in spec
Addresses spec review feedback: clarify fillFunc wrapping data flow,
specify FillScore comparison context, note Quantity <= 0 means unlimited,
annotate CaliperAngle as radians, remove RemnantFinder return claim.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:17:15 -04:00
20aa172f46 docs: add iterative shrink-fill design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:13:45 -04:00
9a58782c46 merge: resolve conflicts from remote nesting progress changes
Kept using OpenNest.Api in Timing.cs and EditNestForm.cs alongside
remote's reorganized usings and namespace changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:35:25 -04:00
e656956c1c fix(api): set plate Material from request, add null guards in LoadAsync
- NestRunner now assigns Material to plates from request.Material
- NestResponse.LoadAsync uses descriptive exceptions instead of null-forgiving operators
- Fix pre-existing FillExtents.Fill signature mismatch (add bestFits parameter)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 08:48:30 -04:00
f13443b6b3 feat(api): add NestRunner with multi-plate loop
Stateless orchestrator that takes a NestRequest and returns a NestResponse.
Imports DXFs, builds NestItems, runs the engine in a multi-plate loop until
all parts are placed, computes timing, and returns utilization metrics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 08:37:44 -04:00
a7688f4c9d feat(api): add NestResponse with SaveAsync/LoadAsync
Adds NestResponse type to OpenNest.Api with SaveAsync/LoadAsync for .nestquote format — a ZIP containing request.json, response.json (metrics), and an embedded nest.nest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:33:35 -04:00
e324e15fc0 feat(io): add NestWriter.Write(Stream) overload
Adds a Write(Stream) overload that writes the ZIP archive to any stream
with leaveOpen: true so the caller can read back a MemoryStream after
the ZipArchive is disposed. Refactors Write(string) to delegate to the
new overload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:30:59 -04:00
d7cc08dff7 refactor: rename .opnest file extension to .nest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:29:46 -04:00
1c8b35bcfb refactor(engine): rename NestResult to OptimizationResult
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:28:12 -04:00
84679b40ce feat(api): add NestStrategy, NestRequestPart, NestRequest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:26:51 -04:00
b6bd7eda6e refactor: move CutParameters to OpenNest.Api namespace with new properties
Relocates CutParameters from OpenNest namespace to OpenNest.Api, adds
LeadInLength and PostProcessor properties, and provides a typed Default
factory. Updates Timing.cs, the WinForms project reference, and the three
consuming forms to resolve the type from the new namespace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:25:32 -04:00
cfe8a38620 chore: add OpenNest.Api project skeleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:23:07 -04:00
4be0b0db09 docs: add Nest API implementation plan
9-task plan covering: project skeleton, CutParameters migration, request/response
types, NestResult rename, .opnest→.nest rename, NestWriter Stream overload,
NestResponse persistence, NestRunner with multi-plate loop, and verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:59:23 -04:00
2f5d20f972 docs: add Nest API design spec
Design for OpenNest.Api project providing a stateless NestRequest/NestResponse
facade over the engine, IO, and timing layers. Includes CutParameters unification,
multi-plate loop, .nestquote persistence format, and .opnest → .nest rename.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:46:33 -04:00
0f953b8701 docs: add two-bucket preview spec and plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:48:25 -04:00
62ec6484c8 fix(ui): PatternTileForm layout, orientation, and dropdown display
Move PlateViews and labels to designer file so they show in VS.
Fix nest orientation by swapping Box(Width,Length) to Box(Length,Width)
matching plate convention (Length=X, Width=Y). Add ComboBox Format
handler to show Drawing.Name. Zoom to fit after moving parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:48:19 -04:00
0472c12113 refactor(fill): extract constants and EvaluateCandidate in PairFiller
Extract magic numbers into named constants (MaxTopCandidates,
EarlyExitMinTried, etc.), extract candidate evaluation into
EvaluateCandidate method, and expose BestFits property so
PairsFillStrategy can reuse without redundant BestFitCache call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:48:12 -04:00
a9a9dc8a0a feat(ui): route progress to stationary/active buckets in MainForm
Replace SetTemporaryParts/ClearTemporaryParts/AcceptTemporaryParts in all
three progress callbacks (RunAutoNest, FillPlate, FillArea) with the new
two-bucket API: SetStationaryParts for IsOverallBest updates,
SetActiveParts for transient updates, AcceptPreviewParts(parts) and
ClearPreviewParts for completion. Also removes the now-redundant
highWaterMark guards from FillPlate_Click and FillArea_Click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:16:55 -04:00
4fc8f1f6cf feat(ui): two-bucket preview parts in PlateView
Replace single temporaryParts list with stationaryParts (overall best,
full opacity) and activeParts (current strategy, reduced opacity).
Update SetPlate, Refresh, UpdateMatrix, DrawParts, and FillWithProgress
accordingly. Replace SetTemporaryParts/ClearTemporaryParts/AcceptTemporaryParts
with SetStationaryParts/SetActiveParts/ClearPreviewParts/AcceptPreviewParts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:14:59 -04:00
231f97fafc feat(ui): add active preview brush/pen to ColorScheme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:13:10 -04:00
76e30d91c0 feat(engine): flag overall-best progress reports in DefaultNestEngine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:12:20 -04:00
e789fe312d feat(engine): add IsOverallBest flag to NestProgress
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:11:29 -04:00
f73bb2bc2f refactor(fill): simplify FindPatternCopyDistance — extract pair loop, remove redundant span calculation
The pattern bounding box already computes max(upper) - min(lower), so the
manual loop was redundant. Extract the N×N pair distance loop into a static
FindMaxPairDistance helper. Drop pre-cached edge arrays since GetEdges()
returns stored references with zero allocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:04:35 -04:00
0da970ec9a fix: revert FillExtents/FillLinear FillHelpers.Tile calls (not yet available)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:29:38 -04:00
62f00055b7 Reapply "refactor(compactor): deduplicate Push — PushDirection delegates to Vector overload"
This reverts commit e695e29355.
2026-03-18 20:26:14 -04:00
e695e29355 Revert "refactor(compactor): deduplicate Push — PushDirection delegates to Vector overload"
This reverts commit 9012a9fc1c.
2026-03-18 20:24:33 -04:00
9012a9fc1c refactor(compactor): deduplicate Push — PushDirection delegates to Vector overload
Also fix missing using for FillHelpers in FillLinear and FillExtents,
and update callers (CompactorTests, PatternTileForm) for the new
Vector parameter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:23:50 -04:00
b009f195be refactor(compactor): remove dead code — Compact, CompactIndividual, and helpers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:19:55 -04:00
dddc890a96 Revert "refactor(engine): simplify FillExtents logic using Compactor.Push"
This reverts commit d1d47b5223.
2026-03-18 20:17:57 -04:00
794ef16629 test: add Compactor safety-net tests before refactor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:17:06 -04:00
d1d47b5223 refactor(engine): simplify FillExtents logic using Compactor.Push
Simplify geometry-aware positioning by replacing manual slide calculations with higher-level Compactor.Push utility. Extract pair creation into CreatePair helper, remove redundant UpdateBounds calls, and clean up column/horizontal repetition logic.
2026-03-18 20:13:55 -04:00
24ed878d8e docs: add Compactor refactor implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:12:23 -04:00
c2b8400986 refactor(engine): extract AngleCandidateBuilder.Build into focused helpers
Move known-good pruning check before sweep/ML to avoid wasted work,
extract ContainsAngle, NeedsSweep, AddSweepAngles, ApplyMlPrediction,
and BuildPrunedList so Build reads as a clear pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:02:30 -04:00
0a33047ad6 fix(engine): prevent FillExtents overlap and add strategy filter API
FillExtents vertical copy distance was not clamped, allowing rows to be
placed overlapping each other when slide calculations returned large
values. Clamp to pairHeight + partSpacing minimum, matching FillLinear.

Also add FillStrategyRegistry.SetEnabled() to restrict which strategies
run — useful for isolating individual strategies during troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 19:53:08 -04:00
c98e024f9c feat(ui): disable remove plate button when only one plate exists
Promotes btnRemovePlate to a field and toggles Enabled based on
plate count in add/remove event handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 18:07:20 -04:00
d6d7ba8480 fix(ui): increase font sizes in progress form for readability
Labels: 8.25pt -> 9.75pt, headers: 9pt -> 10.5pt, values: 8.25pt -> 9.75pt.
Panel heights increased to accommodate larger text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:58:35 -04:00
b6cde145e1 fix(ui): handle edge cases in DensityBar and PhaseStepperControl
DensityBar: clamp rounded rect radius for small fill widths to avoid
GDI+ artifacts at very low density values.

PhaseStepperControl: use float arithmetic for circle spacing to
handle DPI-scaled widths evenly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:39:13 -04:00
9a4f20ca00 feat(ui): support Accept button in nesting callers 2026-03-18 17:35:33 -04:00
b5af5a118d feat(ui): rewrite NestProgressForm with grouped panels, stepper, density bar, and Accept button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:34:50 -04:00
60a557bd37 feat(ui): add DensityBar sparkline control for density visualization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:31:59 -04:00
97ab33c899 feat(ui): add PhaseStepperControl for nesting progress phases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:31:54 -04:00
a1810db96d docs: add NestProgressForm redesign v2 implementation plan
6-task plan covering PhaseStepperControl, DensityBar, form rewrite,
color-coded flash & fade, Accept/Stop buttons, and caller changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:22:49 -04:00
39d656ad21 docs: add NestProgressForm redesign v2 spec
Phase stepper, grouped panels, density sparkline bar,
color-coded flash & fade, and Accept/Stop buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:16:42 -04:00
1d9bcc63d2 chore: sort using directives
Auto-formatter reordering of using statements across the solution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:47:42 -04:00
6102dd5b85 refactor(engine): migrate Fill(List<Part>) to strategy pipeline
Single-part group fills now delegate to Fill(NestItem) which runs
the full strategy pipeline, eliminating ~70 lines of duplicated
manual phase logic. Multi-part group fills retain the linear
pattern fill (unique to multi-part groups).

PairFiller now references FillHelpers directly instead of
bouncing through DefaultNestEngine helper methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:46:24 -04:00
495ee6f0c3 refactor(engine): move NFP code to OpenNest.Engine.Nfp namespace
Move AutoNester, BottomLeftFill, NfpCache, SimulatedAnnealing,
and INestOptimizer/NestResult to OpenNest.Engine.Nfp. These are
not yet integrated into the engine registry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:46:17 -04:00
0e1e619f0a refactor(engine): move fill and strategy code to dedicated namespaces
Move fill algorithms to OpenNest.Engine.Fill namespace:
FillLinear, FillExtents, PairFiller, ShrinkFiller, Compactor,
RemnantFiller, RemnantFinder, FillScore, Pattern, PatternTiler,
PartBoundary, RotationAnalysis, AngleCandidateBuilder, and
AccumulatingProgress.

Move strategy layer to OpenNest.Engine.Strategies namespace:
IFillStrategy, FillContext, FillStrategyRegistry, FillHelpers,
and all built-in strategy implementations.

Add using directives to all consuming files across Engine, UI,
MCP, and Tests projects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:46:11 -04:00
0cba528591 docs: update README with accurate features and add roadmap
Remove NFP pair fitting claim from features (not yet integrated).
Qualify lead-in/lead-out as engine-only (UI coming soon).
Mark --autonest CLI option as experimental. Add Roadmap section
with planned work: NFP nesting, lead-in UI, sheet cut-offs,
post-processors, and shape library UI.

Add documentation maintenance instruction to CLAUDE.md requiring
README.md and CLAUDE.md updates when project structure changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:45:50 -04:00
442501828a test(io): add DXF roundtrip tests
Verifies export-then-reimport preserves geometry for lines,
circles, arcs, mixed entities, and rectangle bounding boxes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:53:58 -04:00
202f49f368 test(engine): add FillStrategyRegistry and pipeline tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:08:13 -04:00
7bbfe06494 refactor(engine): replace FindBestFill with strategy pipeline
DefaultNestEngine.Fill(NestItem, ...) now delegates to RunPipeline
which iterates FillStrategyRegistry.Strategies in order.

Removed: FindBestFill, FillRectangleBestFit, QuickFillCount.
Kept: AngleCandidateBuilder, ForceFullAngleSweep, group-fill overload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:05:24 -04:00
267254dcae feat(engine): add LinearFillStrategy adapter
Wraps FillLinear in an IFillStrategy, sweeping all AngleCandidates
from SharedState (falling back to 0° and 90°) in both directions and
recording AngleResults for UI inspection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:02:16 -04:00
5668748f37 feat(engine): add ExtentsFillStrategy adapter
Wraps FillExtents in an IFillStrategy, trying both bestRotation and
bestRotation+90° angles and picking the better result. Reads
BestFits from SharedState (populated by PairsFillStrategy) to allow
FillExtents to search the best-fit cache for improved pair geometry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:02:10 -04:00
b7de61e4d1 feat(engine): add RectBestFitStrategy adapter
Wraps FillBestFit rectangle packer in an IFillStrategy so the rect
best-fit phase participates in the pluggable pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:02:05 -04:00
c4d5cfd17b feat(engine): add PairsFillStrategy adapter
Wraps PairFiller in an IFillStrategy so the pairs phase participates
in the pluggable pipeline. Stores BestFitResults in SharedState for
downstream strategies (Extents) to reuse.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:02:00 -04:00
1f965897f2 refactor(engine): extract FillHelpers from DefaultNestEngine
Move BuildRotatedPattern and FillPattern static methods into a new
public FillHelpers class in Strategies/. DefaultNestEngine retains
internal static forwarding stubs so existing callsites are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:59:15 -04:00
46fe48870c feat(engine): add FillStrategyRegistry with reflection-based discovery
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:57:31 -04:00
c287e3ec32 feat(engine): add IFillStrategy interface and FillContext
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:57:27 -04:00
4348e5c427 feat(engine): add NestPhase.Custom for plugin fill strategies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:57:23 -04:00
e6a7d9b047 docs: add pluggable fill strategies implementation plan
11 tasks covering: IFillStrategy interface, FillContext, FillStrategyRegistry,
FillHelpers extraction, 4 strategy adapters, RunPipeline wiring, and tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:54:14 -04:00
ddf1686ea5 docs: address spec review feedback for pluggable fill strategies
Clarifies: strategy statefulness, cancellation handling, progress
reporting, NestPhase.Custom for plugins, BinConverter visibility,
LinearFillStrategy internal iteration, registry caching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:46:30 -04:00
501fbda762 docs: add pluggable fill strategies design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:39:30 -04:00
a83efd0b01 feat(ui): show both horizontal and vertical fill previews side by side
Replace single preview PlateView with two stacked previews showing
horizontal and vertical FillLinear results. Each has a label showing
the part count. Apply uses the direction with more parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:12:24 -04:00
a1139efecb feat(ui): wire Pattern Tile menu item and apply logic in MainForm 2026-03-18 09:58:24 -04:00
d8373ab135 refactor(ui): extract compaction helper, fix auto-arrange UX in PatternTileForm
- Extract CompactTowardCentroid static helper to DRY compaction logic
- Disable Auto-Arrange button when fewer than 2 drawings selected
- Widen mouse-up compaction guard from == 2 to >= 2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:56:21 -04:00
f0b9b51229 feat(ui): add PatternTileForm dialog with unit cell editor and tile preview 2026-03-18 09:52:36 -04:00
76a338f3d0 refactor(engine): remove dead import and add spacing comment in PatternTiler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:48:14 -04:00
0ac7b9babd fix(test): rename misleading test method name
Tile_CellLargerThanPlate_ReturnsSingleCell -> ReturnsEmpty

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:46:26 -04:00
f336af5d65 feat(engine): add PatternTiler for unit cell tiling across plates 2026-03-18 09:43:45 -04:00
3d6be3900e feat(engine): generalize Compactor.Push to support arbitrary angles and BB-only mode
Add Vector-based overloads to SpatialQuery (ray casting, edge distance,
directional gap, perpendicular overlap) and PartGeometry (directional
line filtering) to support pushing parts along any angle, not just
cardinal directions.

Add Compactor.PushBoundingBox for fast coarse positioning using only
bounding box gaps. ActionClone shift+click now uses a two-phase strategy:
BB push first to skip past irregular geometry snags, then geometry push
to settle against actual contours.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:41:09 -04:00
285e7082fb docs: add pattern tile layout implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:31:27 -04:00
207cef5423 docs: update pattern tile layout spec after review
Address 12 review findings: remove redundant Compactor.Push refactor
(already exists), add Plate.Quantity=0 isolation, specify synthetic
work area for compaction, clarify edge cases and tiling spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:20:47 -04:00
c3b3f24704 docs: add pattern tile layout window design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:15:44 -04:00
386 changed files with 20585 additions and 26106 deletions

4
.gitignore vendored
View File

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

View File

@@ -30,23 +30,25 @@ Domain model, geometry, and CNC primitives organized into namespaces:
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
### OpenNest.Engine (class library, depends on Core)
Nesting algorithms 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).
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.
- **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.
- **BestFit/**: NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
- **RectanglePacking/**: `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
- **CirclePacking/**: Alternative packing for circular parts.
- **ML/**: `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
- `FillLinear`: Grid-based fill with directional sliding.
- `Compactor`: Post-fill gravity compaction — pushes parts toward a plate edge to close gaps.
- `FillScore`: Lexicographic comparison struct for fill results (count > utilization > compactness).
- **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`.
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
- **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts.
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
- `RotationAnalysis`: Analyzes part geometry to determine valid rotation angles.
### OpenNest.IO (class library, depends on Core)
File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
@@ -77,19 +79,17 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
The UI application with MDI interface.
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime.
## File Format
Nest files (`.nest`, ZIP-based) use v2 JSON format:
- `info.json`nest metadata and plate defaults
- `drawing-info.json` — drawing metadata (name, material, quantities, colors)
- `plate-info.json` — plate metadata (size, material, spacing)
- `program-NNN` — G-code text for each drawing's cut program
- `plate-NNN` — G-code text encoding part placements (G00 for position, G65 for sub-program call with rotation)
- `nest.json`single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
## Tool Preferences
@@ -99,12 +99,20 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho
- Always use `var` instead of explicit types (e.g., `var parts = new List<Part>();` not `List<Part> parts = new List<Part>();`).
## Documentation Maintenance
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`.
- OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.Nfp` (NFP-based nesting, not yet integrated), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`.
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Api;
public class NestRequest
{
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
public Size SheetSize { get; init; } = new(60, 120);
public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1;
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default;
}

View File

@@ -0,0 +1,9 @@
namespace OpenNest.Api;
public class NestRequestPart
{
public string DxfPath { get; init; }
public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true;
public int Priority { get; init; } = 0;
}

View File

@@ -0,0 +1,112 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
using System.Threading.Tasks;
using OpenNest.IO;
namespace OpenNest.Api;
public class NestResponse
{
public int SheetCount { get; init; }
public double Utilization { get; init; }
public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; }
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
IncludeFields = true // Required for OpenNest.Geometry.Size (public fields)
};
public async Task SaveAsync(string path)
{
using var fs = new FileStream(path, FileMode.Create);
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
// Write request.json
var requestEntry = zip.CreateEntry("request.json");
await using (var stream = requestEntry.Open())
{
await JsonSerializer.SerializeAsync(stream, Request, JsonOptions);
}
// Write response.json (metrics only)
var metrics = new
{
SheetCount,
Utilization,
CutTimeTicks = CutTime.Ticks,
ElapsedTicks = Elapsed.Ticks
};
var responseEntry = zip.CreateEntry("response.json");
await using (var stream = responseEntry.Open())
{
await JsonSerializer.SerializeAsync(stream, metrics, JsonOptions);
}
// Write embedded nest.nest via NestWriter → MemoryStream → ZIP entry
var nestEntry = zip.CreateEntry("nest.nest");
using var nestMs = new MemoryStream();
var writer = new NestWriter(Nest);
writer.Write(nestMs);
nestMs.Position = 0;
await using (var stream = nestEntry.Open())
{
await nestMs.CopyToAsync(stream);
}
}
public static async Task<NestResponse> LoadAsync(string path)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
// Read request.json
var requestEntry = zip.GetEntry("request.json")
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
NestRequest request;
await using (var stream = requestEntry.Open())
{
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions);
}
// Read response.json
var responseEntry = zip.GetEntry("response.json")
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
JsonElement metricsJson;
await using (var stream = responseEntry.Open())
{
metricsJson = await JsonSerializer.DeserializeAsync<JsonElement>(stream, JsonOptions);
}
// Read embedded nest.nest via NestReader(Stream)
var nestEntry = zip.GetEntry("nest.nest")
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
Nest nest;
using (var nestMs = new MemoryStream())
{
await using (var stream = nestEntry.Open())
{
await stream.CopyToAsync(nestMs);
}
nestMs.Position = 0;
var reader = new NestReader(nestMs);
nest = reader.Read();
}
return new NestResponse
{
SheetCount = metricsJson.GetProperty("sheetCount").GetInt32(),
Utilization = metricsJson.GetProperty("utilization").GetDouble(),
CutTime = TimeSpan.FromTicks(metricsJson.GetProperty("cutTimeTicks").GetInt64()),
Elapsed = TimeSpan.FromTicks(metricsJson.GetProperty("elapsedTicks").GetInt64()),
Nest = nest,
Request = request
};
}
}

131
OpenNest.Api/NestRunner.cs Normal file
View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Api;
public static class NestRunner
{
public static Task<NestResponse> RunAsync(
NestRequest request,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
if (request.Parts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request));
var sw = Stopwatch.StartNew();
// 1. Import DXFs → Drawings
var drawings = new List<Drawing>();
var importer = new DxfImporter();
foreach (var part in request.Parts)
{
if (!File.Exists(part.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
var pgm = ConvertGeometry.ToProgram(geometry);
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(name);
drawing.Program = pgm;
drawings.Add(drawing);
}
// 2. Build NestItems
var items = new List<NestItem>();
for (var i = 0; i < request.Parts.Count; i++)
{
var part = request.Parts[i];
items.Add(new NestItem
{
Drawing = drawings[i],
Quantity = part.Quantity,
Priority = part.Priority,
StepAngle = part.AllowRotation ? 0 : OpenNest.Math.Angle.TwoPI,
});
}
// 3. Multi-plate loop
var nest = new Nest();
var remaining = items.Select(item => item.Quantity).ToList();
while (remaining.Any(q => q > 0))
{
token.ThrowIfCancellationRequested();
var plate = new Plate(request.SheetSize)
{
Thickness = request.Thickness,
PartSpacing = request.Spacing,
Material = new Material(request.Material)
};
// Build items for this pass with remaining quantities
var passItems = new List<NestItem>();
for (var i = 0; i < items.Count; i++)
{
if (remaining[i] <= 0) continue;
passItems.Add(new NestItem
{
Drawing = items[i].Drawing,
Quantity = remaining[i],
Priority = items[i].Priority,
StepAngle = items[i].StepAngle,
});
}
// Run engine
var engine = NestEngineRegistry.Create(plate);
var parts = engine.Nest(passItems, progress, token);
if (parts.Count == 0)
break; // No progress — part doesn't fit on fresh sheet
// Add parts to plate and nest
foreach (var p in parts)
plate.Parts.Add(p);
nest.Plates.Add(plate);
// Deduct placed quantities
foreach (var p in parts)
{
var idx = drawings.IndexOf(p.BaseDrawing);
if (idx >= 0)
remaining[idx]--;
}
}
// 4. Compute timing
var timingInfo = Timing.GetTimingInfo(nest);
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
sw.Stop();
// 5. Build response
var response = new NestResponse
{
SheetCount = nest.Plates.Count,
Utilization = nest.Plates.Count > 0
? nest.Plates.Average(p => p.Utilization())
: 0,
CutTime = cutTime,
Elapsed = sw.Elapsed,
Nest = nest,
Request = request
};
return Task.FromResult(response);
}
}

View File

@@ -0,0 +1,3 @@
namespace OpenNest.Api;
public enum NestStrategy { Auto }

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Api</RootNamespace>
<AssemblyName>OpenNest.Api</AssemblyName>
</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>

View File

@@ -1,13 +1,14 @@
using OpenNest;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using OpenNest;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
return NestConsole.Run(args);
@@ -20,6 +21,12 @@ static class NestConsole
if (options == null)
return 0; // --help was requested
if (options.ListPosts)
{
ListPostProcessors(options);
return 0;
}
if (options.InputFiles.Count == 0)
{
PrintUsage();
@@ -68,6 +75,7 @@ static class NestConsole
PrintResults(success, plate, elapsed);
Save(nest, options);
PostProcess(nest, options);
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
}
@@ -120,6 +128,18 @@ static class NestConsole
case "--engine" when i + 1 < args.Length:
NestEngineRegistry.ActiveEngineName = args[++i];
break;
case "--post" when i + 1 < args.Length:
o.PostName = args[++i];
break;
case "--post-output" when i + 1 < args.Length:
o.PostOutput = args[++i];
break;
case "--posts-dir" when i + 1 < args.Length:
o.PostsDir = args[++i];
break;
case "--list-posts":
o.ListPosts = true;
break;
case "--help":
case "-h":
PrintUsage();
@@ -191,7 +211,7 @@ static class NestConsole
// DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0)
{
Console.Error.WriteLine("Error: no nest (.opnest) or DXF (.dxf) files specified");
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
return null;
}
@@ -382,17 +402,111 @@ static class NestConsole
Console.WriteLine($"Saved: {outputFile}");
}
static string ResolvePostsDir(Options options)
{
if (options.PostsDir != null)
return options.PostsDir;
var exePath = Assembly.GetEntryAssembly()?.Location
?? typeof(NestConsole).Assembly.Location;
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
}
static List<IPostProcessor> LoadPostProcessors(string postsDir)
{
var processors = new List<IPostProcessor>();
if (!Directory.Exists(postsDir))
return processors;
foreach (var file in Directory.GetFiles(postsDir, "*.dll"))
{
try
{
var assembly = Assembly.LoadFrom(file);
foreach (var type in assembly.GetTypes())
{
if (!typeof(IPostProcessor).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract)
continue;
if (Activator.CreateInstance(type) is IPostProcessor processor)
processors.Add(processor);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}");
}
}
return processors;
}
static void ListPostProcessors(Options options)
{
var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir);
if (processors.Count == 0)
{
Console.WriteLine($"No post processors found in: {postsDir}");
return;
}
Console.WriteLine($"Post processors ({postsDir}):");
foreach (var p in processors)
Console.WriteLine($" {p.Name,-30} {p.Description}");
}
static void PostProcess(Nest nest, Options options)
{
if (options.PostName == null)
return;
var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir);
var post = processors.FirstOrDefault(p =>
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase));
if (post == null)
{
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
if (processors.Count > 0)
Console.Error.WriteLine($"Available: {string.Join(", ", processors.Select(p => p.Name))}");
else
Console.Error.WriteLine($"No post processors found in: {postsDir}");
return;
}
var outputFile = options.PostOutput;
if (outputFile == null)
{
var firstInput = options.InputFiles[0];
outputFile = Path.Combine(
Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc");
}
post.Post(nest, outputFile);
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
}
static void PrintUsage()
{
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
Console.Error.WriteLine();
Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" input-files One or more .opnest nest files or .dxf drawing files");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files");
Console.Error.WriteLine();
Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.opnest> Load nest and fill (existing behavior)");
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill");
Console.Error.WriteLine(" <nest.opnest> <part.dxf> Load nest and add imported DXF drawings");
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings");
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
@@ -400,13 +514,17 @@ static class NestConsole
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
Console.Error.WriteLine(" --spacing <value> Override part spacing");
Console.Error.WriteLine(" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode");
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.opnest)");
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.nest)");
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
Console.Error.WriteLine(" --no-save Skip saving output file");
Console.Error.WriteLine(" --no-log Skip writing debug log file");
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
Console.Error.WriteLine(" --list-posts List available post processors and exit");
Console.Error.WriteLine(" -h, --help Show this help");
}
@@ -425,5 +543,9 @@ static class NestConsole
public bool KeepParts;
public bool AutoNest;
public string TemplateFile;
public string PostName;
public string PostOutput;
public string PostsDir;
public bool ListPosts;
}
}

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest
{

View File

@@ -0,0 +1,40 @@
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Bending
{
public class Bend
{
public Vector StartPoint { get; set; }
public Vector EndPoint { get; set; }
public BendDirection Direction { get; set; }
public double? Angle { get; set; }
public double? Radius { get; set; }
public string NoteText { get; set; }
[System.Text.Json.Serialization.JsonIgnore]
public Entity SourceEntity { get; set; }
public double Length => StartPoint.DistanceTo(EndPoint);
public double AngleRadians => Angle.HasValue
? OpenNest.Math.Angle.ToRadians(Angle.Value)
: 0;
public Line ToLine() => new Line(StartPoint, EndPoint);
/// <summary>
/// Returns the angle of the bend line itself (not the bend angle).
/// Used for grain direction comparison.
/// </summary>
public double LineAngle => StartPoint.AngleTo(EndPoint);
public override string ToString()
{
var dir = Direction.ToString();
var angle = Angle?.ToString("0.##") ?? "?";
var radius = Radius?.ToString("0.###") ?? "?";
return $"{dir} {angle}° R{radius}";
}
}
}

View File

@@ -0,0 +1,9 @@
namespace OpenNest.Bending
{
public enum BendDirection
{
Unknown,
Up,
Down
}
}

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,4 +1,3 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{

View File

@@ -1,8 +1,7 @@
using System;
using System.Collections.Generic;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC
{

View File

@@ -0,0 +1,18 @@
namespace OpenNest.CNC
{
public sealed class ProgramVariable
{
public int Number { get; }
public string Name { get; }
public string Expression { get; set; }
public ProgramVariable(int number, string name, string expression = null)
{
Number = number;
Name = name;
Expression = expression;
}
public string Reference => $"#{Number}";
}
}

View File

@@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace OpenNest.CNC
{
public sealed class ProgramVariableManager
{
private readonly Dictionary<int, ProgramVariable> _variables = new();
public ProgramVariable GetOrCreate(string name, int number, string expression = null)
{
if (_variables.TryGetValue(number, out var existing))
return existing;
var variable = new ProgramVariable(number, name, expression);
_variables[number] = variable;
return variable;
}
public List<string> EmitDeclarations()
{
return _variables.Values
.Where(v => v.Expression != null)
.OrderBy(v => v.Number)
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
.ToList();
}
private static string FormatComment(string name)
{
// "LeadInFeedrate" -> "LEAD IN FEEDRATE"
var sb = new StringBuilder();
foreach (var c in name)
{
if (char.IsUpper(c) && sb.Length > 0)
sb.Append(' ');
sb.Append(char.ToUpper(c));
}
return sb.ToString();
}
}
}

View File

@@ -1,7 +1,6 @@
using System.Collections.Generic;
using OpenNest;
using OpenNest.CNC;
using OpenNest.CNC;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Converters
{

View File

@@ -1,9 +1,7 @@
using System;
using System.Collections.Generic;
using OpenNest;
using OpenNest.CNC;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Converters
{
@@ -61,9 +59,11 @@ namespace OpenNest.Converters
if (mode == Mode.Incremental)
pt += curpos;
var layer = ConvertLayer(linearMove.Layer);
var line = new Line(curpos, pt)
{
Layer = ConvertLayer(linearMove.Layer)
Layer = layer,
Color = layer.Color
};
geometry.Add(line);
curpos = pt;
@@ -78,7 +78,8 @@ namespace OpenNest.Converters
var line = new Line(curpos, pt)
{
Layer = SpecialLayers.Rapid
Layer = SpecialLayers.Rapid,
Color = SpecialLayers.Rapid.Color
};
geometry.Add(line);
curpos = pt;
@@ -105,9 +106,9 @@ namespace OpenNest.Converters
var layer = ConvertLayer(arcMove.Layer);
if (startAngle.IsEqualTo(endAngle))
geometry.Add(new Circle(center, radius) { Layer = layer });
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color });
else
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer });
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
curpos = endpt;
}

211
OpenNest.Core/CutOff.cs Normal file
View File

@@ -0,0 +1,211 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest
{
public enum CutOffAxis
{
Horizontal,
Vertical
}
public class CutOff
{
public Vector Position { get; set; }
public CutOffAxis Axis { get; set; }
public double? StartLimit { get; set; }
public double? EndLimit { get; set; }
public Drawing Drawing { get; private set; }
public CutOff(Vector position, CutOffAxis axis)
{
Position = position;
Axis = axis;
Drawing = new Drawing(GetName()) { IsCutOff = true };
}
public void Regenerate(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache = null)
{
var segments = ComputeSegments(plate, settings, cache);
var program = BuildProgram(segments, settings);
Drawing.Program = program;
}
private string GetName()
{
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
var coord = Axis == CutOffAxis.Vertical ? Position.X : Position.Y;
return $"CutOff-{axisChar}-{coord:F2}";
}
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache)
{
var bounds = plate.BoundingBox(includeParts: false);
double lineStart, lineEnd, cutPosition;
if (Axis == CutOffAxis.Vertical)
{
cutPosition = Position.X;
lineStart = StartLimit ?? bounds.Y;
lineEnd = EndLimit ?? (bounds.Y + bounds.Length + settings.Overtravel);
}
else
{
cutPosition = Position.Y;
lineStart = StartLimit ?? bounds.X;
lineEnd = EndLimit ?? (bounds.X + bounds.Width + settings.Overtravel);
}
var exclusions = new List<(double Start, double End)>();
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff)
continue;
Entity perimeter = null;
cache?.TryGetValue(part, out perimeter);
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance);
exclusions.AddRange(partExclusions);
}
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
var merged = new List<(double Start, double End)>();
foreach (var ex in exclusions)
{
if (merged.Count > 0 && ex.Start <= merged[^1].End)
merged[^1] = (merged[^1].Start, System.Math.Max(merged[^1].End, ex.End));
else
merged.Add(ex);
}
var segments = new List<(double Start, double End)>();
var current = lineStart;
foreach (var ex in merged)
{
var clampedStart = System.Math.Max(ex.Start, lineStart);
var clampedEnd = System.Math.Min(ex.End, lineEnd);
if (clampedStart > current)
segments.Add((current, clampedStart));
current = System.Math.Max(current, clampedEnd);
}
if (current < lineEnd)
segments.Add((current, lineEnd));
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
return segments;
}
private static readonly List<(double Start, double End)> EmptyExclusions = new();
private List<(double Start, double End)> GetPartExclusions(
Part part, Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
{
var bb = part.BoundingBox;
var (partMin, partMax) = AxisBounds(bb, clearance);
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
if (cutPosition < partMin || cutPosition > partMax)
return EmptyExclusions;
if (perimeter != null)
{
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance);
if (perimeterExclusions != null)
return perimeterExclusions;
}
return new List<(double Start, double End)> { (partStart, partEnd) };
}
private List<(double Start, double End)> IntersectPerimeter(
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
{
var target = OffsetOutward(perimeter, clearance) ?? perimeter;
var usedOffset = target != perimeter;
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd));
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2)
return null;
var coords = pts
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
.OrderBy(c => c)
.ToList();
if (coords.Count % 2 != 0)
return null;
var padding = usedOffset ? 0 : clearance;
var result = new List<(double Start, double End)>();
for (var i = 0; i < coords.Count; i += 2)
result.Add((coords[i] - padding, coords[i + 1] + padding));
return result;
}
private static Entity OffsetOutward(Entity perimeter, double clearance)
{
if (clearance <= 0)
return null;
try
{
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
offset?.UpdateBounds();
return offset;
}
catch
{
return null;
}
}
private Vector MakePoint(double cutCoord, double lineCoord) =>
Axis == CutOffAxis.Vertical
? new Vector(cutCoord, lineCoord)
: new Vector(lineCoord, cutCoord);
private (double Min, double Max) AxisBounds(Box bb, double clearance) =>
Axis == CutOffAxis.Vertical
? (bb.X - clearance, bb.X + bb.Width + clearance)
: (bb.Y - clearance, bb.Y + bb.Length + clearance);
private (double Start, double End) CrossAxisBounds(Box bb, double clearance) =>
Axis == CutOffAxis.Vertical
? (bb.Y - clearance, bb.Y + bb.Length + clearance)
: (bb.X - clearance, bb.X + bb.Width + clearance);
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
{
var program = new Program();
if (segments.Count == 0)
return program;
var toward = settings.CutDirection == CutDirection.TowardOrigin;
segments = toward
? segments.OrderByDescending(s => s.Start).ToList()
: segments.OrderBy(s => s.Start).ToList();
var cutPos = Axis == CutOffAxis.Vertical ? Position.X : Position.Y;
foreach (var seg in segments)
{
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
}
return program;
}
}
}

View File

@@ -0,0 +1,16 @@
namespace OpenNest
{
public enum CutDirection
{
TowardOrigin,
AwayFromOrigin
}
public class CutOffSettings
{
public double PartClearance { get; set; } = 0.02;
public double Overtravel { get; set; }
public double MinSegmentLength { get; set; } = 0.05;
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
}
}

View File

@@ -1,15 +1,21 @@
using System;
using System;
namespace OpenNest
namespace OpenNest.Api;
public class CutParameters
{
public class CutParameters
public double Feedrate { get; set; }
public double RapidTravelRate { get; set; }
public TimeSpan PierceTime { get; set; }
public double LeadInLength { get; set; }
public string PostProcessor { get; set; }
public Units Units { get; set; }
public static CutParameters Default => new()
{
public double Feedrate { get; set; }
public double RapidTravelRate { get; set; }
public TimeSpan PierceTime { get; set; }
public Units Units { get; set; }
}
Feedrate = 100,
RapidTravelRate = 300,
PierceTime = TimeSpan.FromSeconds(0.5),
Units = OpenNest.Units.Inches
};
}

View File

@@ -1,9 +1,11 @@
using System.Drawing;
using System.Linq;
using System.Threading;
using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Threading;
namespace OpenNest
{
@@ -56,10 +58,14 @@ namespace OpenNest
public Color Color { get; set; }
public bool IsCutOff { get; set; }
public NestConstraints Constraints { get; set; }
public SourceInfo Source { get; set; }
public List<Bend> Bends { get; set; } = new List<Bend>();
public double Area { get; protected set; }
public void UpdateArea()

View File

@@ -8,10 +8,10 @@
public int Remaining
{
get
get
{
var x = Required - Nested;
return x < 0 ? 0: x;
return x < 0 ? 0 : x;
}
}
}

View File

@@ -1,6 +1,6 @@
using System;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{

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

@@ -1,6 +1,5 @@
using System;
using OpenNest.Math;
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Math;
using System.Collections.Generic;
using System.Drawing;
using OpenNest.Math;
namespace OpenNest.Geometry
{
@@ -247,7 +247,7 @@ namespace OpenNest.Geometry
public static class EntityExtensions
{
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI)
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
{
var points = new List<Vector>();
@@ -286,17 +286,35 @@ namespace OpenNest.Geometry
case EntityType.Shape:
var shape = (Shape)entity;
var subResult = shape.Entities.FindBestRotation(startAngle, endAngle);
return subResult;
points.AddRange(shape.Entities.CollectPoints());
break;
}
}
return points;
}
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI)
{
// Check for Shape entity first (recursive case returns early)
foreach (var entity in entities)
{
if (entity.Type == EntityType.Shape)
{
var shape = (Shape)entity;
var subResult = shape.Entities.FindBestRotation(startAngle, endAngle);
return subResult;
}
}
var points = entities.CollectPoints();
if (points.Count == 0)
return new BoundingRectangleResult(startAngle, 0, 0);
var hull = ConvexHull.Compute(points);
bool constrained = !startAngle.IsEqualTo(0) || !endAngle.IsEqualTo(Angle.TwoPI);
var constrained = !startAngle.IsEqualTo(0) || !endAngle.IsEqualTo(Angle.TwoPI);
return constrained
? RotatingCalipers.MinimumBoundingRectangle(hull, startAngle, endAngle)

View File

@@ -1,7 +1,7 @@
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using OpenNest.Math;
namespace OpenNest.Geometry
{
@@ -133,17 +133,30 @@ namespace OpenNest.Geometry
if (!arc1.Radius.IsEqualTo(arc2.Radius))
return false;
if (arc1.StartAngle > arc1.EndAngle)
arc1.StartAngle -= Angle.TwoPI;
var start1 = arc1.StartAngle;
var end1 = arc1.EndAngle;
var start2 = arc2.StartAngle;
var end2 = arc2.EndAngle;
if (arc2.StartAngle > arc2.EndAngle)
arc2.StartAngle -= Angle.TwoPI;
if (start1 > end1)
start1 -= Angle.TwoPI;
if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle)
if (start2 > end2)
start2 -= Angle.TwoPI;
// Check that arcs are adjacent (endpoints touch), not overlapping
var touch1 = end1.IsEqualTo(start2) || (end1 + Angle.TwoPI).IsEqualTo(start2);
var touch2 = end2.IsEqualTo(start1) || (end2 + Angle.TwoPI).IsEqualTo(start1);
if (!touch1 && !touch2)
return false;
var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle;
var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle;
var startAngle = start1 < start2 ? start1 : start2;
var endAngle = end1 > end2 ? end1 : end2;
// Don't merge if the result would be a full circle (start == end)
var sweep = endAngle - startAngle;
if (sweep >= Angle.TwoPI - Tolerance.Epsilon)
return false;
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;

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,6 +1,6 @@
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry
{
@@ -219,6 +219,14 @@ namespace OpenNest.Geometry
}
internal static bool Intersects(Line line1, Line line2, out Vector pt)
{
if (!IntersectsUnbounded(line1, line2, out pt))
return false;
return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt);
}
internal static bool IntersectsUnbounded(Line line1, Line line2, out Vector pt)
{
var a1 = line1.EndPoint.Y - line1.StartPoint.Y;
var b1 = line1.StartPoint.X - line1.EndPoint.X;
@@ -240,7 +248,7 @@ namespace OpenNest.Geometry
var y = (a1 * c2 - a2 * c1) / d;
pt = new Vector(x, y);
return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt);
return true;
}
internal static bool Intersects(Line line, Shape shape, out List<Vector> pts)
@@ -249,9 +257,8 @@ namespace OpenNest.Geometry
foreach (var geo in shape.Entities)
{
List<Vector> pts3;
geo.Intersects(line, out pts3);
pts.AddRange(pts3);
if (geo.Intersects(line, out var pts3))
pts.AddRange(pts3);
}
return pts.Count > 0;

View File

@@ -1,6 +1,6 @@
using System;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Geometry
{
@@ -23,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)
{
@@ -33,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;
}
@@ -79,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;
@@ -99,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);
@@ -118,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)
{
@@ -130,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);
@@ -144,6 +160,7 @@ namespace OpenNest.Geometry
}
result.Close();
result.UpdateBounds();
return result;
}
@@ -251,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;
@@ -264,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;
}
@@ -272,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();
@@ -280,6 +297,7 @@ namespace OpenNest.Geometry
polygon.Vertices.Add(new Vector(pt.x, pt.y));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
}

View File

@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry

View File

@@ -1,7 +1,7 @@
using System;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry
{
@@ -317,12 +317,68 @@ namespace OpenNest.Geometry
public override Entity OffsetEntity(double distance, OffsetSide side)
{
throw new NotImplementedException();
if (Vertices.Count < 3)
return null;
var isClosed = IsClosed();
var count = isClosed ? Vertices.Count - 1 : Vertices.Count;
if (count < 3)
return null;
var ccw = CalculateArea() > 0;
var outward = ccw ? OffsetSide.Left : OffsetSide.Right;
var sign = side == outward ? 1.0 : -1.0;
var d = distance * sign;
var normals = new Vector[count];
for (var i = 0; i < count; i++)
{
var next = (i + 1) % count;
var dx = Vertices[next].X - Vertices[i].X;
var dy = Vertices[next].Y - Vertices[i].Y;
var len = System.Math.Sqrt(dx * dx + dy * dy);
if (len < Tolerance.Epsilon)
return null;
normals[i] = new Vector(-dy / len * d, dx / len * d);
}
var result = new Polygon();
for (var i = 0; i < count; i++)
{
var prev = (i - 1 + count) % count;
var a1 = new Vector(Vertices[prev].X + normals[prev].X, Vertices[prev].Y + normals[prev].Y);
var a2 = new Vector(Vertices[i].X + normals[prev].X, Vertices[i].Y + normals[prev].Y);
var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y);
var b2 = new Vector(Vertices[(i + 1) % count].X + normals[i].X, Vertices[(i + 1) % count].Y + normals[i].Y);
var edgeA = new Line(a1, a2);
var edgeB = new Line(b1, b2);
if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid())
result.Vertices.Add(pt);
else
result.Vertices.Add(new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y));
}
result.Close();
result.RemoveSelfIntersections();
result.UpdateBounds();
return result;
}
public override Entity OffsetEntity(double distance, Vector pt)
{
throw new NotImplementedException();
var left = OffsetEntity(distance, OffsetSide.Left);
var right = OffsetEntity(distance, OffsetSide.Right);
if (left == null) return right;
if (right == null) return left;
var distLeft = left.ClosestPointTo(pt).DistanceTo(pt);
var distRight = right.ClosestPointTo(pt).DistanceTo(pt);
return distLeft > distRight ? left : right;
}
/// <summary>

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Geometry
{

View File

@@ -534,7 +534,7 @@ namespace OpenNest.Geometry
{
Vector intersection;
if (Intersect.Intersects(offsetLine, lastOffsetLine, out intersection))
if (Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out intersection))
{
offsetLine.StartPoint = intersection;
lastOffsetLine.EndPoint = intersection;
@@ -558,6 +558,46 @@ namespace OpenNest.Geometry
throw new NotImplementedException();
}
/// <summary>
/// Offsets the shape outward by the given distance, detecting winding direction
/// to choose the correct offset side. Falls back to the opposite side if the
/// bounding box shrinks (indicating the offset went inward).
/// </summary>
public Shape OffsetOutward(double distance)
{
var poly = ToPolygon();
var side = poly.Vertices.Count >= 3 && poly.RotationDirection() == RotationType.CW
? OffsetSide.Left
: OffsetSide.Right;
var result = OffsetEntity(distance, side) as Shape;
if (result == null)
return null;
UpdateBounds();
var originalBB = BoundingBox;
result.UpdateBounds();
var offsetBB = result.BoundingBox;
if (offsetBB.Width < originalBB.Width || offsetBB.Length < originalBB.Length)
{
Trace.TraceWarning(
"Shape.OffsetOutward: offset shrank bounding box " +
$"(original={originalBB.Width:F3}x{originalBB.Length:F3}, " +
$"offset={offsetBB.Width:F3}x{offsetBB.Length:F3}). " +
"Retrying with opposite side.");
var opposite = side == OffsetSide.Left ? OffsetSide.Right : OffsetSide.Left;
var retry = OffsetEntity(distance, opposite) as Shape;
if (retry != null)
result = retry;
}
return result;
}
/// <summary>
/// Gets the closest point on the shape to the given point.
/// </summary>

View File

@@ -1,6 +1,6 @@
using OpenNest.Math;
using System.Collections.Generic;
using System.Diagnostics;
using OpenNest.Math;
namespace OpenNest.Geometry
{

View File

@@ -21,9 +21,12 @@ namespace OpenNest.Geometry
Perimeter = shapes[0];
Cutouts = new List<Shape>();
for (int i = 1; i < shapes.Count; i++)
for (var i = 1; i < shapes.Count; i++)
{
if (shapes[i].Left < Perimeter.Left)
var bb = shapes[i].BoundingBox;
var perimBB = Perimeter.BoundingBox;
if (bb.Width * bb.Length > perimBB.Width * perimBB.Length)
{
Cutouts.Add(Perimeter);
Perimeter = shapes[i];

View File

@@ -43,7 +43,7 @@ namespace OpenNest.Geometry
}
public override string ToString() => $"{Width} x {Length}";
public string ToString(int decimalPlaces) => $"{System.Math.Round(Width, decimalPlaces)} x {System.Math.Round(Length, decimalPlaces)}";
}
}

View File

@@ -1,7 +1,6 @@
using System;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry
{
@@ -30,47 +29,81 @@ namespace OpenNest.Geometry
{
case PushDirection.Left:
case PushDirection.Right:
{
var dy = p2y - p1y;
if (System.Math.Abs(dy) < Tolerance.Epsilon)
{
var dy = p2y - p1y;
if (System.Math.Abs(dy) < Tolerance.Epsilon)
return double.MaxValue;
var t = (vy - p1y) / dy;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var ix = p1x + t * (p2x - p1x);
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
var t = (vy - p1y) / dy;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var ix = p1x + t * (p2x - p1x);
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
}
case PushDirection.Down:
case PushDirection.Up:
{
var dx = p2x - p1x;
if (System.Math.Abs(dx) < Tolerance.Epsilon)
{
var dx = p2x - p1x;
if (System.Math.Abs(dx) < Tolerance.Epsilon)
return double.MaxValue;
var t = (vx - p1x) / dx;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var iy = p1y + t * (p2y - p1y);
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
var t = (vx - p1x) / dx;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var iy = p1y + t * (p2y - p1y);
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
}
default:
return double.MaxValue;
}
}
/// <summary>
/// Generalized ray-edge distance along an arbitrary unit direction vector.
/// Returns double.MaxValue if the ray does not hit the segment.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayEdgeDistance(
double vx, double vy,
double p1x, double p1y, double p2x, double p2y,
double dirX, double dirY)
{
var ex = p2x - p1x;
var ey = p2y - p1y;
var det = ex * dirY - ey * dirX;
if (System.Math.Abs(det) < Tolerance.Epsilon)
return double.MaxValue;
var dvx = p1x - vx;
var dvy = p1y - vy;
var t = (ex * dvy - ey * dvx) / det;
if (t < -Tolerance.Epsilon)
return double.MaxValue;
var s = (dirX * dvy - dirY * dvx) / det;
if (s < -Tolerance.Epsilon || s > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
if (t > Tolerance.Epsilon) return t;
if (t >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
/// <summary>
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
@@ -329,10 +362,10 @@ namespace OpenNest.Geometry
{
switch (direction)
{
case PushDirection.Left: return box.Left - boundary.Left;
case PushDirection.Left: return box.Left - boundary.Left;
case PushDirection.Right: return boundary.Right - box.Right;
case PushDirection.Up: return boundary.Top - box.Top;
case PushDirection.Down: return box.Bottom - boundary.Bottom;
case PushDirection.Up: return boundary.Top - box.Top;
case PushDirection.Down: return box.Bottom - boundary.Bottom;
default: return double.MaxValue;
}
}
@@ -341,10 +374,10 @@ namespace OpenNest.Geometry
{
switch (direction)
{
case PushDirection.Left: return new Vector(-distance, 0);
case PushDirection.Left: return new Vector(-distance, 0);
case PushDirection.Right: return new Vector(distance, 0);
case PushDirection.Up: return new Vector(0, distance);
case PushDirection.Down: return new Vector(0, -distance);
case PushDirection.Up: return new Vector(0, distance);
case PushDirection.Down: return new Vector(0, -distance);
default: return new Vector();
}
}
@@ -353,14 +386,143 @@ namespace OpenNest.Geometry
{
switch (direction)
{
case PushDirection.Left: return from.Left - to.Right;
case PushDirection.Left: return from.Left - to.Right;
case PushDirection.Right: return to.Left - from.Right;
case PushDirection.Up: return to.Bottom - from.Top;
case PushDirection.Down: return from.Bottom - to.Top;
case PushDirection.Up: return to.Bottom - from.Top;
case PushDirection.Down: return from.Bottom - to.Top;
default: return double.MaxValue;
}
}
#region Generalized direction (Vector) overloads
/// <summary>
/// Computes how far a box can travel along the given unit direction
/// before exiting the boundary box.
/// </summary>
public static double EdgeDistance(Box box, Box boundary, Vector direction)
{
var dist = double.MaxValue;
if (direction.X < -Tolerance.Epsilon)
{
var d = (box.Left - boundary.Left) / -direction.X;
if (d < dist) dist = d;
}
else if (direction.X > Tolerance.Epsilon)
{
var d = (boundary.Right - box.Right) / direction.X;
if (d < dist) dist = d;
}
if (direction.Y < -Tolerance.Epsilon)
{
var d = (box.Bottom - boundary.Bottom) / -direction.Y;
if (d < dist) dist = d;
}
else if (direction.Y > Tolerance.Epsilon)
{
var d = (boundary.Top - box.Top) / direction.Y;
if (d < dist) dist = d;
}
return dist < 0 ? 0 : dist;
}
/// <summary>
/// Computes the directional gap between two boxes along an arbitrary unit direction.
/// Positive means 'to' is ahead of 'from' in the push direction.
/// </summary>
public static double DirectionalGap(Box from, Box to, Vector direction)
{
var fromMax = BoxProjectionMax(from, direction.X, direction.Y);
var toMin = BoxProjectionMin(to, direction.X, direction.Y);
return toMin - fromMax;
}
/// <summary>
/// Returns true if two boxes overlap when projected onto the axis
/// perpendicular to the given unit direction.
/// </summary>
public static bool PerpendicularOverlap(Box a, Box b, Vector direction)
{
var px = -direction.Y;
var py = direction.X;
var aMin = BoxProjectionMin(a, px, py);
var aMax = BoxProjectionMax(a, px, py);
var bMin = BoxProjectionMin(b, px, py);
var bMax = BoxProjectionMax(b, px, py);
return aMin <= bMax + Tolerance.Epsilon && bMin <= aMax + Tolerance.Epsilon;
}
/// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction
/// before any edge of movingLines contacts any edge of stationaryLines.
/// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, Vector direction)
{
var minDist = double.MaxValue;
var dirX = direction.X;
var dirY = direction.Y;
var movingVertices = new HashSet<Vector>();
for (var i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
foreach (var mv in movingVertices)
{
for (var i = 0; i < stationaryLines.Count; i++)
{
var e = stationaryLines[i];
var d = RayEdgeDistance(mv.X, mv.Y, e.pt1.X, e.pt1.Y, e.pt2.X, e.pt2.Y, dirX, dirY);
if (d < minDist) minDist = d;
}
}
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = new HashSet<Vector>();
for (var i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
foreach (var sv in stationaryVertices)
{
for (var i = 0; i < movingLines.Count; i++)
{
var e = movingLines[i];
var d = RayEdgeDistance(sv.X, sv.Y, e.pt1.X, e.pt1.Y, e.pt2.X, e.pt2.Y, oppX, oppY);
if (d < minDist) minDist = d;
}
}
return minDist;
}
private static double BoxProjectionMin(Box box, double dx, double dy)
{
var x = dx >= 0 ? box.Left : box.Right;
var y = dy >= 0 ? box.Bottom : box.Top;
return x * dx + y * dy;
}
private static double BoxProjectionMax(Box box, double dx, double dy)
{
var x = dx >= 0 ? box.Right : box.Left;
var y = dy >= 0 ? box.Top : box.Bottom;
return x * dx + y * dy;
}
#endregion
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;

View File

@@ -1,5 +1,5 @@
using System;
using OpenNest.Math;
using OpenNest.Math;
using System;
namespace OpenNest.Geometry
{

View File

@@ -1,6 +1,4 @@
using System;
namespace OpenNest.Math
namespace OpenNest.Math
{
public static class Angle
{

View File

@@ -1,6 +1,4 @@
using System;
namespace OpenNest.Math
namespace OpenNest.Math
{
public static class Tolerance
{

View File

@@ -1,6 +1,4 @@
using System;
namespace OpenNest.Math
namespace OpenNest.Math
{
public static class Trigonometry
{

View File

@@ -1,6 +1,6 @@
using System;
using OpenNest.Collections;
using OpenNest.Collections;
using OpenNest.Geometry;
using System;
namespace OpenNest
{
@@ -36,6 +36,8 @@ namespace OpenNest
public string Notes { get; set; }
public string AssistGas { get; set; } = "";
public Units Units { get; set; }
public DateTime DateCreated { get; set; }

View File

@@ -1,5 +1,4 @@
using System;
using OpenNest.Math;
using OpenNest.Math;
namespace OpenNest
{

View File

@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest
{
@@ -20,6 +20,7 @@ namespace OpenNest
public class Part : IPart, IBoundable
{
private Vector location;
private bool ownsProgram;
public readonly Drawing BaseDrawing;
@@ -32,6 +33,7 @@ namespace OpenNest
{
BaseDrawing = baseDrawing;
Program = baseDrawing.Program.Clone() as Program;
ownsProgram = true;
this.location = location;
UpdateBounds();
}
@@ -67,6 +69,7 @@ namespace OpenNest
/// <param name="angle">Angle of rotation in radians.</param>
public void Rotate(double angle)
{
EnsureOwnedProgram();
Program.Rotate(angle);
location = Location.Rotate(angle);
UpdateBounds();
@@ -79,6 +82,7 @@ namespace OpenNest
/// <param name="origin">The origin to rotate the part around.</param>
public void Rotate(double angle, Vector origin)
{
EnsureOwnedProgram();
Program.Rotate(angle);
location = Location.Rotate(angle, origin);
UpdateBounds();
@@ -222,6 +226,15 @@ namespace OpenNest
return part;
}
private void EnsureOwnedProgram()
{
if (!ownsProgram)
{
Program = Program.Clone() as Program;
ownsProgram = true;
}
}
private Part(Drawing baseDrawing, Program program, Vector location, Box boundingBox)
{
BaseDrawing = baseDrawing;

View File

@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest
{
@@ -49,7 +49,7 @@ namespace OpenNest
{
// Add chord tolerance to compensate for inscribed polygon chords
// being inside the actual offset arcs.
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
if (offsetEntity == null)
continue;
@@ -71,7 +71,7 @@ namespace OpenNest
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
if (offsetEntity == null)
continue;
@@ -85,6 +85,73 @@ namespace OpenNest
return lines;
}
public static List<Line> GetPartLines(Part part, Vector facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, Vector facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines;
}
/// <summary>
/// Returns only polygon edges whose outward normal faces the specified direction vector.
/// </summary>
private static List<Line> GetDirectionalLines(Polygon polygon, Vector direction)
{
if (polygon.Vertices.Count < 3)
return polygon.ToLines();
var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0;
var lines = new List<Line>();
var last = polygon.Vertices[0];
for (var i = 1; i < polygon.Vertices.Count; i++)
{
var current = polygon.Vertices[i];
var edx = current.X - last.X;
var edy = current.Y - last.Y;
var keep = sign * (edy * direction.X - edx * direction.Y) > 0;
if (keep)
lines.Add(new Line(last, current));
last = current;
}
return lines;
}
/// <summary>
/// Returns only polygon edges whose outward normal faces the specified direction.
/// </summary>
@@ -107,10 +174,10 @@ namespace OpenNest
switch (facingDirection)
{
case PushDirection.Left: keep = -sign * dy > 0; break;
case PushDirection.Right: keep = sign * dy > 0; break;
case PushDirection.Up: keep = -sign * dx > 0; break;
case PushDirection.Down: keep = sign * dx > 0; break;
case PushDirection.Left: keep = -sign * dy > 0; break;
case PushDirection.Right: keep = sign * dy > 0; break;
case PushDirection.Up: keep = -sign * dx > 0; break;
case PushDirection.Down: keep = sign * dx > 0; break;
default: keep = true; break;
}

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Collections;
using OpenNest.Collections;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest
{
@@ -47,17 +47,20 @@ namespace OpenNest
Parts = new ObservableList<Part>();
Parts.ItemAdded += Parts_PartAdded;
Parts.ItemRemoved += Parts_PartRemoved;
CutOffs = new ObservableList<CutOff>();
Quadrant = 1;
}
private void Parts_PartAdded(object sender, ItemAddedEventArgs<Part> e)
{
e.Item.BaseDrawing.Quantity.Nested += Quantity;
if (!e.Item.BaseDrawing.IsCutOff)
e.Item.BaseDrawing.Quantity.Nested += Quantity;
}
private void Parts_PartRemoved(object sender, ItemRemovedEventArgs<Part> e)
{
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
if (!e.Item.BaseDrawing.IsCutOff)
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
}
/// <summary>
@@ -85,11 +88,102 @@ namespace OpenNest
/// </summary>
public Material Material { get; set; }
/// <summary>
/// Material grain direction in radians. 0 = horizontal.
/// </summary>
public double GrainAngle { get; set; }
/// <summary>
/// The parts that the plate contains.
/// </summary>
public ObservableList<Part> Parts { get; set; }
/// <summary>
/// The cut-off lines defined on this plate.
/// </summary>
public ObservableList<CutOff> CutOffs { get; set; }
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
/// regenerated and added back if it produces any geometry.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings)
{
// Remove existing cut-off parts
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
var cache = BuildPerimeterCache(this);
// Regenerate and materialize each cut-off
foreach (var cutoff in CutOffs)
{
cutoff.Regenerate(this, settings, cache);
if (cutoff.Drawing.Program.Codes.Count == 0)
continue;
var part = new Part(cutoff.Drawing);
Parts.Add(part);
}
}
/// <summary>
/// Builds a dictionary mapping each non-cut-off part to its perimeter entity.
/// Closed shapes use ShapeProfile; open contours fall back to ConvexHull.
/// </summary>
public static Dictionary<Part, Geometry.Entity> BuildPerimeterCache(Plate plate)
{
var cache = new Dictionary<Part, Geometry.Entity>();
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff)
continue;
Geometry.Entity perimeter = null;
try
{
var entities = Converters.ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count > 0)
{
var profile = new Geometry.ShapeProfile(entities);
if (profile.Perimeter.IsClosed())
{
perimeter = profile.Perimeter;
perimeter.Offset(part.Location);
}
else
{
var points = entities.CollectPoints();
if (points.Count >= 3)
{
var hull = Geometry.ConvexHull.Compute(points);
hull.Offset(part.Location);
perimeter = hull;
}
}
}
}
catch
{
perimeter = null;
}
cache[part] = perimeter;
}
return cache;
}
/// <summary>
/// The number of times to cut the plate.
/// </summary>
@@ -240,11 +334,20 @@ namespace OpenNest
/// <param name="angle"></param>
public void Rotate(double angle)
{
for (int i = 0; i < Parts.Count; ++i)
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
for (var i = 0; i < Parts.Count; ++i)
{
var part = Parts[i];
part.Rotate(angle);
}
foreach (var cutoff in CutOffs)
cutoff.Position = cutoff.Position.Rotate(angle);
}
/// <summary>
@@ -254,11 +357,24 @@ namespace OpenNest
/// <param name="origin"></param>
public void Rotate(double angle, Vector origin)
{
for (int i = 0; i < Parts.Count; ++i)
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
for (var i = 0; i < Parts.Count; ++i)
{
var part = Parts[i];
part.Rotate(angle, origin);
}
foreach (var cutoff in CutOffs)
{
var pos = cutoff.Position - origin;
pos = pos.Rotate(angle);
cutoff.Position = pos + origin;
}
}
/// <summary>
@@ -268,11 +384,22 @@ namespace OpenNest
/// <param name="y"></param>
public void Offset(double x, double y)
{
for (int i = 0; i < Parts.Count; ++i)
// Remove cut-off parts before transforming
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
for (var i = 0; i < Parts.Count; ++i)
{
var part = Parts[i];
part.Offset(x, y);
}
// Transform cut-off positions
foreach (var cutoff in CutOffs)
cutoff.Position = new Vector(cutoff.Position.X + x, cutoff.Position.Y + y);
}
/// <summary>
@@ -281,11 +408,20 @@ namespace OpenNest
/// <param name="voffset"></param>
public void Offset(Vector voffset)
{
for (int i = 0; i < Parts.Count; ++i)
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
for (var i = 0; i < Parts.Count; ++i)
{
var part = Parts[i];
part.Offset(voffset);
}
foreach (var cutoff in CutOffs)
cutoff.Position = new Vector(cutoff.Position.X + voffset.X, cutoff.Position.Y + voffset.Y);
}
/// <summary>
@@ -454,24 +590,23 @@ namespace OpenNest
/// <returns>Returns a number between 0.0 and 1.0</returns>
public double Utilization()
{
return Parts.Sum(part => part.BaseDrawing.Area) / Area();
return Parts.Where(p => !p.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) / Area();
}
public bool HasOverlappingParts(out List<Vector> pts)
{
pts = new List<Vector>();
var realParts = Parts.Where(p => !p.BaseDrawing.IsCutOff).ToList();
for (int i = 0; i < Parts.Count; i++)
for (var i = 0; i < realParts.Count; i++)
{
var part1 = Parts[i];
var part1 = realParts[i];
for (int j = i + 1; j < Parts.Count; j++)
for (var j = i + 1; j < realParts.Count; j++)
{
var part2 = Parts[j];
var part2 = realParts[j];
List<Vector> pts2;
if (part1.Intersects(part2, out pts2))
if (part1.Intersects(part2, out var pts2))
pts.AddRange(pts2);
}
}

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,21 +1,21 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{
public class RectangleShape : ShapeDefinition
{
public double Length { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public override Drawing GetDrawing()
{
var entities = new List<Entity>
{
new Line(0, 0, Width, 0),
new Line(Width, 0, Width, Height),
new Line(Width, Height, 0, Height),
new Line(0, Height, 0, 0)
new Line(0, 0, Length, 0),
new Line(Length, 0, Length, Width),
new Line(Length, Width, 0, Width),
new Line(0, Width, 0, 0)
};
return CreateDrawing(entities);

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,13 +1,13 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Shapes
{
public class RoundedRectangleShape : ShapeDefinition
{
public double Length { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public double Radius { get; set; }
public override Drawing GetDrawing()
@@ -17,36 +17,36 @@ namespace OpenNest.Shapes
if (r <= 0)
{
entities.Add(new Line(0, 0, Width, 0));
entities.Add(new Line(Width, 0, Width, Height));
entities.Add(new Line(Width, Height, 0, Height));
entities.Add(new Line(0, Height, 0, 0));
entities.Add(new Line(0, 0, Length, 0));
entities.Add(new Line(Length, 0, Length, Width));
entities.Add(new Line(Length, Width, 0, Width));
entities.Add(new Line(0, Width, 0, 0));
}
else
{
// Bottom edge (left to right, above bottom-left arc to bottom-right arc)
entities.Add(new Line(r, 0, Width - r, 0));
entities.Add(new Line(r, 0, Length - r, 0));
// Bottom-right corner arc: center at (Width-r, r), from 270deg to 360deg
entities.Add(new Arc(Width - r, r, r,
// Bottom-right corner arc: center at (Length-r, r), from 270deg to 360deg
entities.Add(new Arc(Length - r, r, r,
Angle.ToRadians(270), Angle.ToRadians(360)));
// Right edge
entities.Add(new Line(Width, r, Width, Height - r));
entities.Add(new Line(Length, r, Length, Width - r));
// Top-right corner arc: center at (Width-r, Height-r), from 0deg to 90deg
entities.Add(new Arc(Width - r, Height - r, r,
// Top-right corner arc: center at (Length-r, Width-r), from 0deg to 90deg
entities.Add(new Arc(Length - r, Width - r, r,
Angle.ToRadians(0), Angle.ToRadians(90)));
// Top edge (right to left)
entities.Add(new Line(Width - r, Height, r, Height));
entities.Add(new Line(Length - r, Width, r, Width));
// Top-left corner arc: center at (r, Height-r), from 90deg to 180deg
entities.Add(new Arc(r, Height - r, r,
// Top-left corner arc: center at (r, Width-r), from 90deg to 180deg
entities.Add(new Arc(r, Width - r, r,
Angle.ToRadians(90), Angle.ToRadians(180)));
// Left edge
entities.Add(new Line(0, Height - r, 0, r));
entities.Add(new Line(0, Width - r, 0, r));
// Bottom-left corner arc: center at (r, r), from 180deg to 270deg
entities.Add(new Arc(r, r, r,

View File

@@ -1,9 +1,9 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Shapes
{

View File

@@ -1,21 +1,22 @@
using OpenNest.Geometry;
using System.Drawing;
using OpenNest.Geometry;
namespace OpenNest
{
public static class SpecialLayers
{
public static readonly Layer Default = new Layer("0");
public static readonly Layer Default = new Layer("0") { Color = Color.White };
public static readonly Layer Cut = new Layer("CUT");
public static readonly Layer Cut = new Layer("CUT") { Color = Color.White };
public static readonly Layer Rapid = new Layer("RAPID");
public static readonly Layer Rapid = new Layer("RAPID") { Color = Color.Gray };
public static readonly Layer Display = new Layer("DISPLAY");
public static readonly Layer Display = new Layer("DISPLAY") { Color = Color.Cyan };
public static readonly Layer Leadin = new Layer("LEADIN");
public static readonly Layer Leadin = new Layer("LEADIN") { Color = Color.Yellow };
public static readonly Layer Leadout = new Layer("LEADOUT");
public static readonly Layer Leadout = new Layer("LEADOUT") { Color = Color.Yellow };
public static readonly Layer Scribe = new Layer("SCRIBE");
public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta };
}
}

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest;
public static class AutoSplitCalculator
{
public static List<SplitLine> FitToPlate(Box partBounds, double plateWidth, double plateHeight,
double edgeSpacing, double featureOverhang)
{
var usableWidth = plateWidth - 2 * edgeSpacing - featureOverhang;
var usableHeight = plateHeight - 2 * edgeSpacing - featureOverhang;
var lines = new List<SplitLine>();
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableWidth) - 1 : 0;
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableHeight) - 1 : 0;
if (verticalSplits < 0) verticalSplits = 0;
if (horizontalSplits < 0) horizontalSplits = 0;
for (var i = 1; i <= verticalSplits; i++)
lines.Add(new SplitLine(partBounds.X + usableWidth * i, CutOffAxis.Vertical));
for (var i = 1; i <= horizontalSplits; i++)
lines.Add(new SplitLine(partBounds.Y + usableHeight * i, CutOffAxis.Horizontal));
return lines;
}
public static List<SplitLine> SplitByCount(Box partBounds, int horizontalPieces, int verticalPieces)
{
var lines = new List<SplitLine>();
if (verticalPieces > 1)
{
var spacing = partBounds.Width / verticalPieces;
for (var i = 1; i < verticalPieces; i++)
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
}
if (horizontalPieces > 1)
{
var spacing = partBounds.Length / horizontalPieces;
for (var i = 1; i < horizontalPieces; i++)
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
}
return lines;
}
}

View File

@@ -0,0 +1,565 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Splits a Drawing into multiple pieces along split lines with optional feature geometry.
/// </summary>
public static class DrawingSplitter
{
public static List<Drawing> Split(Drawing drawing, List<SplitLine> splitLines, SplitParameters parameters)
{
if (splitLines.Count == 0)
return new List<Drawing> { drawing };
var profile = BuildProfile(drawing);
DecomposeCircles(profile);
var perimeter = profile.Perimeter;
var bounds = perimeter.BoundingBox;
var sortedLines = splitLines
.Where(l => IsLineInsideBounds(l, bounds))
.OrderBy(l => l.Position)
.ToList();
if (sortedLines.Count == 0)
return new List<Drawing> { drawing };
var regions = BuildClipRegions(sortedLines, bounds);
var feature = GetFeature(parameters.Type);
var results = new List<Drawing>();
var pieceIndex = 1;
foreach (var region in regions)
{
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
if (pieceEntities.Count == 0)
continue;
var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines);
var allEntities = new List<Entity>();
allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities);
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
results.Add(piece);
pieceIndex++;
}
return results;
}
private static ShapeProfile BuildProfile(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
return new ShapeProfile(entities);
}
private static List<Entity> CollectCutouts(List<Shape> cutouts, Box region, List<SplitLine> splitLines)
{
var entities = new List<Entity>();
foreach (var cutout in cutouts)
{
if (IsCutoutInRegion(cutout, region))
entities.AddRange(cutout.Entities);
else if (DoesCutoutCrossSplitLine(cutout, splitLines))
{
var clipped = ClipCutoutToRegion(cutout, region, splitLines);
if (clipped.Count > 0)
entities.AddRange(clipped);
}
}
return entities;
}
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex, Box region)
{
var pieceBounds = entities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
var offsetX = -pieceBounds.X;
var offsetY = -pieceBounds.Y;
foreach (var e in entities)
e.Offset(offsetX, offsetY);
var pgm = ConvertGeometry.ToProgram(entities);
var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm);
piece.Color = source.Color;
piece.Priority = source.Priority;
piece.Material = source.Material;
piece.Constraints = source.Constraints;
piece.Customer = source.Customer;
piece.Source = source.Source;
piece.Quantity.Required = source.Quantity.Required;
if (source.Bends != null && source.Bends.Count > 0)
{
piece.Bends = new List<Bending.Bend>();
foreach (var bend in source.Bends)
{
var clipped = ClipLineToBox(bend.StartPoint, bend.EndPoint, region);
if (clipped == null)
continue;
piece.Bends.Add(new Bending.Bend
{
StartPoint = new Vector(clipped.Value.Start.X + offsetX, clipped.Value.Start.Y + offsetY),
EndPoint = new Vector(clipped.Value.End.X + offsetX, clipped.Value.End.Y + offsetY),
Direction = bend.Direction,
Angle = bend.Angle,
Radius = bend.Radius,
NoteText = bend.NoteText,
});
}
}
return piece;
}
/// <summary>
/// Clips a line segment to an axis-aligned box using Liang-Barsky algorithm.
/// Returns the clipped start/end or null if the line is entirely outside.
/// </summary>
private static (Vector Start, Vector End)? ClipLineToBox(Vector start, Vector end, Box box)
{
var dx = end.X - start.X;
var dy = end.Y - start.Y;
double t0 = 0, t1 = 1;
double[] p = { -dx, dx, -dy, dy };
double[] q = { start.X - box.Left, box.Right - start.X, start.Y - box.Bottom, box.Top - start.Y };
for (var i = 0; i < 4; i++)
{
if (System.Math.Abs(p[i]) < Math.Tolerance.Epsilon)
{
if (q[i] < -Math.Tolerance.Epsilon)
return null;
}
else
{
var t = q[i] / p[i];
if (p[i] < 0)
t0 = System.Math.Max(t0, t);
else
t1 = System.Math.Min(t1, t);
if (t0 > t1)
return null;
}
}
var clippedStart = new Vector(start.X + t0 * dx, start.Y + t0 * dy);
var clippedEnd = new Vector(start.X + t1 * dx, start.Y + t1 * dy);
return (clippedStart, clippedEnd);
}
private static void DecomposeCircles(ShapeProfile profile)
{
DecomposeCirclesInShape(profile.Perimeter);
foreach (var cutout in profile.Cutouts)
DecomposeCirclesInShape(cutout);
}
private static void DecomposeCirclesInShape(Shape shape)
{
for (var i = shape.Entities.Count - 1; i >= 0; i--)
{
if (shape.Entities[i] is Circle circle)
{
var arc1 = new Arc(circle.Center, circle.Radius, 0, System.Math.PI);
var arc2 = new Arc(circle.Center, circle.Radius, System.Math.PI, System.Math.PI * 2);
shape.Entities.RemoveAt(i);
shape.Entities.Insert(i, arc2);
shape.Entities.Insert(i, arc1);
}
}
}
private static bool IsLineInsideBounds(SplitLine line, Box bounds)
{
return line.Axis == CutOffAxis.Vertical
? line.Position > bounds.Left + OpenNest.Math.Tolerance.Epsilon
&& line.Position < bounds.Right - OpenNest.Math.Tolerance.Epsilon
: line.Position > bounds.Bottom + OpenNest.Math.Tolerance.Epsilon
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
}
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds)
{
var verticals = sortedLines.Where(l => l.Axis == CutOffAxis.Vertical).OrderBy(l => l.Position).ToList();
var horizontals = sortedLines.Where(l => l.Axis == CutOffAxis.Horizontal).OrderBy(l => l.Position).ToList();
var xEdges = new List<double> { bounds.Left };
xEdges.AddRange(verticals.Select(v => v.Position));
xEdges.Add(bounds.Right);
var yEdges = new List<double> { bounds.Bottom };
yEdges.AddRange(horizontals.Select(h => h.Position));
yEdges.Add(bounds.Top);
var regions = new List<Box>();
for (var yi = 0; yi < yEdges.Count - 1; yi++)
for (var xi = 0; xi < xEdges.Count - 1; xi++)
regions.Add(new Box(xEdges[xi], yEdges[yi], xEdges[xi + 1] - xEdges[xi], yEdges[yi + 1] - yEdges[yi]));
return regions;
}
/// <summary>
/// Clip perimeter to a region by walking entities, splitting at split line crossings,
/// and stitching in feature edges. No polygon clipping library needed.
/// </summary>
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
{
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>();
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
foreach (var entity in perimeter.Entities)
{
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
}
if (entities.Count == 0)
return new List<Entity>();
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
EnsurePerimeterWinding(entities);
return entities;
}
private static void ProcessEntity(Entity entity, Box region,
List<SplitLine> boundarySplitLines, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
{
// Find the first boundary split line this entity crosses
SplitLine crossedLine = null;
Vector? intersectionPt = null;
foreach (var sl in boundarySplitLines)
{
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
{
var pt = SplitLineIntersect.FindIntersection(entity, sl);
if (pt != null)
{
crossedLine = sl;
intersectionPt = pt;
break;
}
}
}
if (crossedLine != null)
{
// Entity crosses a split line — split it and keep the half inside the region
var regionSide = RegionSideOf(region, crossedLine);
var startPt = GetStartPoint(entity);
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
var startInRegion = startSide == regionSide || startSide == 0;
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
}
else
{
// Entity doesn't cross any boundary split line — check if it's inside the region
var mid = MidPoint(entity);
if (region.Contains(mid))
entities.Add(entity);
}
}
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
SplitLine crossedLine, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
{
if (entity is Line line)
{
var (first, second) = line.SplitAt(point);
if (startInRegion)
{
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
}
else if (entity is Arc arc)
{
var (first, second) = arc.SplitAt(point);
if (startInRegion)
{
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
}
}
/// <summary>
/// Returns split lines whose position matches a boundary edge of the region.
/// </summary>
private static List<SplitLine> GetBoundarySplitLines(Box region, List<SplitLine> splitLines)
{
var result = new List<SplitLine>();
foreach (var sl in splitLines)
{
if (sl.Axis == CutOffAxis.Vertical)
{
if (System.Math.Abs(sl.Position - region.Left) < OpenNest.Math.Tolerance.Epsilon
|| System.Math.Abs(sl.Position - region.Right) < OpenNest.Math.Tolerance.Epsilon)
result.Add(sl);
}
else
{
if (System.Math.Abs(sl.Position - region.Bottom) < OpenNest.Math.Tolerance.Epsilon
|| System.Math.Abs(sl.Position - region.Top) < OpenNest.Math.Tolerance.Epsilon)
result.Add(sl);
}
}
return result;
}
/// <summary>
/// Returns -1 or +1 indicating which side of the split line the region center is on.
/// </summary>
private static int RegionSideOf(Box region, SplitLine sl)
{
return SplitLineIntersect.SideOf(region.Center, sl);
}
/// <summary>
/// Returns the midpoint of an entity. For lines: average of endpoints.
/// For arcs: point at the mid-angle.
/// </summary>
private static Vector MidPoint(Entity entity)
{
if (entity is Line line)
return line.MidPoint;
if (entity is Arc arc)
{
var midAngle = (arc.StartAngle + arc.EndAngle) / 2;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(midAngle),
arc.Center.Y + arc.Radius * System.Math.Sin(midAngle));
}
return new Vector(0, 0);
}
/// <summary>
/// Groups split points by split line, pairs exits with entries, and generates feature edges.
/// </summary>
private static void InsertFeatureEdges(List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
Box region, List<SplitLine> boundarySplitLines,
ISplitFeature feature, SplitParameters parameters)
{
// Group split points by their split line
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
foreach (var sp in splitPoints)
{
if (!groups.ContainsKey(sp.Line))
groups[sp.Line] = new List<(Vector, bool)>();
groups[sp.Line].Add((sp.Point, sp.IsExit));
}
foreach (var kvp in groups)
{
var sl = kvp.Key;
var points = kvp.Value;
// Pair each exit with the next entry
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
if (exits.Count == 0 || entries.Count == 0)
continue;
// For each exit, find the matching entry to form the feature edge span
// Sort exits and entries by their position along the split line
var isVertical = sl.Axis == CutOffAxis.Vertical;
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
// Pair them up: each exit with the next entry (or vice versa)
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
{
var exitPt = exits[i];
var entryPt = entries[i];
var extentStart = isVertical
? System.Math.Min(exitPt.Y, entryPt.Y)
: System.Math.Min(exitPt.X, entryPt.X);
var extentEnd = isVertical
? System.Math.Max(exitPt.Y, entryPt.Y)
: System.Math.Max(exitPt.X, entryPt.X);
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
if (featureEdge.Count > 0)
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
entities.AddRange(featureEdge);
}
}
}
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
{
var featureStart = GetStartPoint(featureEdge[0]);
var featureEnd = GetEndPoint(featureEdge[^1]);
var isVertical = axis == CutOffAxis.Vertical;
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
if (edgeGoesForward != featureGoesForward)
{
featureEdge = new List<Entity>(featureEdge);
featureEdge.Reverse();
foreach (var e in featureEdge)
e.Reverse();
}
return featureEdge;
}
private static void EnsurePerimeterWinding(List<Entity> entities)
{
var shape = new Shape();
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CW)
shape.Reverse();
entities.Clear();
entities.AddRange(shape.Entities);
}
private static bool IsCutoutInRegion(Shape cutout, Box region)
{
if (cutout.Entities.Count == 0) return false;
var pt = GetStartPoint(cutout.Entities[0]);
return region.Contains(pt);
}
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
{
var bb = cutout.BoundingBox;
foreach (var sl in splitLines)
{
if (sl.Axis == CutOffAxis.Vertical && bb.Left < sl.Position && bb.Right > sl.Position)
return true;
if (sl.Axis == CutOffAxis.Horizontal && bb.Bottom < sl.Position && bb.Top > sl.Position)
return true;
}
return false;
}
/// <summary>
/// Clip a cutout shape to a region by walking entities, splitting at split line
/// intersections, keeping portions inside the region, and closing gaps with
/// straight lines. No polygon clipping library needed.
/// </summary>
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
{
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>();
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
foreach (var entity in cutout.Entities)
{
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
}
if (entities.Count == 0)
return new List<Entity>();
// Close gaps with straight lines (connect exit→entry pairs)
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
foreach (var sp in splitPoints)
{
if (!groups.ContainsKey(sp.Line))
groups[sp.Line] = new List<(Vector, bool)>();
groups[sp.Line].Add((sp.Point, sp.IsExit));
}
foreach (var kvp in groups)
{
var sl = kvp.Key;
var points = kvp.Value;
var isVertical = sl.Axis == CutOffAxis.Vertical;
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
entities.Add(new Line(exits[i], entries[i]));
}
// Ensure CCW winding for cutouts
var shape = new Shape();
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CCW)
shape.Reverse();
return shape.Entities;
}
private static Vector GetStartPoint(Entity entity)
{
return entity switch
{
Line l => l.StartPoint,
Arc a => a.StartPoint(),
_ => new Vector(0, 0)
};
}
private static Vector GetEndPoint(Entity entity)
{
return entity switch
{
Line l => l.EndPoint,
Arc a => a.EndPoint(),
_ => new Vector(0, 0)
};
}
private static ISplitFeature GetFeature(SplitType type)
{
return type switch
{
SplitType.Straight => new StraightSplit(),
SplitType.WeldGapTabs => new WeldGapTabSplit(),
SplitType.SpikeGroove => new SpikeGrooveSplit(),
_ => new StraightSplit()
};
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest;
public class SplitFeatureResult
{
public List<Entity> NegativeSideEdge { get; }
public List<Entity> PositiveSideEdge { get; }
public SplitFeatureResult(List<Entity> negativeSideEdge, List<Entity> positiveSideEdge)
{
NegativeSideEdge = negativeSideEdge;
PositiveSideEdge = positiveSideEdge;
}
}
public interface ISplitFeature
{
string Name { get; }
SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters);
}

View File

@@ -0,0 +1,112 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Generates interlocking spike/V-groove pairs along the split edge.
/// Spikes protrude from the positive side into the negative side.
/// V-grooves on the negative side receive the spikes for self-alignment during welding.
/// The weld gap (grooveDepth - spikeDepth) is the clearance at the tip when assembled.
/// </summary>
public class SpikeGrooveSplit : ISplitFeature
{
public string Name => "Spike / V-Groove";
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
{
var extent = extentEnd - extentStart;
var pairCount = parameters.SpikePairCount;
var spikeDepth = parameters.SpikeDepth;
var grooveDepth = parameters.GrooveDepth;
var angleRad = OpenNest.Math.Angle.ToRadians(parameters.SpikeAngle / 2);
var spikeHalfWidth = spikeDepth * System.Math.Tan(angleRad);
var grooveHalfWidth = grooveDepth * System.Math.Tan(angleRad);
var isVertical = line.Axis == CutOffAxis.Vertical;
var pos = line.Position;
// Use custom positions if provided, otherwise place evenly with margin
var pairPositions = new List<double>();
if (line.FeaturePositions.Count > 0)
{
pairPositions.AddRange(line.FeaturePositions);
}
else if (pairCount == 1)
{
pairPositions.Add(extentStart + extent / 2);
}
else
{
var margin = extent * 0.15;
var usable = extent - 2 * margin;
for (var i = 0; i < pairCount; i++)
pairPositions.Add(extentStart + margin + usable * i / (pairCount - 1));
}
var negEntities = BuildGrooveSide(pairPositions, grooveHalfWidth, grooveDepth, extentStart, extentEnd, pos, isVertical);
var posEntities = BuildSpikeSide(pairPositions, spikeHalfWidth, spikeDepth, extentStart, extentEnd, pos, isVertical);
return new SplitFeatureResult(negEntities, posEntities);
}
private static List<Entity> BuildGrooveSide(List<double> pairPositions, double halfWidth, double depth,
double extentStart, double extentEnd, double pos, bool isVertical)
{
var entities = new List<Entity>();
var cursor = extentStart;
foreach (var center in pairPositions)
{
var grooveStart = center - halfWidth;
var grooveEnd = center + halfWidth;
if (grooveStart > cursor + OpenNest.Math.Tolerance.Epsilon)
entities.Add(MakeLine(pos, cursor, pos, grooveStart, isVertical));
entities.Add(MakeLine(pos, grooveStart, pos - depth, center, isVertical));
entities.Add(MakeLine(pos - depth, center, pos, grooveEnd, isVertical));
cursor = grooveEnd;
}
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
entities.Add(MakeLine(pos, cursor, pos, extentEnd, isVertical));
return entities;
}
private static List<Entity> BuildSpikeSide(List<double> pairPositions, double halfWidth, double depth,
double extentStart, double extentEnd, double pos, bool isVertical)
{
var entities = new List<Entity>();
var cursor = extentEnd;
for (var i = pairPositions.Count - 1; i >= 0; i--)
{
var center = pairPositions[i];
var spikeEnd = center + halfWidth;
var spikeStart = center - halfWidth;
if (cursor > spikeEnd + OpenNest.Math.Tolerance.Epsilon)
entities.Add(MakeLine(pos, cursor, pos, spikeEnd, isVertical));
entities.Add(MakeLine(pos, spikeEnd, pos - depth, center, isVertical));
entities.Add(MakeLine(pos - depth, center, pos, spikeStart, isVertical));
cursor = spikeStart;
}
if (cursor > extentStart + OpenNest.Math.Tolerance.Epsilon)
entities.Add(MakeLine(pos, cursor, pos, extentStart, isVertical));
return entities;
}
private static Line MakeLine(double splitAxis1, double along1, double splitAxis2, double along2, bool isVertical)
{
return isVertical
? new Line(new Vector(splitAxis1, along1), new Vector(splitAxis2, along2))
: new Line(new Vector(along1, splitAxis1), new Vector(along2, splitAxis2));
}
}

View File

@@ -0,0 +1,39 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest;
/// <summary>
/// Defines a split line at a position along an axis.
/// For Vertical, Position is the X coordinate. For Horizontal, Position is the Y coordinate.
/// </summary>
public class SplitLine
{
public double Position { get; }
public CutOffAxis Axis { get; }
/// <summary>
/// Optional custom center positions for features (tabs/spikes) along the split line.
/// Values are absolute coordinates on the perpendicular axis.
/// When empty, feature generators use their default even spacing.
/// </summary>
public List<double> FeaturePositions { get; set; } = new();
public SplitLine(double position, CutOffAxis axis)
{
Position = position;
Axis = axis;
}
/// <summary>
/// Returns a Line entity at the split position spanning the given extent range.
/// For Vertical: line from (Position, extentStart) to (Position, extentEnd).
/// For Horizontal: line from (extentStart, Position) to (extentEnd, Position).
/// </summary>
public Line ToLine(double extentStart, double extentEnd)
{
return Axis == CutOffAxis.Vertical
? new Line(Position, extentStart, Position, extentEnd)
: new Line(extentStart, Position, extentEnd, Position);
}
}

View File

@@ -0,0 +1,81 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest;
/// <summary>
/// Static helpers for testing entity-splitline intersections.
/// </summary>
public static class SplitLineIntersect
{
/// <summary>
/// Finds the intersection point between an entity and a split line.
/// Returns null if no intersection or the entity doesn't straddle the split line.
/// </summary>
public static Vector? FindIntersection(Entity entity, SplitLine sl)
{
if (!CrossesSplitLine(entity, sl))
return null;
var bbox = entity.BoundingBox;
var margin = 1.0;
// Create a line at the split position spanning the entity's bbox extent (with margin)
Line splitLine;
if (sl.Axis == CutOffAxis.Vertical)
splitLine = sl.ToLine(bbox.Bottom - margin, bbox.Top + margin);
else
splitLine = sl.ToLine(bbox.Left - margin, bbox.Right + margin);
switch (entity.Type)
{
case EntityType.Line:
var line = (Line)entity;
if (Intersect.Intersects(line, splitLine, out var pt))
return pt;
return null;
case EntityType.Arc:
var arc = (Arc)entity;
if (Intersect.Intersects(arc, splitLine, out var pts))
return pts.Count > 0 ? pts[0] : null;
return null;
default:
return null;
}
}
/// <summary>
/// Returns true if the entity's bounding box straddles the split line,
/// meaning it extends to both sides of the split position (not just touching).
/// </summary>
public static bool CrossesSplitLine(Entity entity, SplitLine sl)
{
var bbox = entity.BoundingBox;
if (sl.Axis == CutOffAxis.Vertical)
return bbox.Left < sl.Position - Tolerance.Epsilon
&& bbox.Right > sl.Position + Tolerance.Epsilon;
else
return bbox.Bottom < sl.Position - Tolerance.Epsilon
&& bbox.Top > sl.Position + Tolerance.Epsilon;
}
/// <summary>
/// Returns -1 if the point is below/left of the split line,
/// +1 if above/right, or 0 if on the line (within tolerance).
/// </summary>
public static int SideOf(Vector pt, SplitLine sl)
{
var value = sl.Axis == CutOffAxis.Vertical ? pt.X : pt.Y;
var diff = value - sl.Position;
if (System.Math.Abs(diff) <= Tolerance.Epsilon)
return 0;
return diff < 0 ? -1 : 1;
}
}

View File

@@ -0,0 +1,35 @@
namespace OpenNest;
public enum SplitType
{
Straight,
WeldGapTabs,
SpikeGroove
}
public class SplitParameters
{
public SplitType Type { get; set; } = SplitType.Straight;
// Tab parameters
public double TabWidth { get; set; } = 1.0;
public double TabHeight { get; set; } = 0.125;
public int TabCount { get; set; } = 3;
// Spike/Groove parameters
public double SpikeDepth { get; set; } = 0.5;
public double GrooveDepth { get; set; } = 0.625;
public double SpikeWeldGap { get; set; } = 0.125;
public double SpikeAngle { get; set; } = 60.0; // degrees
public int SpikePairCount { get; set; } = 2;
/// <summary>
/// Max protrusion from the split edge (for auto-fit plate size calculation).
/// </summary>
public double FeatureOverhang => Type switch
{
SplitType.WeldGapTabs => TabHeight,
SplitType.SpikeGroove => System.Math.Max(SpikeDepth, GrooveDepth),
_ => 0
};
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest;
public class StraightSplit : ISplitFeature
{
public string Name => "Straight";
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
{
var (negEdge, posEdge) = line.Axis == CutOffAxis.Vertical
? (new Line(new Vector(line.Position, extentStart), new Vector(line.Position, extentEnd)),
new Line(new Vector(line.Position, extentEnd), new Vector(line.Position, extentStart)))
: (new Line(new Vector(extentStart, line.Position), new Vector(extentEnd, line.Position)),
new Line(new Vector(extentEnd, line.Position), new Vector(extentStart, line.Position)));
return new SplitFeatureResult(
new List<Entity> { negEdge },
new List<Entity> { posEdge });
}
}

View File

@@ -0,0 +1,92 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Generates rectangular tabs on one side of the split edge (negative side).
/// The positive side remains a straight line. Tabs act as weld-gap spacers.
/// </summary>
public class WeldGapTabSplit : ISplitFeature
{
public string Name => "Weld-Gap Tabs";
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
{
var extent = extentEnd - extentStart;
var tabCount = parameters.TabCount;
var tabWidth = parameters.TabWidth;
var tabHeight = parameters.TabHeight;
// Use custom positions if provided, otherwise evenly space
var tabCenters = new List<double>();
if (line.FeaturePositions.Count > 0)
{
tabCenters.AddRange(line.FeaturePositions);
}
else
{
var spacing = extent / (tabCount + 1);
for (var i = 0; i < tabCount; i++)
tabCenters.Add(extentStart + spacing * (i + 1));
}
var negEntities = new List<Entity>();
var isVertical = line.Axis == CutOffAxis.Vertical;
var pos = line.Position;
// Tabs protrude toward the negative side (lower coordinate on the split axis)
var tabDir = -1.0;
var cursor = extentStart;
for (var i = 0; i < tabCenters.Count; i++)
{
var tabCenter = tabCenters[i];
var tabStart = tabCenter - tabWidth / 2;
var tabEnd = tabCenter + tabWidth / 2;
if (isVertical)
{
if (tabStart > cursor + OpenNest.Math.Tolerance.Epsilon)
negEntities.Add(new Line(new Vector(pos, cursor), new Vector(pos, tabStart)));
negEntities.Add(new Line(new Vector(pos, tabStart), new Vector(pos + tabDir * tabHeight, tabStart)));
negEntities.Add(new Line(new Vector(pos + tabDir * tabHeight, tabStart), new Vector(pos + tabDir * tabHeight, tabEnd)));
negEntities.Add(new Line(new Vector(pos + tabDir * tabHeight, tabEnd), new Vector(pos, tabEnd)));
}
else
{
if (tabStart > cursor + OpenNest.Math.Tolerance.Epsilon)
negEntities.Add(new Line(new Vector(cursor, pos), new Vector(tabStart, pos)));
negEntities.Add(new Line(new Vector(tabStart, pos), new Vector(tabStart, pos + tabDir * tabHeight)));
negEntities.Add(new Line(new Vector(tabStart, pos + tabDir * tabHeight), new Vector(tabEnd, pos + tabDir * tabHeight)));
negEntities.Add(new Line(new Vector(tabEnd, pos + tabDir * tabHeight), new Vector(tabEnd, pos)));
}
cursor = tabEnd;
}
// Final segment from last tab to extent end
if (isVertical)
{
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
negEntities.Add(new Line(new Vector(pos, cursor), new Vector(pos, extentEnd)));
}
else
{
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
negEntities.Add(new Line(new Vector(cursor, pos), new Vector(extentEnd, pos)));
}
// Positive side: plain straight line (reversed direction)
var posEntities = new List<Entity>();
if (isVertical)
posEntities.Add(new Line(new Vector(pos, extentEnd), new Vector(pos, extentStart)));
else
posEntities.Add(new Line(new Vector(extentEnd, pos), new Vector(extentStart, pos)));
return new SplitFeatureResult(negEntities, posEntities);
}
}

View File

@@ -1,8 +1,9 @@
using System;
using System.Linq;
using OpenNest.Api;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System;
using System.Linq;
namespace OpenNest
{
@@ -83,7 +84,7 @@ namespace OpenNest
time += TimeSpan.FromSeconds(info.TravelDistance / cutParams.RapidTravelRate);
break;
}
time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks);
return time;

View File

@@ -19,7 +19,7 @@ namespace OpenNest
case Units.Millimeters:
return "mm";
default:
default:
return string.Empty;
}
}
@@ -34,7 +34,7 @@ namespace OpenNest
case Units.Millimeters:
return "millimeters";
default:
default:
return string.Empty;
}
}
@@ -49,7 +49,7 @@ namespace OpenNest
case Units.Millimeters:
return "sec";
default:
default:
return string.Empty;
}
}

View File

@@ -1,97 +0,0 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using OpenNest.Engine.ML;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// Builds candidate rotation angles for single-item fill. Encapsulates the
/// full pipeline: base angles, narrow-area sweep, ML prediction, and
/// known-good pruning across fills.
/// </summary>
public class AngleCandidateBuilder
{
private readonly HashSet<double> knownGoodAngles = new();
public bool ForceFullSweep { get; set; }
public List<double> Build(NestItem item, double bestRotation, Box workArea)
{
var angles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
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);
var needsSweep = workAreaShortSide < partLongestSide || ForceFullSweep;
if (needsSweep)
{
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);
}
}
if (!ForceFullSweep && angles.Count > 2)
{
var features = FeatureExtractor.Extract(item.Drawing);
if (features != null)
{
var predicted = AnglePredictor.PredictAngles(
features, workArea.Width, workArea.Length);
if (predicted != null)
{
var mlAngles = new List<double>(predicted);
if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
mlAngles.Add(bestRotation);
if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
mlAngles.Add(bestRotation + Angle.HalfPI);
Debug.WriteLine($"[AngleCandidateBuilder] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
angles = mlAngles;
}
}
}
if (knownGoodAngles.Count > 0 && !ForceFullSweep)
{
var pruned = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
foreach (var a in knownGoodAngles)
{
if (!pruned.Any(existing => existing.IsEqualTo(a)))
pruned.Add(a);
}
Debug.WriteLine($"[AngleCandidateBuilder] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
return pruned;
}
return angles;
}
/// <summary>
/// Records angles that produced results. These are used to prune
/// subsequent Build() calls.
/// </summary>
public void RecordProductive(List<AngleResult> angleResults)
{
foreach (var ar in angleResults)
{
if (ar.PartCount > 0)
knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
}
}
}
}

View File

@@ -1,81 +0,0 @@
using System;
using OpenNest.Math;
namespace OpenNest
{
internal static class BestCombination
{
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;
count2 = 0;
var remnant = overallLength - maxCountLength1 * length1;
if (remnant.IsEqualTo(0))
return true;
for (int countLength1 = 0; countLength1 <= maxCountLength1; ++countLength1)
{
var remnant1 = overallLength - countLength1 * length1;
if (remnant1 >= length2)
{
var countLength2 = (int)System.Math.Floor(remnant1 / length2);
var remnant2 = remnant1 - length2 * countLength2;
if (!(remnant2 < remnant))
continue;
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;
}
}
return true;
}
}
}

View File

@@ -1,25 +1,27 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using OpenNest.Converters;
using OpenNest.Engine.BestFit.Tiling;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
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

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{

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");
}
}
}
}

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