Compare commits

..

165 Commits

Author SHA1 Message Date
afbbc9ed79 feat: improve EntityView labels for circles and small entities
Place circle labels on the circumference using golden angle distribution
so concentric circles don't overlap. Hide labels when the entity is too
small on screen to fit the badge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:05:32 -04:00
6071e6fa14 refactor: remove Plate menu Fill and Fill Area items replaced by Ctrl+F
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:54:15 -04:00
afdd386456 feat: add entity index labels toggle to EntityView and CadConverterForm
Labels are drawn at each entity's midpoint with a filled background
circle for readability. Toggle via "Labels" checkbox in the detail bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:49:07 -04:00
2db8c49838 feat: add etch mark entities from bend lines to CNC program pipeline
Etch marks for up bends are now real geometry entities on an ETCH layer
instead of being drawn dynamically. They flow through the full pipeline:
entities → FilterPanel layers → ConvertGeometry (tagged as Scribe) →
post-processor sequencing before cut geometry.

Also includes ShapeProfile normalization (CW perimeter, CCW cutouts)
applied consistently across all import paths, and inward offset support
for cutout shapes in overlap/offset polygon calculations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 00:42:49 -04:00
80e8693da3 fix: add overlap detection safety net for pair tiling
Shape.OffsetOutward produces inward offsets for certain rotated polygons,
causing geometry-aware copy distances to be too small and placing
overlapping parts. Root cause is in the offset winding direction
detection — this commit adds safety nets while that is investigated.

- FillLinear.FillGrid: detect bbox overlaps after geometry-aware tiling,
  fall back to bbox-based spacing when overlaps found
- FillExtents.RepeatColumns: detect overlaps after Compactor computes
  copy distance, fall back to columnWidth + spacing
- PairFiller/StripeFiller remnant fills: use FillLinear directly instead
  of spawning full engine pipeline (avoids strategies with the bug)
- Add PairOverlapDiagnosticTests reproducing the issue
- MCP config: use shadow-copy wrapper for dev hot-reload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:52:50 -04:00
d7eb3ebd7a fix: skip aspect ratio rejection when best-fit utilization is high
High-utilization pairs (>=75%) are no longer discarded for exceeding
the aspect ratio limit, since the material isn't being wasted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:15:45 -04:00
4404d3a5d0 feat: add OpenNest.Data machine configuration system 2026-03-27 20:30:55 -04:00
d27dee3db9 feat: add MachineConfigForm editor with tree navigation and MainForm menu integration
Wires the OpenNest.Data layer into the UI: adds project reference, creates MachineConfigForm (tree-based editor for machines/materials/thicknesses with import/export), and adds Tools > Machine Configuration... menu item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:27:44 -04:00
7081c7b4d0 feat: add embedded CL-980 default config with first-run EnsureDefaults
Embeds CL-980.json as a resource in OpenNest.Data and adds EnsureDefaults()
to LocalJsonProvider, which seeds the machines directory on first run when empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:23:00 -04:00
a6e813bc85 feat: add IDataProvider interface and LocalJsonProvider with JSON file CRUD
One JSON file per machine named by GUID, stored in a configurable directory.
Supports save, load, list (as summaries), and delete with IO-error retry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:19:56 -04:00
98453243fc feat: add MachineConfig, MaterialConfig, MachineSummary with parameter lookup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:16:59 -04:00
64874857a1 feat: add LeadConfig, CutOffConfig, and ThicknessConfig data models 2026-03-27 20:14:39 -04:00
5d3fcb2dc8 feat: add OpenNest.Data project with MachineType and UnitSystem enums 2026-03-27 20:13:54 -04:00
ae9a63b5ce feat: add Parts/Groups tabs with editable material, thickness, and per-group plate sizes
- Parts tab: shows all BOM items, editable Material/Thickness for
  matched rows, grayed-out rows for items without DXF files
- Groups tab: auto-computed from parts with editable Plate Width/Length
  per material+thickness group
- Editing Material/Thickness on Parts tab immediately re-groups
- Per-group plate sizes preserved across re-groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:50:06 -04:00
596328148d fix: correct dock z-order so Fill control gets remaining space
Fill must be at index 0 (front) so it's processed last by the
docking layout engine. Edge docks at higher indices process first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:07:32 -04:00
6cd48a623d fix: use fixed height for input group instead of AutoSize
AutoSize with Dock.Fill child causes circular sizing and collapses
the GroupBox. Use fixed Height=200 instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:04:43 -04:00
42243c7df0 fix: rewrite BomImportForm layout using TableLayoutPanel for DPI safety
Replaced absolute-positioned controls with TableLayoutPanel in the input
section and Dock-based layout for bottom buttons. Fixes controls being
hidden at non-100% DPI scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:58:50 -04:00
4b10d4801c fix: correct dock order and make BomImportForm resizable
- Add Fill control last so edge-docked controls get space first
- Remove stale hardcoded Location on bottom panel
- Switch to Sizable border with MinimumSize so user can resize

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:55:18 -04:00
f0bdaa14e6 fix: increase BomImportForm size and enable font auto-scaling
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:50:23 -04:00
79ddce346b fix: move mnuFileImportBom construction before AddRange to avoid null reference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:47:05 -04:00
20777541c0 fix: address review findings — MdiParent conflict, null guard, Drawing.Material
- Fix critical: use MdiParentForm (custom property) instead of MdiParent
  (WinForms property) in ImportBom_Click to avoid InvalidOperationException
- Add null guard in CreateNests_Click
- Set Drawing.Material from BOM group
- Move DxfImporter creation outside loop
- Improve summary label text with reason descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:40:18 -04:00
7c8168b002 feat: add 'Import BOM...' menu item to MainForm File menu 2026-03-27 17:35:48 -04:00
203bd4eeea feat: add BomImportForm nest creation logic
Task 9: CreateNests_Click validates plate dimensions, then for each
MaterialGroup creates a Nest with name '{job} - {thickness} {material}',
sets PlateDefaults (size, thickness, material, quadrant=1, spacing),
imports each matched DXF via DxfImporter, converts entities to Program
with leading RapidMove offset handling (same pattern as CadConverterForm),
sets Quantity.Required from BOM qty, then opens an EditNestForm for each
nest that has drawings. Summary MessageBox reports count and import errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:34:15 -04:00
02d15dea9c feat: add BomImportForm file browsing and analysis logic
Task 8: BrowseBom_Click auto-fills DXF folder and derives job name by
stripping ' BOM' suffix; BrowseDxf_Click opens folder browser;
Analyze_Click reads BOM via BomReader, runs BomAnalyzer, populates
DataGridView with material/thickness/parts/qty columns, updates summary
label with skipped/unmatched counts, and enables Create Nests button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:33:39 -04:00
a88937b716 feat: add BomImportForm designer layout and shell
Task 7: WinForms dialog for BOM import with Input groupbox (job name,
BOM file, DXF folder, plate size, Analyze button), Material Groups
DataGridView, and bottom panel (summary label, Create Nests, Close).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:33:13 -04:00
986a0412b1 feat: add BomAnalyzer — groups BOM items by material+thickness and matches DXFs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:28:45 -04:00
e7f2ee80e2 test: add BomAnalyzer tests (red — implementation pending)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:27:24 -04:00
31063d954d feat: add Fraction parsing utility for BOM descriptions 2026-03-27 17:24:55 -04:00
fc1fee54cd feat: add BomItem model and BomReader Excel parser 2026-03-27 17:24:43 -04:00
094b522644 feat: add ColumnAttribute and CellExtensions for BOM parsing 2026-03-27 17:24:15 -04:00
45dea4ec2b chore: add ClosedXML NuGet package to OpenNest.IO for BOM import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:23:41 -04:00
743bb25f7b chore: add EPPlus NuGet package to OpenNest.IO for BOM import 2026-03-27 17:19:34 -04:00
a34811bb6d fix: address review findings — input validation, exception handling, cleanup
Add argument validation to EllipseConverter.Convert for tolerance and
semi-axis parameters. Narrow bare catch in Extensions.cs spline method
to log via Debug.WriteLine. Remove unused lineCount variable from
SolidWorksBendDetectorTests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:34:13 -04:00
9b460f77e5 test: add DXF import integration test for ellipse-to-arc conversion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:32:14 -04:00
85bf779f21 feat: wire up EllipseConverter and SplineConverter in DXF import pipeline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:24:58 -04:00
641c1cd461 feat: add SplineConverter with tangent-chained arc fitting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:16:12 -04:00
4a5ed1b9c0 feat: add EllipseConverter arc fitting with normal-constrained G1 continuity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:01:55 -04:00
c40941ed35 feat: add EllipseConverter evaluation helpers with tests
Add EllipseConverter static class with foundational methods for converting
ellipse parameters to circular arcs: EvaluatePoint, EvaluateTangent,
EvaluateNormal, and IntersectNormals. All 8 unit tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:50:06 -04:00
d6184fdc8f docs: add implementation plan for direct arc conversion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:47:13 -04:00
d61ec1747a docs: add design spec for direct spline/ellipse to arc conversion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:39:10 -04:00
7b815c9579 feat: auto-detect simplifiable geometry in CAD converter
When a file is loaded, a background task analyzes the entities for
simplification candidates and highlights the Simplify button with a
count when candidates are found. Button resets after simplification
is applied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:15:05 -04:00
5568789902 feat: add fill strategy enable/disable settings in options
OptionsForm now shows checkboxes for each fill strategy, persisted via
the new DisabledStrategies user setting. FillStrategyRegistry exposes
AllStrategies and DisabledNames for the UI. MainForm applies disabled
strategies on startup via OptionsForm.ApplyDisabledStrategies().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:14:10 -04:00
fd93cc9db2 test: add engine and strategy overlap tests, update stripe filler tests
New EngineOverlapTests verifies all engine types produce overlap-free
results. New StrategyOverlapTests checks each fill strategy individually.
StripeFillerTests updated to verify returned parts are overlap-free
rather than just asserting non-empty results. Remove obsolete FitCircle
tests from GeometrySimplifierTests (method was removed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:13:47 -04:00
740fd79adc fix: add overlap validation guards to FillExtents and StripeFiller
FillExtents falls back to the unadjusted column when iterative pair
adjustment shifts parts enough to cause genuine overlap. StripeFiller
rejects grid results where bounding boxes overlap, which can occur when
angle convergence produces slightly off-axis rotations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:13:35 -04:00
e1b6752ede fix: improve overlap detection to ignore touch points and add bounding box pre-filtering
Part.Intersects now filters out intersection points that coincide with
vertices of both perimeters (shared corners/endpoints), which are touch
points rather than actual crossings. Plate.HasOverlappingParts adds a
bounding box pre-filter requiring overlap region to exceed Epsilon in
both dimensions before performing expensive shape intersection checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:13:21 -04:00
18d9bbadfa refactor: extract SimplifierViewerForm designer file
Convert SimplifierViewerForm to partial class with standard WinForms
designer pattern. UI controls are now defined in the .Designer.cs file
with InitializeComponent(), enabling visual designer support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:11:12 -04:00
e27def388f fix: geometry simplifier arc connectivity and ellipse support
Three bugs prevented the simplifier from working on ellipse geometry:

1. Sweep angle check blocked initial fit — the 5-degree minimum sweep
   was inside TryFit(), killing candidates before the extension loop
   could accumulate enough segments. Moved to TryFitArcAt() after
   extension.

2. Layer reference equality split runs — entities from separate DXF
   ellipses had different Layer object instances for the same layer "0",
   splitting them into independent runs. Changed to compare Layer.Name.

3. Symmetrize replaced arcs with mirrored copies whose endpoints didn't
   match the target's original geometry, creating ~0.014 gaps. Now only
   applies mirrored arcs when endpoints are within tolerance of the
   target's boundary points.

Also: default tolerance 0.02 -> 0.004, Export DXF button in
CadConverterForm for debugging simplified geometry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:49:27 -04:00
356b989424 feat: mirror axis simplifier, bend note propagation, ellipse fixes
Geometry Simplifier:
- Replace least-squares circle fitting with mirror axis algorithm
  that constrains center to perpendicular bisector of chord, guaranteeing
  zero-gap endpoint connectivity by construction
- Golden section search optimizes center position along the axis
- Increase default tolerance from 0.005 to 0.5 for practical CNC use
- Support existing arcs in simplification runs (sample arc points to
  find larger replacement arcs spanning lines + arcs together)
- Add tolerance zone visualization (offset original geometry ±tolerance)
- Show original geometry overlay with orange dashed lines in preview
- Add "Original" checkbox to CadConverter for comparing old vs new
- Store OriginalEntities on FileListItem to prevent tolerance creep
  when re-running simplifier with different settings

Bend Detection:
- Propagate bend notes to collinear bend lines split by cutouts
  using infinite-line perpendicular distance check
- Add bend note text rendering in EntityView at bend line midpoints

DXF Import:
- Fix trimmed ellipse closing chord: only close when sweep ≈ 2π,
  preventing phantom lines through slot cutouts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:27:46 -04:00
c6652f7707 fix: remove 0 from nest name encoding and padding
Use chars.Length instead of hardcoded 36 for modulus/division since
the character set excludes 0 and O. Pad with '2' (first valid char)
instead of '0' to avoid ambiguity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:11:09 -04:00
df008081d1 fix: persist simplified entities back to FileListItem
Without this, simplified geometry was lost on file switch and
not included in the final GetDrawings output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:50:07 -04:00
0a294934ae feat: integrate geometry simplifier into CadConverterForm
Add "Simplify..." button to the detail bar and wire up SimplifierViewerForm
as a tool window with lazy creation, positioning, and entity replacement on apply.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:44:10 -04:00
f711a2e4d6 feat: add SimplifierViewerForm tool window
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:41:37 -04:00
a4df4027f1 feat: add simplifier highlight and preview rendering to EntityView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:37:45 -04:00
278bbe54ba feat: add GeometrySimplifier.Apply to replace lines with arcs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:35:08 -04:00
ca5eb53bc1 feat: add GeometrySimplifier.Analyze with incremental arc fitting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:30:10 -04:00
bbc02f6f3f feat: add ArcCandidate and Kasa circle fitting
Foundation for the geometry simplifier that will replace consecutive line
segments with fitted arcs. Adds ArcCandidate data class, GeometrySimplifier
with stub Analyze/Apply methods, and FitCircle using the Kasa algebraic
least-squares method. Also adds InternalsVisibleTo for OpenNest.Tests on
OpenNest.Core.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:22:05 -04:00
12173204d1 fix: prevent etch line layers from defaulting to layer 0 after split
DxfImporter now filters ETCH entities (like BEND) since etch marks are
generated from bends during export, not cut geometry. GeometryOptimizer
no longer merges lines/arcs across different layers and preserves layer
and color on merged entities. EntityView draws etch marks directly from
the Bends list so they remain visible without relying on imported ETCH
entities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:31:28 -04:00
cbabf5e9d1 refactor: extract shared feature utilities and sub-program registry from CincinnatiPostProcessor
Consolidate duplicated static methods (SplitFeatures, ComputeCutDistance,
IsFeatureEtch, feature ordering) from CincinnatiSheetWriter and
CincinnatiPartSubprogramWriter into a shared FeatureUtils class. Move
inline sub-program registry building from Post() into
CincinnatiPartSubprogramWriter.BuildRegistry().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:54:04 -04:00
1aac03c9ef feat: add resizable split between sidebar and viewer in CadConverterForm
Wrap the left sidebar and right entity view in a SplitContainer so the
boundary can be dragged to resize. Fixed panel on the left with a 200px
minimum width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:45:28 -04:00
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
203 changed files with 34261 additions and 1419 deletions

View File

@@ -1,8 +1,8 @@
{
"mcpServers": {
"opennest": {
"command": "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
"args": []
"command": "cmd",
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"]
}
}
}

View File

@@ -30,10 +30,12 @@ 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) → `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`.
@@ -43,7 +45,7 @@ Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the
- **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`): NFP-based nesting (not yet integrated)`AutoNester` (mixed-part nesting with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`NestResult`.
- **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.
@@ -77,15 +79,15 @@ 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:
- `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)
- `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)
@@ -113,3 +115,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- 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

@@ -35,7 +35,8 @@ public static class NestRunner
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 normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(name);
drawing.Program = pgm;

View File

@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
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();
@@ -235,7 +255,8 @@ static class NestConsole
return null;
}
var pgm = ConvertGeometry.ToProgram(geometry);
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm == null)
{
@@ -382,6 +403,100 @@ 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]");
@@ -407,6 +522,10 @@ static class NestConsole
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 +544,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

@@ -0,0 +1,96 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Drawing;
namespace OpenNest.Bending
{
public class Bend
{
public static readonly Layer EtchLayer = new Layer("ETCH")
{
Color = Color.Green,
IsVisible = true
};
private const double DefaultEtchLength = 1.0;
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);
/// <summary>
/// Generates etch mark entities for this bend (up bends only).
/// Returns 1" dashes at each end of the bend line, or the full line if shorter than 3".
/// </summary>
public List<Line> GetEtchEntities(double etchLength = DefaultEtchLength)
{
var result = new List<Line>();
if (Direction != BendDirection.Up)
return result;
var length = Length;
if (length < etchLength * 3.0)
{
result.Add(CreateEtchLine(StartPoint, EndPoint));
}
else
{
var angle = StartPoint.AngleTo(EndPoint);
var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength;
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)));
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
}
return result;
}
/// <summary>
/// Removes existing etch entities from the list and regenerates from the given bends.
/// </summary>
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
{
entities.RemoveAll(e => e.Layer == EtchLayer);
if (bends == null) return;
foreach (var bend in bends)
entities.AddRange(bend.GetEtchEntities());
}
private static Line CreateEtchLine(Vector start, Vector end)
{
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green };
}
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

@@ -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

@@ -108,7 +108,10 @@ namespace OpenNest.Converters
if (line.StartPoint != lastpt)
pgm.MoveTo(line.StartPoint);
pgm.LineTo(line.EndPoint);
var move = new LinearMove(line.EndPoint);
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
move.Layer = LayerType.Scribe;
pgm.Codes.Add(move);
lastpt = line.EndPoint;
return lastpt;

View File

@@ -59,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;
@@ -76,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;
@@ -103,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,6 +1,8 @@
using OpenNest.CNC;
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;
@@ -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

@@ -0,0 +1,217 @@
using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry
{
public static class EllipseConverter
{
private const int MaxSubdivisionDepth = 12;
private const int DeviationSamples = 20;
internal static Vector EvaluatePoint(double semiMajor, double semiMinor, double rotation, Vector center, double t)
{
var x = semiMajor * System.Math.Cos(t);
var y = semiMinor * System.Math.Sin(t);
var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation);
return new Vector(
center.X + x * cos - y * sin,
center.Y + x * sin + y * cos);
}
internal static Vector EvaluateTangent(double semiMajor, double semiMinor, double rotation, double t)
{
var tx = -semiMajor * System.Math.Sin(t);
var ty = semiMinor * System.Math.Cos(t);
var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation);
return new Vector(
tx * cos - ty * sin,
tx * sin + ty * cos);
}
internal static Vector EvaluateNormal(double semiMajor, double semiMinor, double rotation, double t)
{
// Inward normal: perpendicular to tangent, pointing toward center of curvature.
// In local coords: N(t) = (-b*cos(t), -a*sin(t))
var nx = -semiMinor * System.Math.Cos(t);
var ny = -semiMajor * System.Math.Sin(t);
var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation);
return new Vector(
nx * cos - ny * sin,
nx * sin + ny * cos);
}
internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2)
{
// Solve: p1 + s*n1 = p2 + t*n2
var det = n1.X * (-n2.Y) - (-n2.X) * n1.Y;
if (System.Math.Abs(det) < 1e-10)
return Vector.Invalid;
var dx = p2.X - p1.X;
var dy = p2.Y - p1.Y;
var s = (dx * (-n2.Y) - dy * (-n2.X)) / det;
return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y);
}
public static List<Entity> Convert(Vector center, double semiMajor, double semiMinor,
double rotation, double startParam, double endParam, double tolerance = 0.001)
{
if (tolerance <= 0)
throw new ArgumentOutOfRangeException(nameof(tolerance), "Tolerance must be positive.");
if (semiMajor <= 0 || semiMinor <= 0)
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
if (endParam <= startParam)
endParam += Angle.TwoPI;
// True circle — emit a single arc (or two for full circle)
if (System.Math.Abs(semiMajor - semiMinor) < Tolerance.Epsilon)
return ConvertCircle(center, semiMajor, rotation, startParam, endParam);
var splits = GetInitialSplits(startParam, endParam);
var entities = new List<Entity>();
for (var i = 0; i < splits.Count - 1; i++)
FitSegment(center, semiMajor, semiMinor, rotation,
splits[i], splits[i + 1], tolerance, entities, 0);
return entities;
}
private static List<Entity> ConvertCircle(Vector center, double radius,
double rotation, double startParam, double endParam)
{
var sweep = endParam - startParam;
var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01;
if (isFull)
{
var startAngle1 = Angle.NormalizeRad(startParam + rotation);
var midAngle = Angle.NormalizeRad(startParam + System.Math.PI + rotation);
var endAngle2 = startAngle1;
return new List<Entity>
{
new Arc(center, radius, startAngle1, midAngle, false),
new Arc(center, radius, midAngle, endAngle2, false)
};
}
var sa = Angle.NormalizeRad(startParam + rotation);
var ea = Angle.NormalizeRad(endParam + rotation);
return new List<Entity> { new Arc(center, radius, sa, ea, false) };
}
private static List<double> GetInitialSplits(double startParam, double endParam)
{
var splits = new List<double> { startParam };
var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2);
for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2)
{
if (q > startParam + 1e-10 && q < endParam - 1e-10)
splits.Add(q);
}
splits.Add(endParam);
return splits;
}
private static void FitSegment(Vector center, double semiMajor, double semiMinor,
double rotation, double t0, double t1, double tolerance, List<Entity> results, int depth)
{
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0);
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1);
if (p0.DistanceTo(p1) < 1e-10)
return;
var n0 = EvaluateNormal(semiMajor, semiMinor, rotation, t0);
var n1 = EvaluateNormal(semiMajor, semiMinor, rotation, t1);
var arcCenter = IntersectNormals(p0, n0, p1, n1);
if (!arcCenter.IsValid() || depth >= MaxSubdivisionDepth)
{
results.Add(new Line(p0, p1));
return;
}
var radius = p0.DistanceTo(arcCenter);
var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation,
t0, t1, arcCenter, radius);
if (maxDev <= tolerance)
{
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
}
else
{
var tMid = (t0 + t1) / 2.0;
FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1);
FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1);
}
}
private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor,
double rotation, double t0, double t1, Vector arcCenter, double radius)
{
var maxDev = 0.0;
for (var i = 1; i <= DeviationSamples; i++)
{
var t = t0 + (t1 - t0) * i / DeviationSamples;
var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t);
var dist = p.DistanceTo(arcCenter);
var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev;
}
return maxDev;
}
private static Arc CreateArc(Vector arcCenter, double radius,
Vector ellipseCenter, double semiMajor, double semiMinor, double rotation,
double t0, double t1)
{
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
var startAngle = System.Math.Atan2(p0.Y - arcCenter.Y, p0.X - arcCenter.X);
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
var pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2);
var points = new List<Vector> { p0, pMid, p1 };
var isReversed = SumSignedAngles(arcCenter, points) < 0;
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
}
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI;
while (da < -System.Math.PI) da += Angle.TwoPI;
total += da;
}
return total;
}
}
}

View File

@@ -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

@@ -76,6 +76,9 @@ namespace OpenNest.Geometry
if (line1 == line2)
return false;
if (line1.Layer?.Name != line2.Layer?.Name)
return false;
if (!line1.IsCollinearTo(line2))
return false;
@@ -113,9 +116,9 @@ namespace OpenNest.Geometry
var b = b1 < b2 ? b1 : b2;
if (!line1.IsVertical() && line1.Slope() < 0)
lineOut = new Line(new Vector(l, t), new Vector(r, b));
lineOut = new Line(new Vector(l, t), new Vector(r, b)) { Layer = line1.Layer, Color = line1.Color };
else
lineOut = new Line(new Vector(l, b), new Vector(r, t));
lineOut = new Line(new Vector(l, b), new Vector(r, t)) { Layer = line1.Layer, Color = line1.Color };
return true;
}
@@ -127,28 +130,44 @@ namespace OpenNest.Geometry
if (arc1 == arc2)
return false;
if (arc1.Layer?.Name != arc2.Layer?.Name)
return false;
if (arc1.Center != arc2.Center)
return false;
if (!arc1.Radius.IsEqualTo(arc2.Radius))
return false;
if (arc1.StartAngle > arc1.EndAngle)
arc1.StartAngle -= Angle.TwoPI;
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;
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle);
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle) { Layer = arc1.Layer, Color = arc1.Color };
return true;
}

View File

@@ -0,0 +1,703 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry;
public class ArcCandidate
{
public int ShapeIndex { get; set; }
public int StartIndex { get; set; }
public int EndIndex { get; set; }
public int LineCount => EndIndex - StartIndex + 1;
public Arc FittedArc { get; set; }
public double MaxDeviation { get; set; }
public Box BoundingBox { get; set; }
public bool IsSelected { get; set; } = true;
/// <summary>First point of the original line segments this candidate covers.</summary>
public Vector FirstPoint { get; set; }
/// <summary>Last point of the original line segments this candidate covers.</summary>
public Vector LastPoint { get; set; }
}
/// <summary>
/// A mirror axis defined by a point on the axis and a unit direction vector.
/// </summary>
public class MirrorAxisResult
{
public static readonly MirrorAxisResult None = new(Vector.Invalid, Vector.Invalid, 0);
public Vector Point { get; }
public Vector Direction { get; }
public double Score { get; }
public bool IsValid => Point.IsValid();
public MirrorAxisResult(Vector point, Vector direction, double score)
{
Point = point;
Direction = direction;
Score = score;
}
/// <summary>Reflects a point across this axis.</summary>
public Vector Reflect(Vector p)
{
var dx = p.X - Point.X;
var dy = p.Y - Point.Y;
var dot = dx * Direction.X + dy * Direction.Y;
return new Vector(
p.X - 2 * (dx - dot * Direction.X),
p.Y - 2 * (dy - dot * Direction.Y));
}
}
public class GeometrySimplifier
{
public double Tolerance { get; set; } = 0.004;
public int MinLines { get; set; } = 3;
public List<ArcCandidate> Analyze(Shape shape)
{
var candidates = new List<ArcCandidate>();
var entities = shape.Entities;
var i = 0;
while (i < entities.Count)
{
if (entities[i] is not Line and not Arc)
{
i++;
continue;
}
var runStart = i;
var layerName = entities[i].Layer?.Name;
var lineCount = 0;
while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer?.Name == layerName)
{
if (entities[i] is Line) lineCount++;
i++;
}
var runEnd = i - 1;
if (lineCount >= MinLines)
FindCandidatesInRun(entities, runStart, runEnd, candidates);
}
return candidates;
}
public Shape Apply(Shape shape, List<ArcCandidate> candidates)
{
var selected = candidates
.Where(c => c.IsSelected)
.OrderBy(c => c.StartIndex)
.ToList();
var newEntities = new List<Entity>();
var i = 0;
foreach (var candidate in selected)
{
while (i < candidate.StartIndex)
{
newEntities.Add(shape.Entities[i]);
i++;
}
newEntities.Add(candidate.FittedArc);
i = candidate.EndIndex + 1;
}
while (i < shape.Entities.Count)
{
newEntities.Add(shape.Entities[i]);
i++;
}
var result = new Shape();
result.Entities.AddRange(newEntities);
return result;
}
/// <summary>
/// Detects the mirror axis of a shape by testing candidate axes through the
/// centroid. Uses PCA to find principal directions, then also tests horizontal
/// and vertical. Works for shapes rotated at any angle.
/// </summary>
public static MirrorAxisResult DetectMirrorAxis(Shape shape)
{
var midpoints = new List<Vector>();
foreach (var e in shape.Entities)
midpoints.Add(e.BoundingBox.Center);
if (midpoints.Count < 4) return MirrorAxisResult.None;
// Centroid
var cx = 0.0;
var cy = 0.0;
foreach (var p in midpoints) { cx += p.X; cy += p.Y; }
cx /= midpoints.Count;
cy /= midpoints.Count;
var centroid = new Vector(cx, cy);
// Covariance matrix for PCA
var cxx = 0.0;
var cxy = 0.0;
var cyy = 0.0;
foreach (var p in midpoints)
{
var dx = p.X - cx;
var dy = p.Y - cy;
cxx += dx * dx;
cxy += dx * dy;
cyy += dy * dy;
}
// Eigenvectors of 2x2 symmetric matrix via analytic formula
var trace = cxx + cyy;
var det = cxx * cyy - cxy * cxy;
var disc = System.Math.Sqrt(System.Math.Max(0, trace * trace / 4 - det));
var lambda1 = trace / 2 + disc;
var lambda2 = trace / 2 - disc;
var candidates = new List<Vector>();
// PCA eigenvectors (major and minor axes)
if (System.Math.Abs(cxy) > 1e-10)
{
candidates.Add(Normalize(new Vector(lambda1 - cyy, cxy)));
candidates.Add(Normalize(new Vector(lambda2 - cyy, cxy)));
}
else
{
candidates.Add(new Vector(1, 0));
candidates.Add(new Vector(0, 1));
}
// Also always test pure horizontal and vertical
candidates.Add(new Vector(1, 0));
candidates.Add(new Vector(0, 1));
// Score each candidate axis
var bestResult = MirrorAxisResult.None;
foreach (var dir in candidates)
{
var score = MirrorMatchScore(midpoints, centroid, dir);
if (score > bestResult.Score)
bestResult = new MirrorAxisResult(centroid, dir, score);
}
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
}
private static Vector Normalize(Vector v)
{
var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
return len < 1e-10 ? new Vector(1, 0) : new Vector(v.X / len, v.Y / len);
}
private static double MirrorMatchScore(List<Vector> points, Vector axisPoint, Vector axisDir)
{
var matchTol = 0.1;
var matched = 0;
for (var i = 0; i < points.Count; i++)
{
var p = points[i];
// Distance from point to axis
var dx = p.X - axisPoint.X;
var dy = p.Y - axisPoint.Y;
var dot = dx * axisDir.X + dy * axisDir.Y;
var perpX = dx - dot * axisDir.X;
var perpY = dy - dot * axisDir.Y;
var dist = System.Math.Sqrt(perpX * perpX + perpY * perpY);
// Points on the axis count as matched
if (dist < matchTol)
{
matched++;
continue;
}
// Reflect across axis and look for partner
var mx = p.X - 2 * perpX;
var my = p.Y - 2 * perpY;
for (var j = 0; j < points.Count; j++)
{
if (i == j) continue;
var d = System.Math.Sqrt((points[j].X - mx) * (points[j].X - mx) +
(points[j].Y - my) * (points[j].Y - my));
if (d < matchTol)
{
matched++;
break;
}
}
}
return (double)matched / points.Count;
}
/// <summary>
/// Pairs candidates across a mirror axis and forces each pair to use
/// the same arc (mirrored). The candidate with more lines or lower
/// deviation is kept as the source.
/// </summary>
public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis)
{
if (!axis.IsValid || candidates.Count < 2) return;
var paired = new HashSet<int>();
for (var i = 0; i < candidates.Count; i++)
{
if (paired.Contains(i)) continue;
var ci = candidates[i];
var ciCenter = ci.BoundingBox.Center;
// Distance from candidate center to axis
var dx = ciCenter.X - axis.Point.X;
var dy = ciCenter.Y - axis.Point.Y;
var dot = dx * axis.Direction.X + dy * axis.Direction.Y;
var perpDist = System.Math.Sqrt((dx - dot * axis.Direction.X) * (dx - dot * axis.Direction.X) +
(dy - dot * axis.Direction.Y) * (dy - dot * axis.Direction.Y));
if (perpDist < 0.1) continue; // on the axis
var mirrorCenter = axis.Reflect(ciCenter);
var bestJ = -1;
var bestDist = double.MaxValue;
for (var j = i + 1; j < candidates.Count; j++)
{
if (paired.Contains(j)) continue;
var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center);
if (d < bestDist)
{
bestDist = d;
bestJ = j;
}
}
var matchTol = System.Math.Max(ci.BoundingBox.Width, ci.BoundingBox.Length) * 0.5;
if (bestJ < 0 || bestDist > matchTol) continue;
paired.Add(i);
paired.Add(bestJ);
var cj = candidates[bestJ];
var sourceIdx = i;
var targetIdx = bestJ;
if (cj.LineCount > ci.LineCount || (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation))
{
sourceIdx = bestJ;
targetIdx = i;
}
var source = candidates[sourceIdx];
var target = candidates[targetIdx];
var mirrored = MirrorArc(source.FittedArc, axis);
// Only apply the mirrored arc if its endpoints are close enough to the
// target's actual boundary points. Otherwise the mirror introduces gaps.
var mirroredStart = mirrored.StartPoint();
var mirroredEnd = mirrored.EndPoint();
var startDist = mirroredStart.DistanceTo(target.FirstPoint);
var endDist = mirroredEnd.DistanceTo(target.LastPoint);
if (startDist <= Tolerance && endDist <= Tolerance)
{
target.FittedArc = mirrored;
target.MaxDeviation = source.MaxDeviation;
}
}
}
private static Arc MirrorArc(Arc arc, MirrorAxisResult axis)
{
var mirrorCenter = axis.Reflect(arc.Center);
// Reflect start and end points, then compute new angles
var sp = arc.StartPoint();
var ep = arc.EndPoint();
var mirrorSp = axis.Reflect(sp);
var mirrorEp = axis.Reflect(ep);
// Mirroring reverses winding — swap start/end to preserve arc direction
var mirrorStart = System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X);
var mirrorEnd = System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X);
// Normalize to [0, 2pi)
if (mirrorStart < 0) mirrorStart += Angle.TwoPI;
if (mirrorEnd < 0) mirrorEnd += Angle.TwoPI;
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
result.Layer = arc.Layer;
result.Color = arc.Color;
return result;
}
private void FindCandidatesInRun(List<Entity> entities, int runStart, int runEnd, List<ArcCandidate> candidates)
{
var j = runStart;
var chainedTangent = Vector.Invalid;
while (j <= runEnd - MinLines + 1)
{
var result = TryFitArcAt(entities, j, runEnd, chainedTangent);
if (result == null)
{
j++;
chainedTangent = Vector.Invalid;
continue;
}
chainedTangent = ComputeEndTangent(result.Center, result.Points);
candidates.Add(new ArcCandidate
{
StartIndex = j,
EndIndex = result.EndIndex,
FittedArc = CreateArc(result.Center, result.Radius, result.Points, entities[j]),
MaxDeviation = result.Deviation,
BoundingBox = result.Points.GetBoundingBox(),
FirstPoint = result.Points[0],
LastPoint = result.Points[^1],
});
j = result.EndIndex + 1;
}
}
private record ArcFitResult(Vector Center, double Radius, double Deviation, List<Vector> Points, int EndIndex);
private ArcFitResult TryFitArcAt(List<Entity> entities, int start, int runEnd, Vector chainedTangent)
{
var k = start + MinLines - 1;
if (k > runEnd) return null;
var points = CollectPoints(entities, start, k);
if (points.Count < 3) return null;
var startTangent = chainedTangent.IsValid()
? chainedTangent
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var (center, radius, dev) = TryFit(points, startTangent);
if (!center.IsValid()) return null;
// Extend the arc as far as possible
while (k + 1 <= runEnd)
{
var extPoints = CollectPoints(entities, start, k + 1);
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent) : (Vector.Invalid, 0, 0d);
if (!nc.IsValid()) break;
k++;
center = nc;
radius = nr;
dev = nd;
points = extPoints;
}
// Reject arcs that subtend a tiny angle — these are nearly-straight lines
// that happen to fit a huge circle. Applied after extension so that many small
// segments can accumulate enough sweep to qualify.
var sweep = System.Math.Abs(SumSignedAngles(center, points));
if (sweep < Angle.ToRadians(5))
return null;
return new ArcFitResult(center, radius, dev, points, k);
}
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent)
{
var (center, radius, dev) = FitWithStartTangent(points, startTangent);
if (!center.IsValid() || dev > Tolerance)
(center, radius, dev) = FitMirrorAxis(points);
if (!center.IsValid() || dev > Tolerance)
return (Vector.Invalid, 0, 0);
// Check that the arc doesn't bulge away from the original line segments
var isReversed = SumSignedAngles(center, points) < 0;
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
if (arcDev > Tolerance)
return (Vector.Invalid, 0, 0);
return (center, radius, System.Math.Max(dev, arcDev));
}
/// <summary>
/// Fits a circular arc constrained to be tangent to the given direction at the
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
/// the arc passes through both endpoints and departs P1 in the given direction.
/// </summary>
private static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
var mx = (p1.X + pn.X) / 2;
var my = (p1.Y + pn.Y) / 2;
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var bx = -dy / chordLen;
var by = dx / chordLen;
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
if (tLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var nx = -tangent.Y / tLen;
var ny = tangent.X / tLen;
var det = nx * by - ny * bx;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var t = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
var cx = p1.X + t * nx;
var cy = p1.Y + t * ny;
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (radius < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
/// <summary>
/// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc.
/// </summary>
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
{
var lastPt = points[^1];
var totalAngle = SumSignedAngles(center, points);
var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y;
if (totalAngle >= 0)
return new Vector(-ry, rx);
else
return new Vector(ry, -rx);
}
/// <summary>
/// Fits a circular arc using the mirror axis approach. The center is constrained
/// to the perpendicular bisector of the chord (P1->Pn), guaranteeing the arc
/// passes exactly through both endpoints. Golden section search optimizes position.
/// </summary>
private (Vector center, double radius, double deviation) FitMirrorAxis(List<Vector> points)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
var mx = (p1.X + pn.X) / 2;
var my = (p1.Y + pn.Y) / 2;
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var halfChord = chordLen / 2;
var nx = -dy / chordLen;
var ny = dx / chordLen;
var maxSagitta = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
var proj = (points[i].X - mx) * nx + (points[i].Y - my) * ny;
if (System.Math.Abs(proj) > System.Math.Abs(maxSagitta))
maxSagitta = proj;
}
if (System.Math.Abs(maxSagitta) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
d => MaxRadialDeviation(points, mx + d * nx, my + d * ny,
System.Math.Sqrt(halfChord * halfChord + d * d)));
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
}
private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
{
var phi = (System.Math.Sqrt(5) - 1) / 2;
for (var iter = 0; iter < 30; iter++)
{
var d1 = high - phi * (high - low);
var d2 = low + phi * (high - low);
if (eval(d1) < eval(d2))
high = d2;
else
low = d1;
if (high - low < 1e-6)
break;
}
return (low + high) / 2;
}
private static List<Vector> CollectPoints(List<Entity> entities, int start, int end)
{
var points = new List<Vector>();
for (var i = start; i <= end; i++)
{
switch (entities[i])
{
case Line line:
if (i == start)
points.Add(line.StartPoint);
points.Add(line.EndPoint);
break;
case Arc arc:
if (i == start)
points.Add(arc.StartPoint());
var segments = System.Math.Max(2, arc.SegmentsForTolerance(0.1));
var arcPoints = arc.ToPoints(segments);
for (var j = 1; j < arcPoints.Count; j++)
points.Add(arcPoints[j]);
break;
}
}
return points;
}
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
{
var firstPoint = points[0];
var lastPoint = points[^1];
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
var isReversed = SumSignedAngles(center, points) < 0;
// Normalize to [0, 2pi)
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
arc.Layer = sourceEntity.Layer;
arc.Color = sourceEntity.Color;
return arc;
}
/// <summary>
/// Sums signed angular change traversing consecutive points around a center.
/// Positive = CCW, negative = CW.
/// </summary>
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI;
while (da < -System.Math.PI) da += Angle.TwoPI;
total += da;
}
return total;
}
/// <summary>
/// Max deviation of intermediate points (excluding endpoints) from a circle.
/// </summary>
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
{
var maxDev = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
var px = points[i].X - cx;
var py = points[i].Y - cy;
var dist = System.Math.Sqrt(px * px + py * py);
var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev;
}
return maxDev;
}
/// <summary>
/// Measures the maximum distance from sampled points along the fitted arc
/// back to the original line segments. This catches cases where points lie
/// on a large circle but the arc bulges far from the original straight geometry.
/// </summary>
private static double MaxArcToSegmentDeviation(List<Vector> points, Vector center, double radius, bool isReversed)
{
var startAngle = System.Math.Atan2(points[0].Y - center.Y, points[0].X - center.X);
var endAngle = System.Math.Atan2(points[^1].Y - center.Y, points[^1].X - center.X);
var sweep = endAngle - startAngle;
if (isReversed)
{
if (sweep > 0) sweep -= Angle.TwoPI;
}
else
{
if (sweep < 0) sweep += Angle.TwoPI;
}
var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10));
sampleCount = System.Math.Min(sampleCount, 100);
var maxDev = 0.0;
for (var i = 1; i < sampleCount; i++)
{
var t = (double)i / sampleCount;
var angle = startAngle + sweep * t;
var px = center.X + radius * System.Math.Cos(angle);
var py = center.Y + radius * System.Math.Sin(angle);
var arcPt = new Vector(px, py);
var minDist = double.MaxValue;
for (var j = 0; j < points.Count - 1; j++)
{
var dist = DistanceToSegment(arcPt, points[j], points[j + 1]);
if (dist < minDist) minDist = dist;
}
if (minDist > maxDev) maxDev = minDist;
}
return maxDev;
}
private static double DistanceToSegment(Vector p, Vector a, Vector b)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
var lenSq = dx * dx + dy * dy;
if (lenSq < 1e-20)
return System.Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y));
var t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq;
t = System.Math.Max(0, System.Math.Min(1, t));
var projX = a.X + t * dx;
var projY = a.Y + t * dy;
return System.Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY));
}
}

View File

@@ -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

@@ -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

@@ -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,81 @@ 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>
/// Offsets the shape inward by the given distance.
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
/// making the method independent of the original contour winding direction.
/// </summary>
public Shape OffsetInward(double distance)
{
var poly = ToPolygon();
if (poly == null || poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CCW)
return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Create a reversed copy to avoid mutating shared entity objects.
var copy = new Shape();
for (var i = Entities.Count - 1; i >= 0; i--)
{
switch (Entities[i])
{
case Line l:
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break;
case Arc a:
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break;
case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
break;
}
}
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
}
/// <summary>
/// Gets the closest point on the shape to the given point.
/// </summary>

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Geometry
{
@@ -21,9 +22,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];
@@ -38,5 +42,52 @@ namespace OpenNest.Geometry
public Shape Perimeter { get; set; }
public List<Shape> Cutouts { get; set; }
/// <summary>
/// Ensures CNC-standard winding: perimeter CW (kerf left = outward),
/// cutouts CCW (kerf left = inward). Reverses contours in-place as needed.
/// </summary>
public void NormalizeWinding()
{
EnsureWinding(Perimeter, RotationType.CW);
foreach (var cutout in Cutouts)
EnsureWinding(cutout, RotationType.CCW);
}
/// <summary>
/// Returns the entities in normalized winding order (perimeter first, then cutouts).
/// </summary>
public List<Entity> ToNormalizedEntities()
{
NormalizeWinding();
var result = new List<Entity>(Perimeter.Entities);
foreach (var cutout in Cutouts)
result.AddRange(cutout.Entities);
return result;
}
/// <summary>
/// Convenience method: builds a ShapeProfile from raw entities,
/// normalizes winding, and returns the corrected entity list.
/// </summary>
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
{
var profile = new ShapeProfile(entities.ToList());
return profile.ToNormalizedEntities();
}
private static void EnsureWinding(Shape shape, RotationType desired)
{
var poly = shape.ToPolygon();
if (poly != null && poly.Vertices.Count >= 3
&& poly.RotationDirection() != desired)
{
shape.Reverse();
}
}
}
}

View File

@@ -0,0 +1,247 @@
using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry
{
public static class SplineConverter
{
private const int MinPointsForArc = 3;
public static List<Entity> Convert(List<Vector> points, bool isClosed, double tolerance = 0.001)
{
if (points == null || points.Count < 2)
return new List<Entity>();
var entities = new List<Entity>();
var i = 0;
var chainedTangent = Vector.Invalid;
while (i < points.Count - 1)
{
var result = TryFitArc(points, i, chainedTangent, tolerance);
if (result != null)
{
entities.Add(result.Arc);
chainedTangent = result.EndTangent;
i = result.EndIndex;
}
else
{
entities.Add(new Line(points[i], points[i + 1]));
chainedTangent = Vector.Invalid;
i++;
}
}
return entities;
}
private static ArcFitResult TryFitArc(List<Vector> points, int start,
Vector chainedTangent, double tolerance)
{
var minEnd = start + MinPointsForArc - 1;
if (minEnd >= points.Count)
return null;
var hasTangent = chainedTangent.IsValid();
var subPoints = points.GetRange(start, MinPointsForArc);
var (center, radius, dev) = hasTangent
? FitWithStartTangent(subPoints, chainedTangent)
: FitCircumscribed(subPoints);
if (!center.IsValid() || dev > tolerance)
return null;
var endIdx = minEnd;
while (endIdx + 1 < points.Count)
{
var extPoints = points.GetRange(start, endIdx + 1 - start + 1);
var (nc, nr, nd) = hasTangent
? FitWithStartTangent(extPoints, chainedTangent)
: FitCircumscribed(extPoints);
if (!nc.IsValid() || nd > tolerance)
break;
endIdx++;
center = nc;
radius = nr;
dev = nd;
}
var finalPoints = points.GetRange(start, endIdx - start + 1);
var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints));
if (sweep < Angle.ToRadians(5))
return null;
var arc = CreateArc(center, radius, finalPoints);
var endTangent = ComputeEndTangent(center, finalPoints);
return new ArcFitResult(arc, endTangent, endIdx);
}
private static (Vector center, double radius, double deviation) FitCircumscribed(
List<Vector> points)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p0 = points[0];
var pMid = points[points.Count / 2];
var pEnd = points[^1];
// Find circumcenter by intersecting perpendicular bisectors of two chords
var (center, radius) = Circumcenter(p0, pMid, pEnd);
if (!center.IsValid())
return (Vector.Invalid, 0, double.MaxValue);
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
}
private static (Vector center, double radius) Circumcenter(Vector a, Vector b, Vector c)
{
// Perpendicular bisector of chord a-b
var m1x = (a.X + b.X) / 2;
var m1y = (a.Y + b.Y) / 2;
var d1x = -(b.Y - a.Y);
var d1y = b.X - a.X;
// Perpendicular bisector of chord b-c
var m2x = (b.X + c.X) / 2;
var m2y = (b.Y + c.Y) / 2;
var d2x = -(c.Y - b.Y);
var d2y = c.X - b.X;
var det = d1x * d2y - d1y * d2x;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0);
var t = ((m2x - m1x) * d2y - (m2y - m1y) * d2x) / det;
var cx = m1x + t * d1x;
var cy = m1y + t * d1y;
var radius = System.Math.Sqrt((cx - a.X) * (cx - a.X) + (cy - a.Y) * (cy - a.Y));
if (radius < 1e-10)
return (Vector.Invalid, 0);
return (new Vector(cx, cy), radius);
}
private static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
var mx = (p1.X + pn.X) / 2;
var my = (p1.Y + pn.Y) / 2;
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var bx = -dy / chordLen;
var by = dx / chordLen;
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
if (tLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var nx = -tangent.Y / tLen;
var ny = tangent.X / tLen;
var det = nx * by - ny * bx;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var s = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
var cx = p1.X + s * nx;
var cy = p1.Y + s * ny;
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (radius < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
{
var maxDev = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
var px = points[i].X - cx;
var py = points[i].Y - cy;
var dist = System.Math.Sqrt(px * px + py * py);
var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev;
}
return maxDev;
}
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI;
while (da < -System.Math.PI) da += Angle.TwoPI;
total += da;
}
return total;
}
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
{
var lastPt = points[^1];
var totalAngle = SumSignedAngles(center, points);
var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y;
return totalAngle >= 0
? new Vector(-ry, rx)
: new Vector(ry, -rx);
}
private static Arc CreateArc(Vector center, double radius, List<Vector> points)
{
var firstPoint = points[0];
var lastPoint = points[^1];
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
var isReversed = SumSignedAngles(center, points) < 0;
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
return new Arc(center, radius, startAngle, endAngle, isReversed);
}
private sealed class ArcFitResult
{
public Arc Arc { get; }
public Vector EndTangent { get; }
public int EndIndex { get; }
public ArcFitResult(Arc arc, Vector endTangent, int endIndex)
{
Arc = arc;
EndTangent = endTangent;
EndIndex = endIndex;
}
}
}
}

View File

@@ -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

@@ -4,6 +4,9 @@
<RootNamespace>OpenNest</RootNamespace>
<AssemblyName>OpenNest.Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Clipper2" Version="2.0.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />

View File

@@ -1,6 +1,7 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
@@ -173,7 +174,53 @@ namespace OpenNest
perimeter1.Offset(Location);
perimeter2.Offset(part.Location);
return perimeter1.Intersects(perimeter2, out pts);
if (!perimeter1.Intersects(perimeter2, out var rawPts))
return false;
// Exclude intersection points that coincide with vertices of BOTH
// perimeters — these are touch points (shared corners/endpoints),
// not actual crossings where one shape enters the other's interior.
var verts1 = CollectVertices(perimeter1);
var verts2 = CollectVertices(perimeter2);
foreach (var pt in rawPts)
{
if (IsNearAnyVertex(pt, verts1) && IsNearAnyVertex(pt, verts2))
continue;
pts.Add(pt);
}
return pts.Count > 0;
}
private static List<Vector> CollectVertices(Geometry.Shape shape)
{
var verts = new List<Vector>();
foreach (var entity in shape.Entities)
{
switch (entity)
{
case Geometry.Line line:
verts.Add(line.StartPoint);
verts.Add(line.EndPoint);
break;
case Geometry.Arc arc:
verts.Add(arc.StartPoint());
verts.Add(arc.EndPoint());
break;
}
}
return verts;
}
private static bool IsNearAnyVertex(Vector pt, List<Vector> vertices)
{
foreach (var v in vertices)
{
if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y))
return true;
}
return false;
}
public double Left

View File

@@ -42,23 +42,17 @@ namespace OpenNest
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>();
var totalSpacing = spacing + chordTolerance;
foreach (var shape in shapes)
{
// Add chord tolerance to compensate for inscribed polygon chords
// being inside the actual offset arcs.
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(polygon.ToLines());
}
foreach (var cutout in profile.Cutouts)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location);
return lines;
}
@@ -66,21 +60,17 @@ namespace OpenNest
public static List<Line> GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>();
var totalSpacing = spacing + chordTolerance;
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location, facingDirection);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
foreach (var cutout in profile.Cutouts)
AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location, facingDirection);
return lines;
}
@@ -104,21 +94,17 @@ namespace OpenNest
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 profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>();
var totalSpacing = spacing + chordTolerance;
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location, facingDirection);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
foreach (var cutout in profile.Cutouts)
AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location, facingDirection);
return lines;
}
@@ -189,5 +175,41 @@ namespace OpenNest
return lines;
}
private static void AddOffsetLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(polygon.ToLines());
}
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location, PushDirection facingDirection)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location, Vector facingDirection)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
}
}

View File

@@ -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,37 @@ 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];
var b1 = part1.BoundingBox;
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];
var b2 = part2.BoundingBox;
List<Vector> pts2;
// Skip pairs whose bounding boxes don't meaningfully overlap.
// Floating-point rounding can produce sub-epsilon overlaps for
// parts that are merely edge-touching, so require the overlap
// region to exceed Epsilon in both dimensions.
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (part1.Intersects(part2, out pts2))
if (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon)
continue;
if (part1.Intersects(part2, out var pts2))
pts.AddRange(pts2);
}
}

View File

@@ -5,17 +5,17 @@ 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

@@ -6,8 +6,8 @@ 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,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

@@ -0,0 +1,9 @@
namespace OpenNest.Data;
public class CutOffConfig
{
public double PartClearance { get; set; } = 0.02;
public double Overtravel { get; set; }
public double MinSegmentLength { get; set; } = 0.05;
public string Direction { get; set; } = "AwayFromOrigin";
}

View File

@@ -0,0 +1,174 @@
{
"id": "00000000-0000-0000-0000-000000980001",
"schemaVersion": 1,
"name": "CL-980",
"type": "laser",
"units": "inches",
"materials": [
{
"name": "Mild Steel",
"grade": "A36",
"density": 0.2836,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.075,
"kerf": 0.008,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.105,
"kerf": 0.010,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.135,
"kerf": 0.010,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.1875,
"kerf": 0.012,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.250,
"kerf": 0.012,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.375,
"kerf": 0.016,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.375, "angle": 90.0, "radius": 0.1875 },
"leadOut": { "type": "Line", "length": 0.1875, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.625, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
},
{
"value": 0.500,
"kerf": 0.020,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.5, "angle": 90.0, "radius": 0.25 },
"leadOut": { "type": "Line", "length": 0.25, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.75, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
}
]
},
{
"name": "Stainless Steel",
"grade": "304",
"density": 0.289,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.075,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.105,
"kerf": 0.010,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.250,
"kerf": 0.014,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
},
{
"name": "Aluminum",
"grade": "5052",
"density": 0.097,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.080,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.125,
"kerf": 0.010,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.250,
"kerf": 0.014,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
}
]
}

View File

@@ -0,0 +1,9 @@
namespace OpenNest.Data;
public interface IDataProvider
{
IReadOnlyList<MachineSummary> GetMachines();
MachineConfig? GetMachine(Guid id);
void SaveMachine(MachineConfig machine);
void DeleteMachine(Guid id);
}

View File

@@ -0,0 +1,9 @@
namespace OpenNest.Data;
public class LeadConfig
{
public string Type { get; set; } = "Line";
public double Length { get; set; }
public double Angle { get; set; } = 90.0;
public double Radius { get; set; }
}

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Data;
public class LocalJsonProvider : IDataProvider
{
private readonly string _directory;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public LocalJsonProvider(string directory)
{
_directory = directory;
Directory.CreateDirectory(_directory);
}
public IReadOnlyList<MachineSummary> GetMachines()
{
var summaries = new List<MachineSummary>();
foreach (var file in Directory.GetFiles(_directory, "*.json"))
{
var machine = ReadFile(file);
if (machine is not null)
summaries.Add(new MachineSummary(machine.Id, machine.Name));
}
return summaries;
}
public MachineConfig? GetMachine(Guid id)
{
var path = GetPath(id);
return File.Exists(path) ? ReadFile(path) : null;
}
public void SaveMachine(MachineConfig machine)
{
var json = JsonSerializer.Serialize(machine, JsonOptions);
var path = GetPath(machine.Id);
WriteWithRetry(path, json);
}
public void DeleteMachine(Guid id)
{
var path = GetPath(id);
if (File.Exists(path))
File.Delete(path);
}
private string GetPath(Guid id) => Path.Combine(_directory, $"{id}.json");
private static MachineConfig? ReadFile(string path)
{
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<MachineConfig>(json, JsonOptions);
}
catch (JsonException)
{
return null;
}
catch (IOException)
{
return null;
}
}
public void EnsureDefaults()
{
if (Directory.GetFiles(_directory, "*.json").Length > 0)
return;
var assembly = typeof(LocalJsonProvider).Assembly;
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("CL-980.json"));
if (resourceName is null) return;
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null) return;
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var config = JsonSerializer.Deserialize<MachineConfig>(json, JsonOptions);
if (config is null) return;
SaveMachine(config);
}
private static void WriteWithRetry(string path, string json, int maxRetries = 3)
{
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
}
}

View File

@@ -0,0 +1,26 @@
using OpenNest.Math;
namespace OpenNest.Data;
public class MachineConfig
{
public Guid Id { get; set; } = Guid.NewGuid();
public int SchemaVersion { get; set; } = 1;
public string Name { get; set; } = "";
public MachineType Type { get; set; } = MachineType.Laser;
public UnitSystem Units { get; set; } = UnitSystem.Inches;
public List<MaterialConfig> Materials { get; set; } = new();
public ThicknessConfig? GetParameters(string material, double thickness)
{
var mat = GetMaterial(material);
if (mat is null) return null;
return mat.Thicknesses.FirstOrDefault(t => t.Value.IsEqualTo(thickness));
}
public MaterialConfig? GetMaterial(string name)
{
return Materials.FirstOrDefault(m =>
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,3 @@
namespace OpenNest.Data;
public record MachineSummary(Guid Id, string Name);

View File

@@ -0,0 +1,8 @@
namespace OpenNest.Data;
public enum MachineType
{
Laser,
Plasma,
Waterjet
}

View File

@@ -0,0 +1,9 @@
namespace OpenNest.Data;
public class MaterialConfig
{
public string Name { get; set; } = "";
public string Grade { get; set; } = "";
public double Density { get; set; }
public List<ThicknessConfig> Thicknesses { get; set; } = new();
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Data</RootNamespace>
<AssemblyName>OpenNest.Data</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Defaults\CL-980.json" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace OpenNest.Data;
public class ThicknessConfig
{
public double Value { get; set; }
public double Kerf { get; set; }
public string AssistGas { get; set; } = "";
public LeadConfig LeadIn { get; set; } = new();
public LeadConfig LeadOut { get; set; } = new();
public CutOffConfig CutOff { get; set; } = new();
public List<string> PlateSizes { get; set; } = new();
}

View File

@@ -0,0 +1,7 @@
namespace OpenNest.Data;
public enum UnitSystem
{
Inches,
Millimeters
}

View File

@@ -8,6 +8,7 @@ namespace OpenNest.Engine.BestFit
public double MaxPlateHeight { get; set; }
public double MaxAspectRatio { get; set; } = 5.0;
public double MinUtilization { get; set; } = 0.3;
public double UtilizationOverride { get; set; } = 0.75;
public void Apply(List<BestFitResult> results)
{
@@ -25,7 +26,7 @@ namespace OpenNest.Engine.BestFit
var aspect = result.LongestSide / result.ShortestSide;
if (aspect > MaxAspectRatio)
if (aspect > MaxAspectRatio && result.Utilization < UtilizationOverride)
{
result.Keep = false;
result.Reason = string.Format("Aspect ratio {0:F1} exceeds max {1}", aspect, MaxAspectRatio);

View File

@@ -22,10 +22,11 @@ namespace OpenNest.Engine.BestFit
if (perimeter == null)
return new PolygonExtractionResult(null, Vector.Zero);
// Inflate by half-spacing if spacing is non-zero.
// OffsetSide.Right = outward for CCW perimeters (standard for outer contours).
// Ensure CW winding for correct outward offset direction.
definedShape.NormalizeWinding();
var inflated = halfSpacing > 0
? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter)
? (perimeter.OffsetOutward(halfSpacing) ?? perimeter)
: perimeter;
// Convert to polygon with circumscribed arcs for tight nesting.

View File

@@ -64,10 +64,17 @@ namespace OpenNest
var best = context.CurrentBest ?? new List<Part>();
if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary(),
isOverallBest: true);
ReportProgress(progress, new ProgressReport
{
Phase = WinnerPhase,
PlateNumber = PlateNumber,
Parts = best,
WorkArea = workArea,
Description = BuildProgressSummary(),
IsOverallBest = true,
});
return best;
}
@@ -94,8 +101,15 @@ namespace OpenNest
Debug.WriteLine($"[Fill(groupParts,Box)] Linear pattern: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}");
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary(),
isOverallBest: true);
ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Linear,
PlateNumber = PlateNumber,
Parts = best,
WorkArea = workArea,
Description = BuildProgressSummary(),
IsOverallBest = true,
});
return best ?? new List<Part>();
}
@@ -151,9 +165,15 @@ namespace OpenNest
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
{
ReportProgress(context.Progress, context.WinnerPhase, PlateNumber,
context.CurrentBest, context.WorkArea, BuildProgressSummary(),
isOverallBest: true);
ReportProgress(context.Progress, new ProgressReport
{
Phase = context.WinnerPhase,
PlateNumber = PlateNumber,
Parts = context.CurrentBest,
WorkArea = context.WorkArea,
Description = BuildProgressSummary(),
IsOverallBest = true,
});
}
}
}

View File

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

View File

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

View File

@@ -157,7 +157,7 @@ namespace OpenNest.Engine.Fill
continue;
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
var d = gap - partSpacing;
var d = gap - partSpacing - 2 * ChordTolerance;
if (d < 0) d = 0;
if (d < distance)
distance = d;

View File

@@ -3,6 +3,7 @@ using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
namespace OpenNest.Engine.Fill
@@ -36,18 +37,44 @@ namespace OpenNest.Engine.Fill
if (column.Count == 0)
return new List<Part>();
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
column, workArea, $"Extents: initial column {column.Count} parts");
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = column,
WorkArea = workArea,
Description = $"Extents: initial column {column.Count} parts",
});
var adjusted = AdjustColumn(pair.Value, column, token);
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
adjusted, workArea, $"Extents: adjusted column {adjusted.Count} parts");
// The iterative pair adjustment can shift parts enough to cause
// genuine overlap. Fall back to the unadjusted column when this happens.
if (HasOverlappingParts(adjusted))
{
Debug.WriteLine("[FillExtents] Adjusted column has overlaps, using unadjusted");
adjusted = column;
}
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = adjusted,
WorkArea = workArea,
Description = $"Extents: column {adjusted.Count} parts",
});
var result = RepeatColumns(adjusted, token);
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
result, workArea, $"Extents: {result.Count} parts total");
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = result,
WorkArea = workArea,
Description = $"Extents: {result.Count} parts total",
});
return result;
}
@@ -323,6 +350,21 @@ namespace OpenNest.Engine.Fill
if (copyDistance <= Tolerance.Epsilon)
copyDistance = columnWidth + partSpacing;
// Safety: if the compacted test column overlaps the original column,
// fall back to bbox-based spacing.
var probe = new List<Part>(column);
probe.AddRange(testColumn.Where(IsWithinWorkArea));
if (HasOverlappingParts(probe))
{
Debug.WriteLine($"[FillExtents] Compacted column overlaps, falling back to bbox spacing");
copyDistance = columnWidth + partSpacing;
// Rebuild test column at safe distance.
testColumn.Clear();
foreach (var part in column)
testColumn.Add(part.CloneAtOffset(new Vector(copyDistance, 0)));
}
Debug.WriteLine($"[FillExtents] Column copy distance: {copyDistance:F2} (bbox width: {columnWidth:F2}, spacing: {partSpacing:F2})");
// Build all columns.
@@ -368,5 +410,31 @@ namespace OpenNest.Engine.Fill
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
part.BoundingBox.Bottom >= workArea.Bottom - Tolerance.Epsilon;
}
private static bool HasOverlappingParts(List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
continue;
if (parts[i].Intersects(parts[j], out _))
return true;
}
}
return false;
}
}
}

View File

@@ -287,6 +287,65 @@ namespace OpenNest.Engine.Fill
return result;
}
/// <summary>
/// Fallback tiling using bounding-box spacing when geometry-aware tiling
/// produces overlapping parts.
/// </summary>
private List<Part> TilePatternBbox(Pattern basePattern, NestDirection direction)
{
var copyDistance = GetDimension(basePattern.BoundingBox, direction) + PartSpacing;
if (copyDistance <= 0)
return new List<Part>();
var dim = GetDimension(basePattern.BoundingBox, direction);
var start = GetStart(basePattern.BoundingBox, direction);
var limit = GetLimit(direction);
var result = new List<Part>();
var count = 1;
while (true)
{
var nextPos = start + copyDistance * count;
if (nextPos + dim > limit + Tolerance.Epsilon)
break;
var offset = MakeOffset(direction, copyDistance * count);
foreach (var part in basePattern.Parts)
result.Add(part.CloneAtOffset(offset));
count++;
}
return result;
}
private static bool HasOverlappingParts(List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon)
return true;
}
}
return false;
}
/// <summary>
/// Creates a seed pattern containing a single part positioned at the work area origin.
/// Returns an empty pattern if the part does not fit.
@@ -325,10 +384,25 @@ namespace OpenNest.Engine.Fill
var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction, boundaries));
// Safety: if geometry-aware spacing produced overlapping parts,
// fall back to bbox-based spacing for this axis.
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
{
row = new List<Part>(pattern.Parts);
row.AddRange(TilePatternBbox(pattern, direction));
}
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
{
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
{
row = new List<Part>(pattern.Parts);
row.AddRange(TilePatternBbox(pattern, perpAxis));
}
return row;
}

View File

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

View File

@@ -18,7 +18,7 @@ namespace OpenNest.Engine.Fill
/// <summary>
/// Composes <see cref="RemnantFiller"/> and <see cref="ShrinkFiller"/> with
/// dual-direction shrink selection. Wraps the caller's fill function in a
/// closure that tries both <see cref="ShrinkAxis.Height"/> and
/// closure that tries both <see cref="ShrinkAxis.Length"/> and
/// <see cref="ShrinkAxis.Width"/>, picks the better <see cref="FillScore"/>,
/// and passes the wrapper to <see cref="RemnantFiller.FillItems"/>.
/// </summary>
@@ -31,7 +31,8 @@ namespace OpenNest.Engine.Fill
double spacing,
CancellationToken token = default,
IProgress<NestProgress> progress = null,
int plateNumber = 0)
int plateNumber = 0,
Func<NestItem, Box, List<Part>> widthFillFunc = null)
{
if (items == null || items.Count == 0)
return new IterativeShrinkResult();
@@ -72,6 +73,8 @@ namespace OpenNest.Engine.Fill
// include them in progress reports.
var placedSoFar = new List<Part>();
var wFillFunc = widthFillFunc ?? fillFunc;
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
{
var target = ni.Quantity > 0 ? ni.Quantity : 0;
@@ -82,9 +85,9 @@ namespace OpenNest.Engine.Fill
ShrinkResult widthResult = null;
Parallel.Invoke(
() => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token,
() => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Length, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar),
() => widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token,
() => widthResult = ShrinkFiller.Shrink(wFillFunc, ni, box, spacing, ShrinkAxis.Width, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar)
);
@@ -108,8 +111,15 @@ namespace OpenNest.Engine.Fill
var allParts = new List<Part>(placedSoFar.Count + best.Count);
allParts.AddRange(placedSoFar);
allParts.AddRange(best);
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
allParts, box, $"Shrink: {best.Count} parts placed", isOverallBest: true);
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Custom,
PlateNumber = plateNumber,
Parts = allParts,
WorkArea = box,
Description = $"Shrink: {best.Count} parts placed",
IsOverallBest = true,
});
}
// Accumulate for the next item's progress reports.

View File

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

View File

@@ -33,7 +33,7 @@ namespace OpenNest.Engine.Fill
if (perimeter != null)
{
var offsetEntity = perimeter.OffsetEntity(spacing, OffsetSide.Left) as Shape;
var offsetEntity = perimeter.OffsetOutward(spacing);
if (offsetEntity != null)
{

View File

@@ -102,11 +102,21 @@ namespace OpenNest.Engine.Fill
if (placed == null)
continue;
// Remove the topmost bounding box part to create a clean
// rectangular obstacle boundary. Without this, gaps between
// individual bounding boxes cause the next drawing to fill
// into inter-row spaces, producing an interleaved layout.
if (placed.Count > 1)
RemoveTopmostPart(placed);
allParts.AddRange(placed);
localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count);
foreach (var p in placed)
finder.AddObstacle(p.BoundingBox.Offset(spacing));
// Add the envelope of all placed parts as a single obstacle
// rather than individual bounding boxes, preventing the
// remnant finder from seeing inter-part gaps.
var envelope = ComputeEnvelope(placed, spacing);
finder.AddObstacle(envelope);
return true;
}
@@ -114,6 +124,39 @@ namespace OpenNest.Engine.Fill
return false;
}
private static void RemoveTopmostPart(List<Part> parts)
{
var topIdx = 0;
for (var i = 1; i < parts.Count; i++)
{
if (parts[i].BoundingBox.Top > parts[topIdx].BoundingBox.Top)
topIdx = i;
}
parts.RemoveAt(topIdx);
}
private static Box ComputeEnvelope(List<Part> parts, double spacing)
{
var left = double.MaxValue;
var bottom = double.MaxValue;
var right = double.MinValue;
var top = double.MinValue;
foreach (var p in parts)
{
var bb = p.BoundingBox;
if (bb.Left < left) left = bb.Left;
if (bb.Bottom < bottom) bottom = bb.Bottom;
if (bb.Right > right) right = bb.Right;
if (bb.Top > top) top = bb.Top;
}
return new Box(left - spacing, bottom - spacing,
right - left + spacing * 2, top - bottom + spacing * 2);
}
private static List<Part> TryFillInRemnants(
NestItem item,
int qty,

View File

@@ -7,7 +7,7 @@ using System.Threading;
namespace OpenNest.Engine.Fill
{
public enum ShrinkAxis { Width, Height }
public enum ShrinkAxis { Width, Length }
public class ShrinkResult
{
@@ -79,8 +79,14 @@ namespace OpenNest.Engine.Fill
var desc = $"Shrink {axis}: {bestParts.Count} parts, dim={dim:F1}";
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
allParts, workArea, desc);
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Custom,
PlateNumber = plateNumber,
Parts = allParts,
WorkArea = workArea,
Description = desc,
});
}
/// <summary>
@@ -95,7 +101,7 @@ namespace OpenNest.Engine.Fill
if (bbox.Width <= 0 || bbox.Length <= 0)
return box;
var maxDim = axis == ShrinkAxis.Height ? box.Length : box.Width;
var maxDim = axis == ShrinkAxis.Length ? box.Length : box.Width;
// Use FillBestFit for a fast, accurate rectangle count on the full box.
var bin = new Bin { Size = new Size(box.Width, box.Length) };
@@ -115,7 +121,7 @@ namespace OpenNest.Engine.Fill
if (estimate <= 0 || estimate >= maxDim)
return box;
return axis == ShrinkAxis.Height
return axis == ShrinkAxis.Length
? new Box(box.X, box.Y, box.Width, estimate)
: new Box(box.X, box.Y, estimate, box.Length);
}

View File

@@ -20,6 +20,7 @@ public class StripeFiller
private readonly FillContext _context;
private readonly NestDirection _primaryAxis;
private readonly IFillComparer _comparer;
private readonly GridDedup _dedup;
/// <summary>
/// When true, only complete stripes are placed — no partial rows/columns.
@@ -38,6 +39,7 @@ public class StripeFiller
_context = context;
_primaryAxis = primaryAxis;
_comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
_dedup = GridDedup.GetOrCreate(context.SharedState);
}
public List<Part> Fill()
@@ -93,9 +95,14 @@ public class StripeFiller
}
}
NestEngineBase.ReportProgress(_context.Progress, NestPhase.Custom,
_context.PlateNumber, bestParts, workArea,
$"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts");
NestEngineBase.ReportProgress(_context.Progress, new ProgressReport
{
Phase = NestPhase.Custom,
PlateNumber = _context.PlateNumber,
Parts = bestParts,
WorkArea = workArea,
Description = $"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts",
});
}
return bestParts ?? new List<Part>();
@@ -110,6 +117,10 @@ public class StripeFiller
var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis);
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
return null;
var stripeEngine = new FillLinear(stripeBox, spacing);
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
@@ -147,6 +158,15 @@ public class StripeFiller
if (gridParts.Count == 0)
return null;
// Reject results where bounding boxes overlap — the angle convergence
// can produce slightly off-axis rotations where FillLinear's copy
// distance calculation doesn't fully account for the rotated geometry.
if (HasOverlappingParts(gridParts))
{
Debug.WriteLine($"[StripeFiller] Rejected grid: overlapping bounding boxes detected");
return null;
}
var allParts = new List<Part>(gridParts);
var remnantParts = FillRemnant(gridParts, primaryAxis);
@@ -224,28 +244,29 @@ public class StripeFiller
return cachedResult;
}
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
try
var filler = new FillLinear(remnantBox, spacing);
List<Part> best = null;
foreach (var angle in new[] { 0.0, Angle.HalfPI })
{
var engine = CreateRemnantEngine(_context.Plate);
var item = new NestItem { Drawing = drawing };
var parts = engine.Fill(item, remnantBox, _context.Progress, _context.Token);
_context.Token.ThrowIfCancellationRequested();
var result = FillHelpers.FillWithDirectionPreference(
dir => filler.Fill(drawing, angle, dir),
null, _comparer, remnantBox);
Debug.WriteLine($"[StripeFiller] Remnant engine ({engine.Name}): {parts?.Count ?? 0} parts, " +
$"winner={engine.WinnerPhase}");
if (parts != null && parts.Count > 0)
{
FillResultCache.Store(drawing, remnantBox, spacing, parts);
return parts;
}
return null;
if (result != null && result.Count > (best?.Count ?? 0))
best = result;
}
finally
Debug.WriteLine($"[StripeFiller] Remnant linear: {best?.Count ?? 0} parts");
if (best != null && best.Count > 0)
{
FillStrategyRegistry.SetEnabled(null);
FillResultCache.Store(drawing, remnantBox, spacing, best);
return best;
}
return null;
}
public static double FindAngleForTargetSpan(
@@ -459,4 +480,34 @@ public class StripeFiller
{
return axis == NestDirection.Horizontal ? box.Width : box.Length;
}
/// <summary>
/// Checks if any pair of parts geometrically overlap. Uses bounding box
/// pre-filtering for performance, then falls back to shape intersection.
/// </summary>
private static bool HasOverlappingParts(List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
continue;
if (parts[i].Intersects(parts[j], out _))
return true;
}
}
return false;
}
}

View File

@@ -24,6 +24,8 @@ namespace OpenNest
public override NestDirection? PreferredDirection => NestDirection.Vertical;
public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };

View File

@@ -43,6 +43,8 @@ namespace OpenNest
public virtual NestDirection? PreferredDirection => null;
public virtual ShrinkAxis TrimAxis => ShrinkAxis.Width;
public virtual List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
return new List<double> { bestRotation, bestRotation + OpenNest.Math.Angle.HalfPI };
@@ -210,55 +212,26 @@ namespace OpenNest
// --- Protected utilities ---
internal static void ReportProgress(
IProgress<NestProgress> progress,
NestPhase phase,
int plateNumber,
List<Part> best,
Box workArea,
string description,
bool isOverallBest = false)
IProgress<NestProgress> progress, ProgressReport report)
{
if (progress == null || best == null || best.Count == 0)
if (progress == null || report.Parts == null || report.Parts.Count == 0)
return;
var score = FillScore.Compute(best, workArea);
var clonedParts = new List<Part>(best.Count);
var totalPartArea = 0.0;
foreach (var part in best)
{
var clonedParts = new List<Part>(report.Parts.Count);
foreach (var part in report.Parts)
clonedParts.Add((Part)part.Clone());
totalPartArea += part.BaseDrawing.Area;
}
var bounds = best.GetBoundingBox();
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
Debug.WriteLine(msg);
try
{
System.IO.File.AppendAllText(
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n");
}
catch { }
Debug.WriteLine($"[Progress] Phase={report.Phase}, Plate={report.PlateNumber}, " +
$"Parts={clonedParts.Count} | {report.Description}");
progress.Report(new NestProgress
{
Phase = phase,
PlateNumber = plateNumber,
BestPartCount = score.Count,
BestDensity = score.Density,
NestedWidth = bounds.Width,
NestedLength = bounds.Length,
NestedArea = totalPartArea,
Phase = report.Phase,
PlateNumber = report.PlateNumber,
BestParts = clonedParts,
Description = description,
ActiveWorkArea = workArea,
IsOverallBest = isOverallBest,
Description = report.Description,
ActiveWorkArea = report.WorkArea,
IsOverallBest = report.IsOverallBest,
});
}
@@ -270,7 +243,7 @@ namespace OpenNest
var parts = new List<string>(PhaseResults.Count);
foreach (var r in PhaseResults)
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
parts.Add($"{r.Phase.ShortName()}: {r.PartCount}");
return string.Join(" | ", parts);
}
@@ -323,17 +296,5 @@ namespace OpenNest
return false;
}
protected static string FormatPhaseName(NestPhase phase)
{
switch (phase)
{
case NestPhase.Pairs: return "Pairs";
case NestPhase.Linear: return "Linear";
case NestPhase.RectBestFit: return "BestFit";
case NestPhase.Extents: return "Extents";
case NestPhase.Custom: return "Custom";
default: return phase.ToString();
}
}
}
}

View File

@@ -21,10 +21,6 @@ namespace OpenNest
"Strip-based nesting for mixed-drawing layouts",
plate => new StripNestEngine(plate));
Register("NFP",
"NFP-based mixed-part nesting with simulated annealing",
plate => new NfpNestEngine(plate));
Register("Vertical Remnant",
"Optimizes for largest right-side vertical drop",
plate => new VerticalRemnantEngine(plate));

View File

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

View File

@@ -74,8 +74,15 @@ namespace OpenNest.Engine.Nfp
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea,
$"NFP: {parts.Count} parts, {result.Iterations} iterations", isOverallBest: true);
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Nfp,
PlateNumber = 0,
Parts = parts,
WorkArea = workArea,
Description = $"NFP: {parts.Count} parts, {result.Iterations} iterations",
IsOverallBest = true,
});
return parts;
}

View File

@@ -277,8 +277,15 @@ namespace OpenNest.Engine.Nfp
private static void ReportBest(IProgress<NestProgress> progress, List<Part> parts,
Box workArea, string description)
{
NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea,
description, isOverallBest: true);
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Nfp,
PlateNumber = 0,
Parts = parts,
WorkArea = workArea,
Description = description,
IsOverallBest = true,
});
}
}
}

View File

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

View File

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

View File

@@ -47,9 +47,14 @@ namespace OpenNest.Engine.Strategies
best = result;
}
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
context.PlateNumber, best, workArea,
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts");
NestEngineBase.ReportProgress(context.Progress, new ProgressReport
{
Phase = NestPhase.Linear,
PlateNumber = context.PlateNumber,
Parts = best,
WorkArea = workArea,
Description = $"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts",
});
}
return best ?? new List<Part>();

View File

@@ -1,24 +1,42 @@
using OpenNest.Engine.Fill;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.Engine.Strategies
{
public class PairsFillStrategy : IFillStrategy
{
private static readonly AsyncLocal<bool> active = new();
public string Name => "Pairs";
public NestPhase Phase => NestPhase.Pairs;
public int Order => 100;
public List<Part> Fill(FillContext context)
{
var comparer = context.Policy?.Comparer;
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing, comparer);
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);
// Prevent recursive PairFiller — remnant fills within PairFiller
// create a new engine that runs the full pipeline, which would
// invoke PairsFillStrategy again, causing deep recursion.
if (active.Value)
return null;
context.SharedState["BestFits"] = result.BestFits;
active.Value = true;
try
{
var comparer = context.Policy?.Comparer;
var dedup = GridDedup.GetOrCreate(context.SharedState);
var filler = new PairFiller(context.Plate, comparer, dedup);
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);
return result.Parts;
context.SharedState["BestFits"] = result.BestFits;
return result.Parts;
}
finally
{
active.Value = false;
}
}
}
}

View File

@@ -77,17 +77,23 @@ namespace OpenNest
// Phase 1: Iterative shrink-fill for multi-quantity items.
if (fillItems.Count > 0)
{
// Pass progress through so the UI shows intermediate results
// during the initial BestFitCache computation and fill phases.
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
// Use direction-specific engines: height shrink benefits from
// minimizing Y-extent, width shrink from minimizing X-extent.
Func<NestItem, Box, List<Part>> heightFillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
var inner = new HorizontalRemnantEngine(Plate);
return inner.Fill(ni, b, progress, token);
};
Func<NestItem, Box, List<Part>> widthFillFunc = (ni, b) =>
{
var inner = new VerticalRemnantEngine(Plate);
return inner.Fill(ni, b, progress, token);
};
var shrinkResult = IterativeShrinkFiller.Fill(
fillItems, workArea, fillFunc, Plate.PartSpacing, token,
progress, PlateNumber);
fillItems, workArea, heightFillFunc, Plate.PartSpacing, token,
progress, PlateNumber, widthFillFunc);
allParts.AddRange(shrinkResult.Parts);

View File

@@ -0,0 +1,40 @@
using ACadSharp;
using OpenNest.Bending;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.IO.Bending
{
public static class BendDetectorRegistry
{
private static readonly List<IBendDetector> detectors = new();
static BendDetectorRegistry()
{
Register(new SolidWorksBendDetector());
}
public static void Register(IBendDetector detector)
{
detectors.Add(detector);
}
public static IReadOnlyList<IBendDetector> Detectors => detectors;
public static IBendDetector GetByName(string name)
{
return detectors.FirstOrDefault(d => d.Name == name);
}
public static List<Bend> AutoDetect(CadDocument document)
{
foreach (var detector in detectors)
{
var bends = detector.DetectBends(document);
if (bends.Count > 0)
return bends;
}
return new List<Bend>();
}
}
}

View File

@@ -0,0 +1,12 @@
using ACadSharp;
using OpenNest.Bending;
using System.Collections.Generic;
namespace OpenNest.IO.Bending
{
public interface IBendDetector
{
string Name { get; }
List<Bend> DetectBends(CadDocument document);
}
}

View File

@@ -0,0 +1,236 @@
using ACadSharp;
using ACadSharp.Entities;
using OpenNest.Bending;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
namespace OpenNest.IO.Bending
{
public class SolidWorksBendDetector : IBendDetector
{
public string Name => "SolidWorks";
public double MaxBendRadius { get; set; } = 4.0;
private static readonly Regex BendNoteRegex = new Regex(
@"(?<direction>UP|DOWN|DN)\s+(?<angle>\d+(\.\d+)?)[^A-Z\d]*R\s*(?<radius>\d+(\.\d+)?)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex MTextFormatRegex = new Regex(
@"\\[fHCTQWASpOoLlKk][^;]*;|\\P|[{}]|%%[dDpPcC]",
RegexOptions.Compiled);
private static readonly Regex UnicodeEscapeRegex = new Regex(
@"\\U\+([0-9A-Fa-f]{4})",
RegexOptions.Compiled);
public List<Bend> DetectBends(CadDocument document)
{
var bendLines = FindBendLines(document);
var bendNotes = FindBendNotes(document);
if (bendLines.Count == 0)
return new List<Bend>();
var bends = new List<Bend>();
foreach (var line in bendLines)
{
var start = new Vector(line.StartPoint.X, line.StartPoint.Y);
var end = new Vector(line.EndPoint.X, line.EndPoint.Y);
var bend = new Bend
{
StartPoint = start,
EndPoint = end,
Direction = BendDirection.Unknown
};
var note = FindClosestBendNote(line, bendNotes);
if (note != null)
{
var noteText = StripMTextFormatting(note.Value);
bend.Direction = GetBendDirection(noteText);
bend.NoteText = noteText;
ParseBendNote(noteText, bend);
}
if (!bend.Radius.HasValue || bend.Radius.Value <= MaxBendRadius)
bends.Add(bend);
}
PropagateCollinearBendNotes(bends);
return bends;
}
/// <summary>
/// For bends without a note (e.g. split by a cutout), copy angle/radius/direction
/// from a collinear bend that does have a note.
/// </summary>
private static void PropagateCollinearBendNotes(List<Bend> bends)
{
const double angleTolerance = 0.01; // radians
const double distanceTolerance = 0.01;
foreach (var bend in bends)
{
if (!string.IsNullOrEmpty(bend.NoteText))
continue;
foreach (var other in bends)
{
if (string.IsNullOrEmpty(other.NoteText))
continue;
if (!AreCollinear(bend, other, angleTolerance, distanceTolerance))
continue;
bend.Direction = other.Direction;
bend.Angle = other.Angle;
bend.Radius = other.Radius;
bend.NoteText = other.NoteText;
break;
}
}
}
private static bool AreCollinear(Bend a, Bend b, double angleTolerance, double distanceTolerance)
{
var angleA = a.StartPoint.AngleTo(a.EndPoint);
var angleB = b.StartPoint.AngleTo(b.EndPoint);
// Normalize angle difference to [0, PI) since opposite directions are still collinear
var diff = System.Math.Abs(angleA - angleB) % System.Math.PI;
if (diff > angleTolerance && System.Math.PI - diff > angleTolerance)
return false;
// Perpendicular distance from midpoint of A to the infinite line through B
var midA = new Vector(
(a.StartPoint.X + a.EndPoint.X) / 2.0,
(a.StartPoint.Y + a.EndPoint.Y) / 2.0);
var dx = b.EndPoint.X - b.StartPoint.X;
var dy = b.EndPoint.Y - b.StartPoint.Y;
var len = System.Math.Sqrt(dx * dx + dy * dy);
if (len < 1e-9)
return false;
// 2D cross product gives signed perpendicular distance * length
var vx = midA.X - b.StartPoint.X;
var vy = midA.Y - b.StartPoint.Y;
var perp = System.Math.Abs(vx * dy - vy * dx) / len;
return perp <= distanceTolerance;
}
private List<ACadSharp.Entities.Line> FindBendLines(CadDocument document)
{
return document.Entities
.OfType<ACadSharp.Entities.Line>()
.Where(l => l.Layer?.Name == "BEND"
&& (l.LineType?.Name?.Contains("CENTER") == true
|| l.LineType?.Name == "CENTERX2"))
.ToList();
}
private List<MText> FindBendNotes(CadDocument document)
{
return document.Entities
.OfType<MText>()
.Where(t => GetBendDirection(t.Value) != BendDirection.Unknown)
.ToList();
}
private static BendDirection GetBendDirection(string text)
{
if (string.IsNullOrEmpty(text))
return BendDirection.Unknown;
var upper = text.ToUpperInvariant();
if (upper.Contains("UP"))
return BendDirection.Up;
if (upper.Contains("DOWN") || upper.Contains("DN"))
return BendDirection.Down;
return BendDirection.Unknown;
}
private static void ParseBendNote(string text, Bend bend)
{
var normalized = text.ToUpperInvariant().Replace("SHARP", "R0");
var match = BendNoteRegex.Match(normalized);
if (match.Success)
{
if (double.TryParse(match.Groups["radius"].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var radius))
bend.Radius = radius;
if (double.TryParse(match.Groups["angle"].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var angle))
bend.Angle = angle;
}
}
private static string StripMTextFormatting(string text)
{
if (string.IsNullOrEmpty(text))
return text;
// Convert \U+XXXX DXF unicode escapes to actual characters
var result = UnicodeEscapeRegex.Replace(text, m =>
{
var codePoint = int.Parse(m.Groups[1].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
return char.ConvertFromUtf32(codePoint);
});
// Replace known DXF special characters
result = result
.Replace("%%d", "°").Replace("%%D", "°")
.Replace("%%p", "±").Replace("%%P", "±")
.Replace("%%c", "⌀").Replace("%%C", "⌀");
// Strip MText formatting codes and braces
result = MTextFormatRegex.Replace(result, " ");
// Collapse multiple spaces
return Regex.Replace(result.Trim(), @"\s+", " ");
}
private MText FindClosestBendNote(ACadSharp.Entities.Line bendLine, List<MText> notes)
{
if (notes.Count == 0) return null;
MText closest = null;
var closestDist = double.MaxValue;
foreach (var note in notes)
{
var notePos = new Vector(note.InsertPoint.X, note.InsertPoint.Y);
var lineStart = new Vector(bendLine.StartPoint.X, bendLine.StartPoint.Y);
var lineEnd = new Vector(bendLine.EndPoint.X, bendLine.EndPoint.Y);
var geomLine = new OpenNest.Geometry.Line(lineStart, lineEnd);
var perpPoint = geomLine.ClosestPointTo(notePos);
var dist = notePos.DistanceTo(perpPoint);
var maxAcceptable = note.Height * 2.0;
if (dist > maxAcceptable) continue;
if (dist < closestDist)
{
closestDist = dist;
closest = note;
}
}
return closest;
}
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace OpenNest.IO.Bom
{
public class BomAnalysis
{
public List<MaterialGroup> Groups { get; set; } = new List<MaterialGroup>();
public List<BomItem> Skipped { get; set; } = new List<BomItem>();
public List<BomItem> Unmatched { get; set; } = new List<BomItem>();
}
public class MaterialGroup
{
public string Material { get; set; }
public double Thickness { get; set; }
public List<MatchedPart> Parts { get; set; } = new List<MatchedPart>();
}
public class MatchedPart
{
public BomItem Item { get; set; }
public string DxfPath { get; set; }
}
public static class BomAnalyzer
{
public static BomAnalysis Analyze(List<BomItem> items, string dxfFolder)
{
var result = new BomAnalysis();
// Build a case-insensitive lookup of DXF files in the folder (if it exists)
var folderExists = Directory.Exists(dxfFolder);
var dxfFiles = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (folderExists)
{
foreach (var file in Directory.GetFiles(dxfFolder, "*.dxf"))
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles[nameWithoutExt] = file;
}
}
// Partition items into: skipped, unmatched, or matched (grouped)
var matched = new List<MatchedPart>();
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item.FileName) || !item.Thickness.HasValue)
{
result.Skipped.Add(item);
continue;
}
var lookupName = item.FileName;
// Strip .dxf extension if the BOM includes it
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (!folderExists)
{
// No folder to search — group items without a DXF path
matched.Add(new MatchedPart { Item = item, DxfPath = null });
}
else if (dxfFiles.TryGetValue(lookupName, out var dxfPath))
{
matched.Add(new MatchedPart { Item = item, DxfPath = dxfPath });
}
else
{
result.Unmatched.Add(item);
}
}
// Group matched parts by material + thickness
var groups = matched
.GroupBy(p => new
{
Material = (p.Item.Material ?? "").ToUpperInvariant(),
Thickness = p.Item.Thickness.Value
})
.Select(g => new MaterialGroup
{
Material = g.First().Item.Material ?? "",
Thickness = g.Key.Thickness,
Parts = g.ToList()
})
.OrderBy(g => g.Material)
.ThenBy(g => g.Thickness)
.ToList();
result.Groups = groups;
return result;
}
}
}

View File

@@ -0,0 +1,35 @@
namespace OpenNest.IO.Bom
{
public class BomItem
{
[Column("Item #", "Item Number", "Item Num")]
public int? ItemNum { get; set; }
[Column("File Name")]
public string FileName { get; set; }
[Column("Qty", "Quantity")]
public int? Qty { get; set; }
[Column("Description")]
public string Description { get; set; }
[Column("Part", "Part Name")]
public string PartName { get; set; }
[Column("Config", "Configuration")]
public string ConfigurationName { get; set; }
[Column("Thickness")]
public double? Thickness { get; set; }
[Column("Material")]
public string Material { get; set; }
[Column("K-Factor")]
public double? KFactor { get; set; }
[Column("Default Bend Radius")]
public double? DefaultBendRadius { get; set; }
}
}

View File

@@ -0,0 +1,99 @@
using ClosedXML.Excel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace OpenNest.IO.Bom
{
public class BomReader : IDisposable
{
private readonly XLWorkbook workbook;
private Dictionary<PropertyInfo, int> columnNameIndexDict;
public BomReader(string file)
{
workbook = new XLWorkbook(file);
columnNameIndexDict = new Dictionary<PropertyInfo, int>();
}
private IXLWorksheet GetPartsWorksheet()
{
if (!workbook.TryGetWorksheet("Parts", out var worksheet))
throw new InvalidOperationException("BOM file does not contain a 'Parts' worksheet.");
return worksheet;
}
private void FindColumnIndexes(IXLWorksheet worksheet)
{
var lastColumn = worksheet.LastColumnUsed()?.ColumnNumber() ?? 0;
var properties = typeof(BomItem).GetProperties();
foreach (var property in properties)
{
var column = property.GetCustomAttribute<ColumnAttribute>();
if (column == null)
continue;
var classColumnNames = column.Names.Select(n => n.ToUpper());
for (var columnIndex = 1; columnIndex <= lastColumn; columnIndex++)
{
var cell = worksheet.Cell(1, columnIndex);
if (cell.IsEmpty()) continue;
var excelColumnName = cell.GetString().ToUpper();
var isMatch = classColumnNames.Any(n => n == excelColumnName);
if (!isMatch)
continue;
columnNameIndexDict.Add(property, columnIndex);
break;
}
}
}
public List<BomItem> GetItems()
{
var worksheet = GetPartsWorksheet();
FindColumnIndexes(worksheet);
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
var items = new List<BomItem>();
for (var rowIndex = 2; rowIndex <= lastRow; rowIndex++)
{
var item = new BomItem();
foreach (var dictItem in columnNameIndexDict)
{
var property = dictItem.Key;
var excelColumnIndex = dictItem.Value;
var cell = worksheet.Cell(rowIndex, excelColumnIndex);
var type = property.PropertyType;
if (type == typeof(int?))
property.SetValue(item, cell.ToIntOrNull());
else if (type == typeof(string))
property.SetValue(item, cell.IsEmpty() ? null : cell.GetString());
else if (type == typeof(double?))
property.SetValue(item, cell.ToDoubleOrNull());
else
throw new NotImplementedException($"Unsupported property type: {type}");
}
items.Add(item);
}
return items;
}
public void Dispose()
{
workbook?.Dispose();
}
}
}

View File

@@ -0,0 +1,23 @@
using ClosedXML.Excel;
namespace OpenNest.IO.Bom
{
public static class CellExtensions
{
public static int? ToIntOrNull(this IXLCell cell)
{
if (cell.IsEmpty()) return null;
if (cell.DataType == XLDataType.Number) return (int)cell.GetDouble();
if (int.TryParse(cell.GetString(), out var i)) return i;
return null;
}
public static double? ToDoubleOrNull(this IXLCell cell)
{
if (cell.IsEmpty()) return null;
if (cell.DataType == XLDataType.Number) return cell.GetDouble();
if (double.TryParse(cell.GetString(), out var result)) return result;
return null;
}
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace OpenNest.IO.Bom
{
[AttributeUsage(AttributeTargets.Property)]
public class ColumnAttribute : Attribute
{
public ColumnAttribute(params string[] names)
{
Names = names;
}
public string[] Names { get; }
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace OpenNest.IO.Bom
{
public static class Fraction
{
public static readonly Regex FractionRegex =
new Regex(@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)");
public static bool IsValid(string s)
{
return FractionRegex.IsMatch(s);
}
public static double Parse(string s)
{
var match = FractionRegex.Match(s);
if (!match.Success)
throw new FormatException("Invalid fraction format.");
var value = 0.0;
var wholeNumGroup = match.Groups["WholeNum"];
var fractionGroup = match.Groups["Fraction"];
if (wholeNumGroup.Success)
value = double.Parse(wholeNumGroup.Value);
if (fractionGroup.Success)
{
var parts = fractionGroup.Value.Split('/');
var numerator = double.Parse(parts[0]);
var denominator = double.Parse(parts[1]);
value += System.Math.Round(numerator / denominator, 8);
}
return value;
}
public static bool TryParse(string s, out double fraction)
{
try
{
fraction = Parse(s);
return true;
}
catch
{
fraction = 0;
return false;
}
}
public static string ReplaceFractionsWithDecimals(string input)
{
var sb = new StringBuilder(input);
var fractionMatches = FractionRegex.Matches(sb.ToString())
.Cast<Match>()
.OrderByDescending(m => m.Index);
foreach (var fractionMatch in fractionMatches)
{
var decimalValue = Parse(fractionMatch.Value);
sb.Remove(fractionMatch.Index, fractionMatch.Length);
sb.Insert(fractionMatch.Index, decimalValue);
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,12 @@
using ACadSharp;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.IO
{
public class DxfImportResult
{
public List<Entity> Entities { get; set; } = new();
public CadDocument Document { get; set; }
}
}

View File

@@ -24,6 +24,12 @@ namespace OpenNest.IO
foreach (var entity in doc.Entities)
{
// Skip bend/etch entities — bends are converted to Bend objects
// separately via bend detection, and etch marks are generated from
// bends during DXF export. Neither should be treated as cut geometry.
if (IsNonCutLayer(entity.Layer?.Name))
continue;
switch (entity)
{
case ACadSharp.Entities.Line line:
@@ -39,7 +45,11 @@ namespace OpenNest.IO
break;
case ACadSharp.Entities.Spline spline:
lines.AddRange(spline.ToOpenNest());
foreach (var e in spline.ToOpenNest(SplinePrecision))
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
case ACadSharp.Entities.LwPolyline lwPolyline:
@@ -51,7 +61,11 @@ namespace OpenNest.IO
break;
case ACadSharp.Entities.Ellipse ellipse:
lines.AddRange(ellipse.ToOpenNest(SplinePrecision));
foreach (var e in ellipse.ToOpenNest())
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
}
}
@@ -65,6 +79,24 @@ namespace OpenNest.IO
return entities;
}
/// <summary>
/// Imports a DXF file, returning both converted entities and the raw CadDocument
/// for bend detection. The CadDocument is NOT disposed — caller can use it for
/// additional analysis (e.g., MText extraction for bend notes).
/// </summary>
public DxfImportResult Import(string path)
{
using var reader = new DxfReader(path);
var doc = reader.Read();
var entities = GetGeometry(doc);
return new DxfImportResult
{
Entities = entities,
Document = doc
};
}
public bool GetGeometry(Stream stream, out List<Entity> geometry)
{
var success = false;
@@ -113,5 +145,11 @@ namespace OpenNest.IO
return success;
}
private static bool IsNonCutLayer(string layerName)
{
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(layerName, "ETCH", System.StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -1,6 +1,7 @@
using ACadSharp.Entities;
using CSMath;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
@@ -56,42 +57,46 @@ namespace OpenNest.IO
return result;
}
public static List<Geometry.Line> ToOpenNest(this Spline spline)
public static List<Geometry.Entity> ToOpenNest(this Spline spline, int precision)
{
var lines = new List<Geometry.Line>();
var pts = spline.ControlPoints;
if (pts.Count == 0)
return lines;
var layer = spline.Layer.ToOpenNest();
var color = spline.ResolveColor();
var lineTypeName = spline.ResolveLineTypeName();
var lastPoint = pts[0].ToOpenNest();
for (var i = 1; i < pts.Count; i++)
// Evaluate actual points on the spline curve (not control points)
List<XYZ> curvePoints;
try
{
var nextPoint = pts[i].ToOpenNest();
lines.Add(new Geometry.Line(lastPoint, nextPoint)
{
Layer = layer,
Color = color,
LineTypeName = lineTypeName
});
lastPoint = nextPoint;
curvePoints = spline.PolygonalVertexes(precision > 0 ? precision : 200);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Spline curve evaluation failed: {ex.Message}");
curvePoints = null;
}
if (spline.IsClosed)
lines.Add(new Geometry.Line(lastPoint, pts[0].ToOpenNest())
{
Layer = layer,
Color = color,
LineTypeName = lineTypeName
});
if (curvePoints == null || curvePoints.Count < 2)
{
// Fallback: use control points if evaluation fails
curvePoints = new List<XYZ>(spline.ControlPoints);
if (curvePoints.Count < 2)
return new List<Geometry.Entity>();
}
return lines;
var points = new List<Vector>(curvePoints.Count);
foreach (var pt in curvePoints)
points.Add(pt.ToOpenNest());
var entities = SplineConverter.Convert(points, spline.IsClosed, tolerance: 0.001);
foreach (var entity in entities)
{
entity.Layer = layer;
entity.Color = color;
entity.LineTypeName = lineTypeName;
}
return entities;
}
public static List<Geometry.Line> ToOpenNest(this Polyline polyline)
@@ -172,69 +177,32 @@ namespace OpenNest.IO
return lines;
}
public static List<Geometry.Line> ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, int precision = 200)
public static List<Geometry.Entity> ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, double tolerance = 0.001)
{
var lines = new List<Geometry.Line>();
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
var majorLength = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var minorLength = majorLength * ellipse.RadiusRatio;
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var startParam = ellipse.StartParameter;
var endParam = ellipse.EndParameter;
if (endParam <= startParam)
endParam += System.Math.PI * 2.0;
var step = (endParam - startParam) / precision;
var points = new List<Vector>();
for (var i = 0; i <= precision; i++)
{
var t = startParam + step * i;
var x = majorLength * System.Math.Cos(t);
var y = minorLength * System.Math.Sin(t);
// Rotate by the major axis angle and translate to center
var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation);
var px = center.X + x * cos - y * sin;
var py = center.Y + x * sin + y * cos;
points.Add(new Vector(px, py));
}
var layer = ellipse.Layer.ToOpenNest();
var color = ellipse.ResolveColor();
var lineTypeName = ellipse.ResolveLineTypeName();
for (var i = 0; i < points.Count - 1; i++)
var entities = EllipseConverter.Convert(center, semiMajor, semiMinor, rotation,
startParam, endParam, tolerance);
foreach (var entity in entities)
{
lines.Add(new Geometry.Line(points[i], points[i + 1])
{
Layer = layer,
Color = color,
LineTypeName = lineTypeName
});
entity.Layer = layer;
entity.Color = color;
entity.LineTypeName = lineTypeName;
}
// Close the ellipse if it's a full ellipse
if (lines.Count >= 2)
{
var first = lines.First();
var last = lines.Last();
lines.Add(new Geometry.Line(last.EndPoint, first.StartPoint)
{
Layer = layer,
Color = color,
LineTypeName = lineTypeName
});
}
return lines;
return entities;
}
public static Geometry.Layer ToOpenNest(this ACadSharp.Tables.Layer layer)

View File

@@ -23,6 +23,7 @@ namespace OpenNest.IO
public string DateCreated { get; init; } = "";
public string DateLastModified { get; init; } = "";
public string Notes { get; init; } = "";
public string AssistGas { get; init; } = "";
public PlateDefaultsDto PlateDefaults { get; init; } = new();
public List<DrawingDto> Drawings { get; init; } = new();
public List<PlateDto> Plates { get; init; } = new();
@@ -49,6 +50,7 @@ namespace OpenNest.IO
public ConstraintsDto Constraints { get; init; } = new();
public MaterialDto Material { get; init; } = new();
public SourceDto Source { get; init; } = new();
public List<BendDto> Bends { get; init; } = new();
}
public record PlateDto
@@ -61,7 +63,9 @@ namespace OpenNest.IO
public double PartSpacing { get; init; }
public MaterialDto Material { get; init; } = new();
public SpacingDto EdgeSpacing { get; init; } = new();
public double GrainAngle { get; init; }
public List<PartDto> Parts { get; init; } = new();
public List<CutOffDto> CutOffs { get; init; } = new();
}
public record PartDto
@@ -72,6 +76,15 @@ namespace OpenNest.IO
public double Rotation { get; init; }
}
public record CutOffDto
{
public double X { get; init; }
public double Y { get; init; }
public string Axis { get; init; } = "vertical";
public double? StartLimit { get; init; }
public double? EndLimit { get; init; }
}
public record SizeDto
{
public double Width { get; init; }
@@ -126,6 +139,18 @@ namespace OpenNest.IO
public double Y { get; init; }
}
public record BendDto
{
public double StartX { get; init; }
public double StartY { get; init; }
public double EndX { get; init; }
public double EndY { get; init; }
public string Direction { get; init; } = "Unknown";
public double? Angle { get; init; }
public double? Radius { get; init; }
public string NoteText { get; init; } = "";
}
public record BestFitSetDto
{
public double PlateWidth { get; init; }

View File

@@ -1,3 +1,4 @@
using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
@@ -91,6 +92,23 @@ namespace OpenNest.IO
drawing.Source.Path = d.Source.Path;
drawing.Source.Offset = new Vector(d.Source.Offset.X, d.Source.Offset.Y);
if (d.Bends != null)
{
foreach (var b in d.Bends)
{
drawing.Bends.Add(new Bend
{
StartPoint = new Vector(b.StartX, b.StartY),
EndPoint = new Vector(b.EndX, b.EndY),
Direction = Enum.TryParse<BendDirection>(b.Direction, true, out var dir)
? dir : BendDirection.Unknown,
Angle = b.Angle,
Radius = b.Radius,
NoteText = b.NoteText
});
}
}
if (programs.TryGetValue(d.Id, out var pgm))
drawing.Program = pgm;
@@ -160,6 +178,7 @@ namespace OpenNest.IO
nest.DateCreated = DateTime.Parse(dto.DateCreated);
nest.DateLastModified = DateTime.Parse(dto.DateLastModified);
nest.Notes = dto.Notes;
nest.AssistGas = dto.AssistGas ?? "";
// Plate defaults
var pd = dto.PlateDefaults;
@@ -185,6 +204,7 @@ namespace OpenNest.IO
plate.PartSpacing = p.PartSpacing;
plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density);
plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top);
plate.GrainAngle = p.GrainAngle;
foreach (var partDto in p.Parts)
{
@@ -197,6 +217,25 @@ namespace OpenNest.IO
plate.Parts.Add(part);
}
// Cut-offs
if (p.CutOffs != null)
{
foreach (var cutoffDto in p.CutOffs)
{
var axis = cutoffDto.Axis?.ToLowerInvariant() == "horizontal"
? CutOffAxis.Horizontal
: CutOffAxis.Vertical;
var cutoff = new CutOff(new Vector(cutoffDto.X, cutoffDto.Y), axis)
{
StartLimit = cutoffDto.StartLimit,
EndLimit = cutoffDto.EndLimit
};
plate.CutOffs.Add(cutoff);
}
plate.RegenerateCutOffs(new CutOffSettings());
}
nest.Plates.Add(plate);
}

View File

@@ -1,3 +1,4 @@
using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using System;
@@ -77,6 +78,7 @@ namespace OpenNest.IO
DateCreated = nest.DateCreated.ToString("o"),
DateLastModified = nest.DateLastModified.ToString("o"),
Notes = nest.Notes ?? "",
AssistGas = nest.AssistGas ?? "",
PlateDefaults = BuildPlateDefaultsDto(),
Drawings = BuildDrawingDtos(),
Plates = BuildPlateDtos()
@@ -139,7 +141,18 @@ namespace OpenNest.IO
{
Path = d.Source.Path ?? "",
Offset = new OffsetDto { X = d.Source.Offset.X, Y = d.Source.Offset.Y }
}
},
Bends = d.Bends?.Select(b => new BendDto
{
StartX = b.StartPoint.X,
StartY = b.StartPoint.Y,
EndX = b.EndPoint.X,
EndY = b.EndPoint.Y,
Direction = b.Direction.ToString(),
Angle = b.Angle,
Radius = b.Radius,
NoteText = b.NoteText ?? ""
}).ToList() ?? new List<BendDto>()
});
}
return list;
@@ -152,7 +165,7 @@ namespace OpenNest.IO
{
var plate = nest.Plates[i];
var parts = new List<PartDto>();
foreach (var part in plate.Parts)
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff))
{
var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault();
parts.Add(new PartDto
@@ -164,6 +177,19 @@ namespace OpenNest.IO
});
}
var cutoffs = new List<CutOffDto>();
foreach (var cutoff in plate.CutOffs)
{
cutoffs.Add(new CutOffDto
{
X = cutoff.Position.X,
Y = cutoff.Position.Y,
Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal",
StartLimit = cutoff.StartLimit,
EndLimit = cutoff.EndLimit
});
}
list.Add(new PlateDto
{
Id = i + 1,
@@ -185,7 +211,9 @@ namespace OpenNest.IO
Right = plate.EdgeSpacing.Right,
Bottom = plate.EdgeSpacing.Bottom
},
Parts = parts
Parts = parts,
CutOffs = cutoffs,
GrainAngle = plate.GrainAngle
});
}
return list;

View File

@@ -8,5 +8,6 @@
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<PackageReference Include="ACadSharp" Version="3.1.32" />
<PackageReference Include="ClosedXML" Version="0.104.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,177 @@
using ACadSharp;
using ACadSharp.Entities;
using ACadSharp.IO;
using ACadSharp.Tables;
using CSMath;
using OpenNest.Bending;
using OpenNest.Converters;
using OpenNest.Geometry;
using System.Collections.Generic;
using System.IO;
// Disambiguate Entity — both ACadSharp.Entities and OpenNest.Geometry define it
using GeoEntity = OpenNest.Geometry.Entity;
namespace OpenNest.IO
{
public class SplitDxfWriter
{
private const double DefaultEtchLength = 1.0;
public double EtchLength { get; set; } = DefaultEtchLength;
public void Write(string path, Drawing drawing)
{
var doc = new CadDocument();
var cutLayer = new ACadSharp.Tables.Layer("CUT") { Color = new Color(7) };
var bendLayer = new ACadSharp.Tables.Layer("BEND") { Color = new Color(2) };
var etchLayer = new ACadSharp.Tables.Layer("ETCH") { Color = new Color(3) };
doc.Layers.Add(cutLayer);
doc.Layers.Add(bendLayer);
doc.Layers.Add(etchLayer);
var centerLineType = new LineType("CENTERX2");
doc.LineTypes.Add(centerLineType);
WriteProgramEntities(doc, drawing.Program, cutLayer);
if (drawing.Bends != null)
{
foreach (var bend in drawing.Bends)
{
WriteBendLine(doc, bend, bendLayer, centerLineType);
WriteEtchLines(doc, bend, etchLayer);
}
}
using var stream = File.Create(path);
using var writer = new DxfWriter(stream, doc, false);
writer.Write();
}
private static void WriteProgramEntities(CadDocument doc, CNC.Program program, ACadSharp.Tables.Layer layer)
{
var geometry = ConvertProgram.ToGeometry(program);
WriteGeometryEntities(doc, geometry, layer);
}
private static void WriteGeometryEntities(CadDocument doc, List<GeoEntity> geometry, ACadSharp.Tables.Layer layer)
{
foreach (var entity in geometry)
{
// Skip rapid moves
if (entity.Layer == SpecialLayers.Rapid)
continue;
switch (entity)
{
case OpenNest.Geometry.Line line:
doc.Entities.Add(new ACadSharp.Entities.Line
{
StartPoint = new XYZ(line.StartPoint.X, line.StartPoint.Y, 0),
EndPoint = new XYZ(line.EndPoint.X, line.EndPoint.Y, 0),
Layer = layer
});
break;
case OpenNest.Geometry.Arc arc:
var startAngle = arc.StartAngle;
var endAngle = arc.EndAngle;
if (arc.IsReversed)
OpenNest.Math.Generic.Swap(ref startAngle, ref endAngle);
doc.Entities.Add(new ACadSharp.Entities.Arc
{
Center = new XYZ(arc.Center.X, arc.Center.Y, 0),
Radius = arc.Radius,
StartAngle = startAngle,
EndAngle = endAngle,
Layer = layer
});
break;
case OpenNest.Geometry.Circle circle:
doc.Entities.Add(new ACadSharp.Entities.Circle
{
Center = new XYZ(circle.Center.X, circle.Center.Y, 0),
Radius = circle.Radius,
Layer = layer
});
break;
case OpenNest.Geometry.Shape shape:
WriteGeometryEntities(doc, shape.Entities, layer);
break;
}
}
}
private static void WriteBendLine(CadDocument doc, Bend bend, ACadSharp.Tables.Layer layer, LineType lineType)
{
var line = new ACadSharp.Entities.Line
{
StartPoint = new XYZ(bend.StartPoint.X, bend.StartPoint.Y, 0),
EndPoint = new XYZ(bend.EndPoint.X, bend.EndPoint.Y, 0),
Layer = layer,
LineType = lineType
};
doc.Entities.Add(line);
if (!string.IsNullOrEmpty(bend.NoteText))
{
var midX = (bend.StartPoint.X + bend.EndPoint.X) / 2;
var midY = (bend.StartPoint.Y + bend.EndPoint.Y) / 2;
var mtext = new MText
{
InsertPoint = new XYZ(midX, midY + 0.5, 0),
Value = bend.NoteText,
Height = 0.1,
Layer = layer
};
doc.Entities.Add(mtext);
}
}
private void WriteEtchLines(CadDocument doc, Bend bend, ACadSharp.Tables.Layer layer)
{
if (bend.Direction != BendDirection.Up)
return;
var start = bend.StartPoint;
var end = bend.EndPoint;
var length = bend.Length;
if (length < EtchLength * 3.0)
{
doc.Entities.Add(new ACadSharp.Entities.Line
{
StartPoint = new XYZ(start.X, start.Y, 0),
EndPoint = new XYZ(end.X, end.Y, 0),
Layer = layer
});
}
else
{
var angle = start.AngleTo(end);
var dx = System.Math.Cos(angle) * EtchLength;
var dy = System.Math.Sin(angle) * EtchLength;
doc.Entities.Add(new ACadSharp.Entities.Line
{
StartPoint = new XYZ(start.X, start.Y, 0),
EndPoint = new XYZ(start.X + dx, start.Y + dy, 0),
Layer = layer
});
doc.Entities.Add(new ACadSharp.Entities.Line
{
StartPoint = new XYZ(end.X, end.Y, 0),
EndPoint = new XYZ(end.X - dx, end.Y - dy, 0),
Layer = layer
});
}
}
}
}

View File

@@ -1,5 +1,6 @@
using ModelContextProtocol.Server;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Shapes;
using System.ComponentModel;
@@ -57,6 +58,35 @@ namespace OpenNest.Mcp.Tools
return sb.ToString();
}
[McpServerTool(Name = "save_nest")]
[Description("Save the current session (all drawings and plates) to a .nest file.")]
public string SaveNest(
[Description("Absolute path for the output .nest file")] string path,
[Description("Name for the nest (optional)")] string name = null)
{
var nest = new Nest();
nest.Name = name ?? Path.GetFileNameWithoutExtension(path);
foreach (var drawing in _session.AllDrawings())
nest.Drawings.Add(drawing);
foreach (var plate in _session.AllPlates())
nest.Plates.Add(plate);
if (nest.Drawings.Count == 0)
return "Error: no drawings in session to save";
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var writer = new NestWriter(nest);
if (!writer.Write(path))
return "Error: failed to write nest file";
return $"Saved nest to {path}\n Drawings: {nest.Drawings.Count}\n Plates: {nest.Plates.Count}";
}
[McpServerTool(Name = "import_dxf")]
[Description("Import a DXF file as a new drawing. Returns drawing name and bounding box.")]
public string ImportDxf(
@@ -74,7 +104,8 @@ namespace OpenNest.Mcp.Tools
if (geometry.Count == 0)
return "Error: no geometry found in DXF file";
var pgm = ConvertGeometry.ToProgram(geometry);
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm == null)
return "Error: failed to convert geometry to program";
@@ -93,7 +124,7 @@ namespace OpenNest.Mcp.Tools
[Description("Name for the drawing")] string name,
[Description("Shape type: rectangle, circle, l_shape, t_shape, gcode")] string shape,
[Description("Width of the shape (not used for circle or gcode)")] double width = 10,
[Description("Height of the shape (not used for circle or gcode)")] double height = 10,
[Description("Length of the shape (not used for circle or gcode)")] double length = 10,
[Description("Radius for circle shape")] double radius = 5,
[Description("G-code string (only used when shape is 'gcode')")] string gcode = null)
{
@@ -102,7 +133,7 @@ namespace OpenNest.Mcp.Tools
switch (shape.ToLower())
{
case "rectangle":
shapeDef = new RectangleShape { Name = name, Width = width, Height = height };
shapeDef = new RectangleShape { Name = name, Width = width, Length = length };
break;
case "circle":
@@ -110,11 +141,11 @@ namespace OpenNest.Mcp.Tools
break;
case "l_shape":
shapeDef = new LShape { Name = name, Width = width, Height = height };
shapeDef = new LShape { Name = name, Width = width, Height = length };
break;
case "t_shape":
shapeDef = new TShape { Name = name, Width = width, Height = height };
shapeDef = new TShape { Name = name, Width = width, Height = length };
break;
case "gcode":

View File

@@ -59,7 +59,7 @@ namespace OpenNest.Mcp.Tools
[Description("X origin of the area")] double x,
[Description("Y origin of the area")] double y,
[Description("Width of the area")] double width,
[Description("Height of the area")] double height,
[Description("Length of the area")] double length,
[Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0)
{
var plate = _session.GetPlate(plateIndex);
@@ -73,14 +73,14 @@ namespace OpenNest.Mcp.Tools
var countBefore = plate.Parts.Count;
var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing, Quantity = quantity };
var area = new Box(x, y, width, height);
var area = new Box(x, y, width, length);
var success = engine.Fill(item, area);
var countAfter = plate.Parts.Count;
var added = countAfter - countBefore;
var sb = new StringBuilder();
sb.AppendLine($"Fill area ({x:F1},{y:F1} {width:F1}x{height:F1}) on plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}");
sb.AppendLine($"Fill area ({x:F1},{y:F1} {width:F1}x{length:F1}) on plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}");
sb.AppendLine($" Parts added: {added}");
sb.AppendLine($" Total parts: {countAfter}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}");

View File

@@ -19,13 +19,13 @@ namespace OpenNest.Mcp.Tools
[Description("Create a new plate with the given dimensions and spacing. Returns plate index and work area.")]
public string CreatePlate(
[Description("Plate width")] double width,
[Description("Plate height")] double height,
[Description("Plate length")] double length,
[Description("Spacing between parts (default 0)")] double partSpacing = 0,
[Description("Edge spacing on all sides (default 0)")] double edgeSpacing = 0,
[Description("Quadrant 1-4 (default 1). 1=TopRight, 2=TopLeft, 3=BottomLeft, 4=BottomRight")] int quadrant = 1,
[Description("Material name (optional)")] string material = null)
{
var plate = new Plate(width, height);
var plate = new Plate(width, length);
plate.PartSpacing = partSpacing;
plate.EdgeSpacing = new Spacing(edgeSpacing, edgeSpacing);
plate.Quadrant = quadrant;

View File

@@ -0,0 +1,240 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Data class carrying all context needed to emit one Cincinnati-format G-code feature block.
/// </summary>
public sealed class FeatureContext
{
public List<ICode> Codes { get; set; } = new();
public int FeatureNumber { get; set; }
public string PartName { get; set; } = "";
public bool IsFirstFeatureOfPart { get; set; }
public bool IsLastFeatureOnSheet { get; set; }
public bool IsSafetyHeadraise { get; set; }
public bool IsExteriorFeature { get; set; }
public bool IsEtch { get; set; }
public string LibraryFile { get; set; } = "";
public double CutDistance { get; set; }
public double SheetDiagonal { get; set; }
}
/// <summary>
/// Emits one Cincinnati-format G-code feature block (one contour) to a TextWriter.
/// Handles rapid positioning, pierce, kerf compensation, anti-dive, feedrate modal
/// suppression, arc I/J conversion (absolute to incremental), and M47 head raise.
/// </summary>
public sealed class CincinnatiFeatureWriter
{
private readonly CincinnatiPostConfig _config;
private readonly CoordinateFormatter _fmt;
private readonly SpeedClassifier _speedClassifier;
public CincinnatiFeatureWriter(CincinnatiPostConfig config)
{
_config = config;
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_speedClassifier = new SpeedClassifier();
}
/// <summary>
/// Writes a complete feature block for the given context.
/// </summary>
public void Write(TextWriter writer, FeatureContext ctx)
{
var currentPos = Vector.Zero;
var lastFeedVar = "";
var kerfEmitted = false;
// Find the pierce point from the first rapid move
var piercePoint = FindPiercePoint(ctx.Codes);
// 1. Rapid to pierce point (with line number if configured)
WriteRapidToPierce(writer, ctx.FeatureNumber, piercePoint);
// 2. Part name comment on first feature of each part
if (ctx.IsFirstFeatureOfPart && !string.IsNullOrEmpty(ctx.PartName))
writer.WriteLine(CoordinateFormatter.Comment($"PART: {ctx.PartName}"));
// 3. G89 process params
if (_config.ProcessParameterMode == G89Mode.LibraryFile)
{
var lib = ctx.LibraryFile;
if (!string.IsNullOrEmpty(lib))
{
var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal);
var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
writer.WriteLine($"G89 P {lib} ({speedClass} {cutDist})");
}
else
{
writer.WriteLine("(WARNING: No library found)");
}
}
// 4. Pierce/beam on — G85 for etch (no pierce), G84 for cut
writer.WriteLine(ctx.IsEtch ? "G85" : "G84");
// 5. Anti-dive off
if (_config.UseAntiDive)
writer.WriteLine("M130 (ANTI DIVE OFF)");
// Update current position to pierce point
currentPos = piercePoint;
// 6. Lead-in + contour moves with kerf comp and feedrate variables
foreach (var code in ctx.Codes)
{
if (code is RapidMove)
continue; // skip rapids in contour (already handled above)
if (code is LinearMove linear)
{
var sb = new StringBuilder();
// Kerf compensation on first cutting move (skip for etch)
if (!ctx.IsEtch && !kerfEmitted && _config.KerfCompensation == KerfMode.ControllerSide)
{
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
kerfEmitted = true;
}
sb.Append($"G1 X{_fmt.FormatCoord(linear.EndPoint.X)} Y{_fmt.FormatCoord(linear.EndPoint.Y)}");
// Feedrate — etch always uses process feedrate
var feedVar = ctx.IsEtch ? "#148" : GetFeedVariable(linear.Layer);
if (feedVar != lastFeedVar)
{
sb.Append($" F{feedVar}");
lastFeedVar = feedVar;
}
writer.WriteLine(sb.ToString());
currentPos = linear.EndPoint;
}
else if (code is ArcMove arc)
{
var sb = new StringBuilder();
// Kerf compensation on first cutting move (skip for etch)
if (!ctx.IsEtch && !kerfEmitted && _config.KerfCompensation == KerfMode.ControllerSide)
{
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
kerfEmitted = true;
}
// G2 = CW, G3 = CCW
var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3";
sb.Append($"{gCode} X{_fmt.FormatCoord(arc.EndPoint.X)} Y{_fmt.FormatCoord(arc.EndPoint.Y)}");
// Convert absolute center to incremental I/J
var i = arc.CenterPoint.X - currentPos.X;
var j = arc.CenterPoint.Y - currentPos.Y;
sb.Append($" I{_fmt.FormatCoord(i)} J{_fmt.FormatCoord(j)}");
// Feedrate — etch always uses process feedrate, cut uses layer-based
var isFullCircle = IsFullCircle(currentPos, arc.EndPoint);
var feedVar = ctx.IsEtch ? "#148"
: isFullCircle ? "[#148*#128]"
: GetFeedVariable(arc.Layer);
if (feedVar != lastFeedVar)
{
sb.Append($" F{feedVar}");
lastFeedVar = feedVar;
}
writer.WriteLine(sb.ToString());
currentPos = arc.EndPoint;
}
}
// 7. Cancel kerf compensation
if (kerfEmitted)
writer.WriteLine("G40");
// 8. Beam off
writer.WriteLine(_config.UseSpeedGas ? "M135" : "M35");
// 9. Anti-dive on
if (_config.UseAntiDive)
writer.WriteLine("M131 (ANTI DIVE ON)");
// 10. Head raise (unless last feature on sheet)
if (!ctx.IsLastFeatureOnSheet)
WriteM47(writer, ctx);
}
private Vector FindPiercePoint(List<ICode> codes)
{
foreach (var code in codes)
{
if (code is RapidMove rapid)
return rapid.EndPoint;
}
// If no rapid move, use the endpoint of the first motion
foreach (var code in codes)
{
if (code is Motion motion)
return motion.EndPoint;
}
return Vector.Zero;
}
private void WriteRapidToPierce(TextWriter writer, int featureNumber, Vector piercePoint)
{
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G0 X{_fmt.FormatCoord(piercePoint.X)} Y{_fmt.FormatCoord(piercePoint.Y)}");
writer.WriteLine(sb.ToString());
}
private void WriteM47(TextWriter writer, FeatureContext ctx)
{
if (ctx.IsSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
{
writer.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
return;
}
var mode = ctx.IsExteriorFeature ? _config.ExteriorM47 : _config.InteriorM47;
switch (mode)
{
case M47Mode.Always:
writer.WriteLine("M47");
break;
case M47Mode.BlockDelete:
writer.WriteLine("/M47");
break;
case M47Mode.None:
break;
}
}
private static string GetFeedVariable(LayerType layer)
{
return layer switch
{
LayerType.Leadin => "#126",
LayerType.Cut => "#148",
_ => "#148"
};
}
private static bool IsFullCircle(Vector start, Vector end)
{
return Tolerance.IsEqualTo(start.X, end.X) && Tolerance.IsEqualTo(start.Y, end.Y);
}
}

View File

@@ -0,0 +1,112 @@
using System.Collections.Generic;
using System.IO;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Writes a Cincinnati-format part sub-program definition.
/// Each sub-program contains the complete cutting sequence for one unique part geometry
/// (drawing + rotation), with coordinates normalized to origin (0,0).
/// Called via M98 from sheet sub-programs.
/// </summary>
public sealed class CincinnatiPartSubprogramWriter
{
private readonly CincinnatiPostConfig _config;
private readonly CincinnatiFeatureWriter _featureWriter;
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config)
{
_config = config;
_featureWriter = new CincinnatiFeatureWriter(config);
}
/// <summary>
/// Writes a complete part sub-program for the given normalized program.
/// The program coordinates must already be normalized to origin (0,0).
/// </summary>
public void Write(TextWriter w, Program normalizedProgram, string drawingName,
int subNumber, string cutLibrary, string etchLibrary, double sheetDiagonal)
{
var allFeatures = FeatureUtils.SplitByRapids(normalizedProgram.Codes);
if (allFeatures.Count == 0)
return;
// Classify and order: etch features first, then cut features
var ordered = FeatureUtils.ClassifyAndOrder(allFeatures);
w.WriteLine("(*****************************************************)");
w.WriteLine($":{subNumber}");
w.WriteLine(CoordinateFormatter.Comment($"PART: {drawingName}"));
for (var i = 0; i < ordered.Count; i++)
{
var (codes, isEtch) = ordered[i];
var featureNumber = i == 0
? _config.FeatureLineNumberStart
: 1000 + i + 1;
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext
{
Codes = codes,
FeatureNumber = featureNumber,
PartName = drawingName,
IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = i == ordered.Count - 1,
IsSafetyHeadraise = false,
IsExteriorFeature = false,
IsEtch = isEtch,
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal
};
_featureWriter.Write(w, ctx);
}
w.WriteLine("G0 X0 Y0");
w.WriteLine($"M99 (END OF {drawingName})");
}
/// <summary>
/// Creates a sub-program key for matching parts to their sub-programs.
/// </summary>
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
(part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
/// <summary>
/// Scans all plates and builds a mapping of unique part geometries to sub-program numbers,
/// along with their normalized programs for writing.
/// </summary>
internal static (Dictionary<(int, long), int> mapping, List<(int subNum, string name, Program program)> entries)
BuildRegistry(IEnumerable<Plate> plates, int startNumber)
{
var mapping = new Dictionary<(int, long), int>();
var entries = new List<(int, string, Program)>();
var nextSubNum = startNumber;
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff) continue;
var key = SubprogramKey(part);
if (!mapping.ContainsKey(key))
{
var subNum = nextSubNum++;
mapping[key] = subNum;
var pgm = part.Program.Clone() as Program;
var bbox = pgm.BoundingBox();
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
entries.Add((subNum, part.BaseDrawing.Name, pgm));
}
}
}
return (mapping, entries);
}
}

View File

@@ -0,0 +1,304 @@
using System.Collections.Generic;
namespace OpenNest.Posts.Cincinnati
{
/// <summary>
/// Specifies how coordinate positioning is handled between parts.
/// </summary>
public enum CoordinateMode
{
/// <summary>Set absolute position.</summary>
G92,
/// <summary>Use relative/incremental positioning.</summary>
G91,
/// <summary>Use machine coordinate system.</summary>
G53
}
/// <summary>
/// Specifies how G89 (hole drilling/tapping parameters) are provided.
/// </summary>
public enum G89Mode
{
/// <summary>Use external library file for G89 parameters.</summary>
LibraryFile,
/// <summary>Explicitly define G89 parameters in the program.</summary>
Explicit
}
/// <summary>
/// Specifies where kerf compensation is applied.
/// </summary>
public enum KerfMode
{
/// <summary>Controller side (using cutter compensation codes).</summary>
ControllerSide,
/// <summary>Pre-applied to part geometry during post-processing.</summary>
PreApplied
}
/// <summary>
/// Specifies which side of the cut line kerf compensation is applied to.
/// </summary>
public enum KerfSide
{
/// <summary>Kerf applied to the left side of the cut.</summary>
Left,
/// <summary>Kerf applied to the right side of the cut.</summary>
Right
}
/// <summary>
/// Specifies how M47 (optional stop) commands are used.
/// </summary>
public enum M47Mode
{
/// <summary>Always include M47.</summary>
Always,
/// <summary>Include M47 with block delete functionality.</summary>
BlockDelete,
/// <summary>Automatically determine M47 placement.</summary>
Auto,
/// <summary>Do not use M47.</summary>
None
}
/// <summary>
/// Specifies when pallet exchange occurs.
/// </summary>
public enum PalletMode
{
/// <summary>No pallet exchange.</summary>
None,
/// <summary>Pallet exchange at end of sheet.</summary>
EndOfSheet,
/// <summary>Pallet exchange at start and end of sheet.</summary>
StartAndEnd
}
/// <summary>
/// Configuration for Cincinnati post processor.
/// Defines machine-specific parameters, output format, and cutting strategies.
/// </summary>
public sealed class CincinnatiPostConfig
{
/// <summary>
/// Gets or sets the configuration name/identifier.
/// Default: "CL940"
/// </summary>
public string ConfigurationName { get; set; } = "CL940";
/// <summary>
/// Gets or sets the units for posted output.
/// Default: Units.Inches
/// </summary>
public Units PostedUnits { get; set; } = Units.Inches;
/// <summary>
/// Gets or sets the decimal accuracy for numeric output.
/// Default: 4
/// </summary>
public int PostedAccuracy { get; set; } = 4;
/// <summary>
/// Gets or sets how coordinate positioning is handled between parts.
/// Default: CoordinateMode.G92
/// </summary>
public CoordinateMode CoordModeBetweenParts { get; set; } = CoordinateMode.G92;
/// <summary>
/// Gets or sets whether to use subprograms for sheet operations.
/// Default: true
/// </summary>
public bool UseSheetSubprograms { get; set; } = true;
/// <summary>
/// Gets or sets the starting subprogram number for sheet operations.
/// Default: 101
/// </summary>
public int SheetSubprogramStart { get; set; } = 101;
/// <summary>
/// Gets or sets whether to use M98 sub-programs for part geometry.
/// When enabled, each unique part geometry is written as a reusable sub-program
/// called via M98, reducing output size for nests with repeated parts.
/// Default: false
/// </summary>
public bool UsePartSubprograms { get; set; } = false;
/// <summary>
/// Gets or sets the starting sub-program number for part geometry sub-programs.
/// Default: 200
/// </summary>
public int PartSubprogramStart { get; set; } = 200;
/// <summary>
/// Gets or sets the subprogram number for variable declarations.
/// Default: 100
/// </summary>
public int VariableDeclarationSubprogram { get; set; } = 100;
/// <summary>
/// Gets or sets how G89 parameters are provided.
/// Default: G89Mode.LibraryFile
/// </summary>
public G89Mode ProcessParameterMode { get; set; } = G89Mode.LibraryFile;
/// <summary>
/// Gets or sets the default assist gas when Nest.AssistGas is empty.
/// Default: "O2"
/// </summary>
public string DefaultAssistGas { get; set; } = "O2";
/// <summary>
/// Gets or sets the gas used for etch operations.
/// Independent of the cutting assist gas — etch typically requires a specific gas.
/// Default: "N2"
/// </summary>
public string DefaultEtchGas { get; set; } = "N2";
/// <summary>
/// Gets or sets the material-to-library mapping for cut operations.
/// Each entry maps (material, thickness, gas) to a G89 library file.
/// </summary>
public List<MaterialLibraryEntry> MaterialLibraries { get; set; } = new();
/// <summary>
/// Gets or sets the gas-to-library mapping for etch operations.
/// Each entry maps a gas type to a G89 etch library file.
/// </summary>
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
/// <summary>
/// Gets or sets whether to use exact stop mode (G61).
/// Default: false
/// </summary>
public bool UseExactStopMode { get; set; } = false;
/// <summary>
/// Gets or sets where kerf compensation is applied.
/// Default: KerfMode.ControllerSide
/// </summary>
public KerfMode KerfCompensation { get; set; } = KerfMode.ControllerSide;
/// <summary>
/// Gets or sets the default side for kerf compensation.
/// Default: KerfSide.Left
/// </summary>
public KerfSide DefaultKerfSide { get; set; } = KerfSide.Left;
/// <summary>
/// Gets or sets how M47 is used in interior cuts.
/// Default: M47Mode.Always
/// </summary>
public M47Mode InteriorM47 { get; set; } = M47Mode.Always;
/// <summary>
/// Gets or sets how M47 is used in exterior cuts.
/// Default: M47Mode.Always
/// </summary>
public M47Mode ExteriorM47 { get; set; } = M47Mode.Always;
/// <summary>
/// Gets or sets the safety head raise distance (in machine units).
/// Default: 2000
/// </summary>
public int? SafetyHeadraiseDistance { get; set; } = 2000;
/// <summary>
/// Gets or sets the distance threshold for M47 override.
/// Default: null
/// </summary>
public double? M47OverrideDistanceThreshold { get; set; } = null;
/// <summary>
/// Gets or sets whether to use anti-dive functionality.
/// Default: true
/// </summary>
public bool UseAntiDive { get; set; } = true;
/// <summary>
/// Gets or sets whether to use smart rapids optimization.
/// Default: false
/// </summary>
public bool UseSmartRapids { get; set; } = false;
/// <summary>
/// Gets or sets when pallet exchange occurs.
/// Default: PalletMode.EndOfSheet
/// </summary>
public PalletMode PalletExchange { get; set; } = PalletMode.EndOfSheet;
/// <summary>
/// Gets or sets whether to use line numbers in output.
/// Default: true
/// </summary>
public bool UseLineNumbers { get; set; } = true;
/// <summary>
/// Gets or sets the starting line number for features.
/// Default: 1
/// </summary>
public int FeatureLineNumberStart { get; set; } = 1;
/// <summary>
/// Gets or sets whether to use speed/gas commands.
/// Default: false
/// </summary>
public bool UseSpeedGas { get; set; } = false;
/// <summary>
/// Gets or sets the feedrate percentage for lead-in moves.
/// Default: 0.5 (50%)
/// </summary>
public double LeadInFeedratePercent { get; set; } = 0.5;
/// <summary>
/// Gets or sets the feedrate percentage for lead-in arc-to-line moves.
/// Default: 0.5 (50%)
/// </summary>
public double LeadInArcLine2FeedratePercent { get; set; } = 0.5;
/// <summary>
/// Gets or sets the feedrate multiplier for circular cuts.
/// Default: 0.8 (80%)
/// </summary>
public double CircleFeedrateMultiplier { get; set; } = 0.8;
/// <summary>
/// Gets or sets the variable number for sheet width.
/// Default: 110
/// </summary>
public int SheetWidthVariable { get; set; } = 110;
/// <summary>
/// Gets or sets the variable number for sheet length.
/// Default: 111
/// </summary>
public int SheetLengthVariable { get; set; } = 111;
}
public class MaterialLibraryEntry
{
public string Material { get; set; } = "";
public double Thickness { get; set; }
public string Gas { get; set; } = "";
public string Library { get; set; } = "";
}
public class EtchLibraryEntry
{
public string Gas { get; set; } = "";
public string Library { get; set; } = "";
}
}

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