Compare commits

102 Commits

Author SHA1 Message Date
aj 3c53d6fecd fix(engine): default FillContext.Policy to avoid null-deref in ReportProgress
FillContext.ReportProgress dereferences Policy.Comparer, so any caller
that forgot to set Policy hit a NullReferenceException. Default to
FillPolicy(DefaultFillComparer) so tests and ad-hoc callers work without
boilerplate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:28:58 -04:00
aj e239967a7b feat(cincinnati): emit SubProgramCall features as M98 hole calls
When a feature is a single SubProgramCall, wrap the call with a G52
offset shift, emit M98 P<num>, reset G52, and add M47 between features.
Accepts an optional hole subprogram id map so the post can remap
drawing-local subprogram ids to machine subprogram numbers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:17:31 -04:00
aj 9d57d3875a fix(cnc): offset SubProgramCall positions in Program.Offset
Program.Offset only adjusted Motion codes, so subprogram calls kept
their original offsets after a part was translated. Apply the offset
to SubProgramCall.Offset too so hole subprograms follow the part.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:17:26 -04:00
aj 0e299d7f6f feat(cincinnati): seed material library defaults and add selector dropdown
Adds the full Cincinnati material/etch library list as the committed
default config (seeded into Posts/ on build only when no runtime config
exists), plus a Selected Library override in the PropertyGrid backed by
a TypeConverter that populates from MaterialLibraries. MainForm calls
the new IPostProcessorNestAware hook before showing the config so the
dropdown opens preselected to the best match by nest material and
nearest thickness.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:16:29 -04:00
aj c6f544c5d7 feat(ui): populate material combobox from post processors
Replaces the material textbox on EditNestInfoForm with a combobox whose
items are aggregated from every loaded post processor that implements the
new IMaterialProvidingPostProcessor interface. CincinnatiPostProcessor
exposes its configured MaterialLibraries entries. Free-text entry still
works so custom materials remain usable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:12:54 -04:00
aj 9563094c2b fix(ui): show Drawings tab before Plates in EditNestForm
Users need to import a drawing first, so Drawings tab should be the
default landing tab to reduce steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:10:58 -04:00
aj 091e750e1b chore(cad-importer): remove dead code and cover named detector branch
- Drop CadImportResult.Document: no caller reads it after the
  migrations (BendDetectorRegistry runs inside CadImporter.Import
  itself, and downstream callers only consume the entity/bend data).
- Drop dead CadConverterForm.GetNextColor() helper: zero callers
  since GetDrawings stopped needing it.
- Drop stale 'using OpenNest.Properties;' and unused 'newItems'
  local in OnSplitClicked.
- Add Import_WhenNamedDetectorDoesNotExist_ReturnsEmptyBends to
  cover the previously untested named-detector branch in
  CadImporter.Import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:13:44 -04:00
aj 87b965f895 refactor(ui): use CadImporter in BomImportForm
Replaces the hand-rolled DXF->Drawing pipeline (Dxf.Import + bend
detection + normalize + ConvertGeometry + pierce offset extraction)
with a single CadImporter.ImportDrawing call. Brings BomImportForm's
output in line with the rest of the callers: drawings now carry
Source.Offset, SourceEntities, SuppressedEntityIds, and detected bends,
and round-trip cleanly through nest files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:11:49 -04:00
aj 08f60690a7 docs: document CadImporter service in CLAUDE.md 2026-04-10 13:27:46 -04:00
aj a4609c816c refactor(ui): use CadImporter.BuildDrawing in CadConverterForm.GetDrawings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:24:26 -04:00
aj 5a4272696e refactor(ui): use CadImporter.Import in CadConverterForm.AddFile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:18:49 -04:00
aj 2cf03be360 refactor(training): use CadImporter for DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:13:00 -04:00
aj 041e184d93 refactor(api): use CadImporter for DXF import in NestRunner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:08:35 -04:00
aj 26df3174ea refactor(mcp): use CadImporter for DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:05:28 -04:00
aj 0f5aace126 refactor(console): use CadImporter for DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:03:16 -04:00
aj 399f8dda6e feat: add CadImporter.ImportDrawing convenience method
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:59:06 -04:00
aj d921558b9c feat: add CadImporter.BuildDrawing stage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:51:58 -04:00
aj bf3e3e1f42 feat: add CadImporter.Import stage with bend detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:37:12 -04:00
aj e120ece014 feat: add CadImportResult data object for CadImporter 2026-04-10 12:28:17 -04:00
aj 264e8264be feat: add CadImportOptions for CadImporter service 2026-04-10 12:25:04 -04:00
aj 24babe353e fix: show both offset and rotation in SubProgramCall.ToString
The either/or format meant a SubProgramCall with both a non-zero
Offset and non-zero Rotation would only show the Offset, hiding the
rotation metadata. The data model supports both independently, so the
display should too.

Also fixes a zero-field leak where the old fallback emitted
`G65 P_ R0` for calls with no rotation. Now each field is only shown
when non-zero, and `G65 P_` with no arguments is emitted when
neither is set.

Note: SubProgramCall.ToString is purely a debug/display aid. The
Cincinnati post emits sub-calls via the G52 + M98 bracket, not via
G65, so this format doesn't correspond to real machine output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:46 -04:00
aj e63be93051 fix: emit G52 bracket for hole sub-program calls
CincinnatiSheetWriter.WriteHoleSubprogramCall emitted
`M98 P<num> X<x> Y<y>`, but per manual §3.98 ("M98 SUB-PROGRAM CALL
WITH NO ARGUMENTS") M98 takes only P and L — the X/Y had no defined
meaning to the control. The intent was to position the sub-program at
the hole center, which is what G52 is for per §1.52 ("local work
coordinate system") and which explicitly does not move the nozzle.

Emit the documented G52 bracket instead:
  G52 X<hole.x> Y<hole.y>
  M98 P<holeSubNum>
  G52 X0 Y0

The hole sub-program is authored in hole-local coordinates, so its
first rapid (the lead-in to the pierce point) resolves to the absolute
pierce under the G52 shift and moves the tool directly there from the
previous feature's end — no phantom rapid to the hole center.

Also add docs/cincinnati-post-output.md as the reference for the full
post output format, with every emitted G/M code cross-referenced to
the Cincinnati programming manual. Un-ignore docs/ (docs/superpowers/
stays ignored) and track the PDF manual alongside the reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:21:15 -04:00
aj ba3c3cbea3 fix: draw sub-program rapid directly to lead-in pierce
The SubProgramCall branch in DrawRapids used to draw a rapid from the
previous feature's end to the hole center, then rely on the sub-program's
own first rapid to draw from center to the lead-in pierce. That rendered
a phantom center-hop segment that doesn't exist physically — a
SubProgramCall is a coordinate-frame shift (emitted as a G52 bracket on
Cincinnati), not a move to the hole center.

Look ahead through the sub-program for its first pierce point in
absolute coordinates and draw a single direct rapid from pos to that
pierce. Recurse into the sub with skipFirstRapid: true so the sub's
first rapid isn't drawn again on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:17:35 -04:00
aj 572fa06a21 fix: track tool position through sub-programs in ConvertMode
ConvertMode.ToIncremental skipped SubProgramCall codes entirely when
computing deltas, so parent motions after a sub-call were encoded as if
the tool never moved. Several traversal sites (ConvertProgram,
GraphicsHelper, PlateRenderer, CutDirectionArrows, Program.BoundingBox)
worked around this with save/restore hacks that treated sub-calls as
transparent — but DrawRapids legitimately tracks actual tool position,
so after the last hole the first perimeter rapid was applied to the
wrong base, drifting the rendered perimeter past the plate edge by
roughly the distance to the last hole.

Fix the root cause: ToIncremental and ToAbsolute now walk sub-programs
to compute where they leave the tool, and advance pos accordingly. The
other traversals capture a frameOrigin at entry and compute sub-call
placement as frameOrigin + Offset, letting pos advance naturally
through the sub recursion. All the save/restore workarounds are
removed.

Program.BoundingBox also picks up the same frame-origin treatment,
which corrects a latent bug where absolute-mode endpoints and nested
sub-calls dropped the parent's frame origin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:51:51 -04:00
aj a6c2235647 fix: let DrawRapids track actual tool position through sub-programs
Don't restore pos after SubProgramCall expansion in DrawRapids — the
machine moves from hole to hole sequentially, so rapids should connect
from the previous hole's end to the next hole's center.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:26:39 -04:00
aj 5c918a0978 fix: draw rapid move to hole center before sub-program lead-in
The rapid from the previous feature to the hole center is implied by
the SubProgramCall offset but wasn't being drawn. Now DrawRapids
renders this traverse before recursing into the sub-program.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:46:44 -04:00
aj 92461deb98 fix: apply SubProgramCall offset additively and restore curpos after expansion
ConvertMode.ToIncremental skips SubProgramCalls when computing deltas,
so all code paths that expand SubProgramCalls must: (1) set curpos to
savedPos + Offset before expanding, and (2) restore curpos afterward
so subsequent incremental codes get correct deltas.

Fixed in ConvertProgram, GraphicsHelper (AddProgram, AddProgramSplit),
PlateRenderer (DrawRapids, DrawProgramPiercePoints, GetFirstPiercePoint),
and CutDirectionArrows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:40:05 -04:00
aj bc859aa28c feat: handle SubProgramCall offsets in BoundingBox and Rotate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:47:40 -04:00
aj 09eac96a03 feat: handle SubProgramCalls in Cincinnati post feature splitting
SubProgramCalls are now treated as standalone features in the Cincinnati
post-processor. SplitByRapids emits them as single-element features
instead of splitting on rapids within sub-programs. A nest-level hole
sub-program registry deduplicates by content and assigns post numbers.
Sheet writers emit M98 calls with X/Y offsets for hole features, and
hole sub-program definitions are written after part sub-programs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:44:58 -04:00
aj df65414a9d feat: serialize and deserialize hole sub-programs in nest file format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:40:13 -04:00
aj 4aed231611 feat: emit SubProgramCalls for circle holes in ContourCuttingStrategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:35:56 -04:00
aj c641b3b68e feat: expand SubProgramCalls with Offset in ConvertProgram
Inline sub-program geometry into the parent geometry list using Offset
as the starting curpos, replacing the Shape-wrapping approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:31:13 -04:00
aj f3b27c32c3 feat: add SubPrograms dictionary to Program with deep-copy support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:28:37 -04:00
aj c270d8ea76 feat: add Offset property to SubProgramCall for hole positioning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:26:55 -04:00
aj de6877ac48 feat: add option to round lead-in angles for circle holes
Snaps lead-in angles on ArcCircle contours to a configurable
increment (default 5°), reducing unique hole variations from
infinite to 72 max. Rounding happens upstream in EmitContour
so the PlateView and post output stay in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:41:33 -04:00
aj 3481764416 perf: use perimeter-only drawing in best fit pair evaluation
PairEvaluator was cloning the full CNC program (including all internal
cutouts) for every candidate. For parts with many holes (e.g. 952),
this caused O(n²) overlap checks and thousands of unnecessary polygon
tessellations per candidate.

Now extracts the perimeter shape once, builds a lightweight drawing
from it, and uses that for all Part.CreateAtOrigin calls. Cutouts are
irrelevant for best fit — only the outer boundary matters for pairing.

75x speedup on a 952-hole rectangle (30s → 0.4s).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:14:02 -04:00
aj 640814fdf6 fix: marshal timer callbacks to UI thread to prevent GDI+ threading exception
System.Timers.Timer fires on thread pool threads, causing GraphicsPath
objects to be accessed concurrently by hover detection and OnPaint,
triggering "Object is currently in use elsewhere" in DrawParts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:41:18 -04:00
aj 6a30828fad feat: optimize external lead-in placement using next-part pierce points
External lead-ins now sit on the line between the last internal cutout
and the next part's first pierce point, minimizing rapid travel. Cutout
sequencing starts from the bounding box corner opposite the origin and
iterates 3 times to converge the perimeter lead-in and internal sequence.
LeadInAssigner and PlateProcessor both use a two-pass approach: first
pass collects pierce points, second pass refines with next-part knowledge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:33:55 -04:00
aj 786b6e2e88 fix: show cutting parameters dialog before assigning lead-ins
Auto-assign lead-ins silently reused existing plate parameters with no
way to change them after the first assignment. Now a dialog with the
full CuttingPanel is shown every time, pre-populated with the current
settings, so the user can review and modify before confirming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:32:41 -04:00
aj ba89967448 fix: respect suppression state in filter panel and guard DetermineWinding
FilterPanel.LoadItem was hardcoding all layer and line type checkboxes
to checked, ignoring actual visibility state. Now reads Layer.IsVisible
and entity IsVisible to set correct checked state.

Also guard DetermineWinding against shapes with fewer than 3 polygon
points (defaults to CCW) to prevent crash when applying lead-ins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:58:11 -04:00
aj b566d984b0 fix: preserve suppression state when reopening converter
LoadItem was resetting all entity visibility to true, overriding the
suppression state set by LoadDrawings. Now stores suppressed entity IDs
on FileListItem and re-applies after the reset. Also auto-unchecks
layers where all entities are suppressed, and syncs suppression state
back to the FileListItem when filters change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:32:18 -04:00
aj c1e6092e83 feat: add entity-based suppression with stable GUIDs
Entities now have a Guid Id for stable identity across edit sessions.
Drawing stores the full source entity set (SourceEntities) and a set of
suppressed entity IDs (SuppressedEntityIds), replacing the previous
SuppressedProgram approach. Unchecked entities in the converter are
suppressed rather than permanently removed, so they can be re-enabled
when editing drawings again.

Entities are serialized as JSON in the nest file (entities/entities-N)
alongside the existing G-code programs. Backward compatible with older
nest files that lack entity data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:25:48 -04:00
aj df86d4367b fix: update drawings in-place when editing in converter so parts reflect changes
EditDrawingsInConverter was replacing Drawing objects with new instances,
but Part.BaseDrawing is readonly — parts kept referencing the old drawings
with stale programs (e.g. etch lines that were removed). Now matches by
name and updates existing drawings in-place, then refreshes all parts.

Also fixes Part.Update() which applied rotation backwards and was missing
UpdateBounds() and lead-in state reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:58:51 -04:00
aj 40026ab4dc test: add SpatialQuery DirectionalDistance tests for circles, squares, and rounded rects
24 tests covering circle-to-circle, square-to-square, rounded rectangle,
mixed shape types, PushDirection overload, edge cases, and symmetry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:10:32 -04:00
aj b18a82df7a refactor: clean up SpatialQuery duplications and redundancies
Extract ArcToLineClosestDistance helper to eliminate duplicate Phase 3
arc-to-line loops, remove redundant MaxValue guard in curve-to-curve
check, consolidate CollectVertices overloads, and add entity-based
PushDirection overload for API consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:33:29 -04:00
aj f090a2e299 fix: add arc-to-line closest-point check in DirectionalDistance
Corner arcs from offset perimeters could slip past vertex sampling,
causing compactor push to undershoot by ~halfSpacing. Use ClosestPointTo
to find the actual nearest point on each arc to each line before firing
the directional ray.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:52:30 -04:00
aj 55192a4888 chore: update ShapeLibraryForm designer layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:37:42 -04:00
aj 7c28a35ad8 feat: add Edit Drawings in Converter button to reopen nest drawings in CadConverterForm
Adds a toolbar button on the Drawings tab that opens the CAD converter
pre-populated with the current nest drawings, allowing users to revisit
layer filtering, quantities, and other settings without re-importing.

Also fixes PlateView stealing focus from text inputs on mouse enter
and FilterPanel crashing when loaded before form handle is created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:37:20 -04:00
aj b2a723ca60 feat: add Shape Library UI with configurable shapes and flange presets
Add a Shape Library dialog (Nest > Shape Library) for creating drawings
from built-in parametric shapes. Supports configuration presets loaded
from JSON files — ships with 136 standard pipe flanges. Parameters use
TextBox inputs with architectural unit parsing (feet/inches, fractions).

- ShapeLibraryForm with split layout: shape list, preview, parameters
- ShapePreviewControl for auto-zoom rendering with info overlay
- ArchUnits utility for parsing architectural measurements
- SetPreviewDefaults() on all ShapeDefinition subclasses
- Convention-based config discovery (Configurations/{ShapeName}.json)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 07:44:03 -04:00
aj 3dca25c601 fix: improve circle nesting with curve-to-curve distance and min copy spacing
Add Phase 3 curve-to-curve direct distance in CpuDistanceComputer to
catch contacts that vertex sampling misses between curved entities.
Enforce minimum copy distance in FillLinear to prevent bounding box
overlap when circumscribed polygon boundaries overshoot true arcs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:15:35 -04:00
aj ebc1a5f980 refactor: extract shared helpers in SpatialQuery
Pull duplicated vertex collection, edge conversion, sorting, and
ray-circle solving into reusable private methods. Delegate the
no-offset DirectionalDistance overload to the offset version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:15:30 -04:00
aj b729f92cd6 fix: correct compactor circle-to-circle directional distance
The vertex-to-entity approach in DirectionalDistance only sampled 4
cardinal points per circle, missing the true closest contact when
circles are offset diagonally from the push direction. This caused
the distance to be overestimated, pushing circles too far and
creating overlap that worsened with distance from center.

Add a curve-to-curve pass that computes exact contact distance by
treating the problem as a ray from one center to an expanded circle
(radius = r1 + r2) at the other center. Includes arc angular range
validation for arc-to-arc and arc-to-circle cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:51:09 -04:00
aj 5d6e018b81 fix: preserve circle rotation direction through geometry round-trip
Circle.Rotation was lost in three places, causing reversed circles to
still offset inward instead of outward:
- ConvertGeometry.AddCircle hardcoded CCW instead of using circle.Rotation
- ConvertProgram.AddArcMove created Circle without setting Rotation from arc
- Shape.OffsetOutward/OffsetInward copied Circle without setting Rotation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:38:23 -04:00
aj 5163b02f89 fix: increase max zoom and handle GDI+ thread race in PlateView
Raise ViewScaleMax from 3000 to 10000 for deeper zoom. Catch
InvalidOperationException in hoverTimer_Elapsed when GraphicsPath is
concurrently used by the paint thread.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:01:50 -04:00
aj a59911b38a remove MicrotabLeadOut — redundant with normal tabs
MicrotabLeadOut was an unimplemented stub (Generate returned empty list)
that duplicated tab functionality. Existing saved configs with "Microtab"
selected will gracefully fall back to NoLeadOut.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:43:38 -04:00
aj 810e37cacf feat: improve multi-plate nesting with multi-remnant filling and better zone scoring
- Iterate all remnants instead of only the first when packing and filling
- Improve ScoreZone with estimated part count and aspect ratio matching
- Cache bounding boxes in SortItems and remnants in TryPlaceOnExistingPlates
- Make TryConsolidateTailPlates loop until stable, trying all donor/target pairs
- Fix consolidation grouping to use BaseDrawing reference instead of name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:20:29 -04:00
aj 8dfa45c446 refactor: rename PlateResult to PlateProcessingResult
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:20:14 -04:00
aj b223f69572 chore: add missing BendLineDialog designer resource
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:20:11 -04:00
aj 98c574c2ad perf: defer Path.IsVisible hit-test to hover timer callback
Move the expensive per-part hit-test out of OnMouseMove and into
the hoverTimer callback. The hit-test now only runs once after
1000ms of stillness, not on every mouse move event.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:19:48 -04:00
aj 30f1008fa9 feat: show hover tooltip only after 1000ms of mouse stillness
Add a hoverTimer that restarts on each mouse move over a part.
Tooltip only renders after the timer fires, hiding while the
cursor is in motion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:12:02 -04:00
aj 41c20eaf75 feat: make hover tooltip follow the cursor
Update hoverPoint on every mouse move while over a part, not just
when the hovered part changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:59:14 -04:00
aj 3a97253473 perf: add bounding box pre-check before Path.IsVisible in hover detection
Path.IsVisible was consuming 52% of CPU on mouse move. Add a cheap
GetBounds().Contains() check first so only parts under the cursor
hit the expensive GDI+ path test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:55:22 -04:00
aj 3eab3c5946 fix: guard against null actionManager during PlateView construction
Plate setter is called in the constructor before actionManager is
initialized, causing a NullReferenceException on startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:45:41 -04:00
aj 0e05ad04ea refactor: clean up PlateView after component extraction
Remove dead programIdFont field, unused imports (OpenNest.CNC,
System.ComponentModel, OpenNest.Math, System.Collections.ObjectModel).
PlateView is now 692 lines (down from 1035).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:20:28 -04:00
aj 5ac985dc0f refactor: update PlateRenderer for SelectionManager cut-off list
PlateRenderer now checks Selection.SelectedCutOffs.Contains() instead
of comparing against a single SelectedCutOff property. Remove temporary
SelectedCutOff shim from PlateView and unused Designer assignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:18:59 -04:00
aj 865754611c refactor: extract PreviewManager from PlateView
Moves preview part lifecycle (stationaryParts, activeParts) into a dedicated
PreviewManager class. PlateView retains forwarding properties and methods for
backward compatibility. Adds Previews property for direct access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:18:17 -04:00
aj 9db326ee5d refactor: extract ActionManager from PlateView
Move action lifecycle (currentAction, previousAction, SetAction, ProcessEscapeKey,
RestorePreviousAction, GetDisplayName) into a dedicated ActionManager class.
PlateView retains public forwarding methods and exposes Actions property.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:16:15 -04:00
aj 25faba430c refactor: extract CutOffHandler from PlateView
Move cut-off drag interaction mechanics into a dedicated CutOffHandler
class, reducing PlateView complexity and following the same pattern
established by SelectionManager extraction in Task 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:13:42 -04:00
aj 089df67627 refactor: extract SelectionManager from PlateView
Move all selection state and operations (SelectedParts, SelectedCutOffs, DeselectAll, SelectAll, AlignSelected, RotateSelectedParts, PushSelected, GetPartAt*, GetPartsFromWindow, DeleteSelected) into a new internal SelectionManager class. PlateView retains public forwarding methods and properties to preserve the existing API surface. SelectedCutOff property kept public for WinForms designer compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:09:08 -04:00
aj 11884e712d fix: clear empty area below items in drawing list on resize
The WM_ERASEBKGND suppression from 3c4d00b left stale artifacts
in the non-item region when the control was resized. Fill only
the area below the last visible item so items still don't flicker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:23:30 -04:00
aj 6bed736cf0 perf: use actual geometry instead of tessellated polygons for push distance
- Add entity-based DirectionalDistance overload to SpatialQuery that
  uses RayArcDistance/RayCircleDistance instead of tessellating arcs
  and circles into line segments
- Add GetOffsetPartEntities, GetPerimeterEntities, GetPartEntities to
  PartGeometry for non-tessellated entity extraction
- Update Compactor.Push to use native entities instead of tessellated
  lines — 952 circles = 952 entities vs ~47,600 line segments
- Use bounding box containment check to skip cutout entities when no
  obstacle is inside the moving part (perimeter-only for common case)
- Obstacles always use perimeter-only entities since cutout edges are
  inside the solid and cannot block external parts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:06:37 -04:00
aj c20a079874 refactor: clean up MultiPlateNester code smells and duplication
Extract shared patterns into reusable helpers: FitsBounds (fits-normal/
rotated check), OptionWorkArea (edge-spacing subtraction), DecrementQuantity,
TryWithUpgradedSize (upgrade-try-revert), FindSmallestFittingOption.
Add PlateResult.AddParts to consolidate dual parts-list bookkeeping.
Cache sorted plate options and add HasPlateOptions property. Introduce
MultiPlateNestOptions to replace 10-parameter Nest signature with a
clean options object. Fix fragile Drawing.Name matching with reference
equality in PackIntoExistingRemnants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:43:58 -04:00
aj 804a7fd9c1 fix: check longest side against plate dimensions in best fit filter
The filter only checked ShortestSide against the plate's short dimension,
allowing results where the long side far exceeded the plate length.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:28:59 -04:00
aj 3c4d00baa4 fix: suppress WM_ERASEBKGND to prevent drawing list flicker on quantity change
ListBox is a native Win32 control so ControlStyles.OptimizedDoubleBuffer
had no effect. The erase-then-redraw cycle on each Invalidate() caused
visible flashing. Suppressing WM_ERASEBKGND is safe because OnDrawItem
already fills the complete item bounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:42:00 -04:00
aj 959ab15491 fix: re-enable delete plate button when changing plate selection
UpdateRemovePlateButton() was only called from PlateListChanged,
not CurrentPlateChanged, so the button stayed disabled after switching
away from the sentinel plate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:26:49 -04:00
aj cca70db547 fix: consolidate tail plates by upgrading instead of creating new plates
Three fixes to TryUpgradeOrNewPlate and a new post-pass:

1. Change ShouldUpgrade from < to <= so upgrade wins when costs are
   tied (e.g., all zero) — previously 0 < 0 was always false

2. Guard against "upgrades" that shrink a dimension — when options are
   sorted by cost and costs are equal, the next option may have a
   smaller length despite higher width (e.g., 72x96 after 60x144)

3. Revert plate size when upgrade fill fails — the plate was being
   resized before confirming parts fit, leaving it at the wrong size

4. Add TryConsolidateTailPlates post-pass: after all nesting, find the
   lowest-utilization new plate and try to absorb its parts into
   another plate via upgrade. Eliminates wasteful tail plates (e.g.,
   a 48x96 plate at 21% util for 2 parts that fit in upgraded space).

Real nest file: 6 plates → 5 plates, all 43 parts placed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:41:49 -04:00
aj 62d9dce0b1 refactor: simplify MultiPlateNester by converting to instance class
- Convert static class to instance with private constructor; shared
  parameters (template, plateOptions, salvageRate, minRemnantSize,
  progress, token) become fields, eliminating parameter threading
  across all private methods (10→3 params on Nest entry point stays
  unchanged; private methods drop from 7-9 params to 1-2)
- Extract FillAndPlace helper consolidating the repeated
  clone→fill→add-to-plate→deduct-quantity pattern (was duplicated
  in 4 call sites)
- Merge FindScrapZones/FindViableRemnants (98% duplicate) into single
  FindRemnants(plate, minRemnantSize, scrapOnly) method
- Extract ScoreZone helper and collapse duplicate normal/rotated
  orientation checks into single conditional
- Extract CreateNewPlateResult helper for repeated PlateResult
  construction + PlateOption lookup pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:08:44 -04:00
aj 1f88453d4c fix: recalculate remnants after each fill to prevent overlaps
The consolidation pass was iterating stale remnant lists after placing
parts, causing overlapping placements. Now recalculates remnants from
the plate after each fill operation. Also added plate options to the
real nest file integration test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:55:48 -04:00
aj 0697bebbc2 fix: defer small parts to consolidation pass for shared plates
Small parts no longer create their own plates during the main pass.
Instead they're deferred to the consolidation pass which fills them
into remaining space on existing plates, packing multiple drawing
types together. Drops from 9 plates to 4 on the test nest file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:47:50 -04:00
aj beadb14acc fix: consolidation pass packs medium/small parts onto shared plates
After the main single-pass placement, leftover items are now packed
together using the engine's multi-item Nest()/PackArea() methods
instead of creating one plate per drawing. First tries packing into
remaining space on existing plates, then creates shared plates for
anything still remaining.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:35:12 -04:00
aj 09f1140f54 fix: allow large parts to use remnant space on existing plates
Previously, parts classified as "Large" skipped all existing plates
and always created new ones. This caused one-unique-part-per-plate
behavior since most parts exceed half the plate dimension. Now large
parts search viable remnants on existing plates before creating new
ones, matching the intended part-first behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:24:17 -04:00
aj 7c918a2378 feat: integrate MultiPlateNester into MainForm auto-nest workflow
Wires part-first mode from AutoNestForm into RunAutoNestAsync: reads
PartFirstMode, SortOrder, MinRemnantSize, and AllowPlateCreation from
the form, passes them through to a new part-first branch that delegates
to MultiPlateNester.Nest instead of the plate-first loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:16:10 -04:00
aj feb08a5f60 feat: refactor AutoNestForm into Parts/Plates tabs with part-first controls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:13:50 -04:00
aj f1fd211ba5 fix: small parts use FindScrapZones not FindAllRemnants
Small parts must only go into scrap zones (both dims < minRemnantSize)
to preserve viable remnants. The implementer had inverted this, giving
small parts access to all remnants. Also fixed the test to verify
remnant preservation behavior and removed unused FindAllRemnants helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:11:10 -04:00
aj fd3c2462df feat: add MultiPlateNester.Nest orchestration method
Implements the main Nest() method that ties together sorting,
classification, and placement across multiple plates. The method
processes items largest-first, placing medium/small parts into
remnant zones on existing plates before creating new ones. Includes
private helpers: TryPlaceOnExistingPlates, PlaceOnNewPlates,
TryUpgradeOrNewPlate, FindAllRemnants, and CloneItem.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:07:24 -04:00
aj a4773748a1 feat: add plate creation and upgrade-vs-new evaluation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:56:08 -04:00
aj af57153269 feat: add scrap zone identification to MultiPlateNester
Adds IsScrapRemnant(), FindScrapZones(), and FindViableRemnants() to
MultiPlateNester. A remnant is scrap only when both dimensions fall
below the minimum remnant size threshold (AND logic, not OR).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:53:32 -04:00
aj 35e89600d0 feat: add part classification (large/medium/small) to MultiPlateNester
Introduces PartClass enum and Classify() static method that categorizes
parts as Large (exceeds half work area in either dimension), Medium
(area > 1/9 work area), or Small.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:52:27 -04:00
aj 89a4e6b981 feat: add MultiPlateNester with sorting logic
Implements static MultiPlateNester.SortItems with BoundingBoxArea and Size sort orders, covered by two passing xUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:48:57 -04:00
aj ebad3577dd feat: add MultiPlateResult type for part-first nesting 2026-04-06 13:46:51 -04:00
aj a8dc275da4 feat: add PartSortOrder enum for part-first nesting 2026-04-06 13:46:49 -04:00
aj d84becdaee fix: add bend detection and etch lines to BOM import path
BOM import was skipping BendDetectorRegistry.AutoDetect and
Bend.UpdateEtchEntities, so parts imported via BOM had no etch
or bend lines. Now matches the CadConverterForm import behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:19:17 -04:00
aj 9cba3a6cd7 fix: plate optimizer skips oversized items instead of rejecting all plate options
When an item was too large for every plate option, its dimensions dominated
the global min-dimension filter, causing all candidate plates to be rejected.
This made auto-nesting exit immediately with no results even when the other
items could fit. Oversized items are now excluded from the filter so the
remaining items nest normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:41:39 -04:00
aj e93523d7a2 perf: optimize best fit computation and plate optimizer
- Try all valid best fit pairs instead of only the first when qty=2,
  picking the best via IsBetterFill comparer (fixes suboptimal plate
  selection during auto-nesting)
- Pre-compute best fits across all plate sizes once via
  BestFitCache.ComputeForSizes instead of per-size GPU evaluation
- Early exit plate optimizer when all items fit (salvage < 100%)
- Trim slide offset sweep range to 50% overlap to reduce candidates
- Use actual geometry (ray-arc/ray-circle intersection) instead of
  tessellated polygons for slide distance computation — eliminates
  the massive line count from circle/arc tessellation
- Add RayArcDistance and RayCircleDistance to SpatialQuery
- Add PartGeometry.GetOffsetPerimeterEntities for non-tessellated
  perimeter extraction
- Disable GPU slide computer (slower than CPU currently)
- Remove dead SelectBestFitPair virtual method and overrides

Reduces best fit computation from 7+ minutes to ~4 seconds for a
73x25" part with 30+ holes on a 48x96 plate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:21:44 -04:00
aj 3bdbf21881 fix: plate optimizer tiebreak prefers highest utilization over smallest area
When plate costs are equal (e.g. all zero), the optimizer now picks the
plate size with the tightest density instead of the smallest plate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:19:12 -04:00
aj a8e42fb4b5 feat: use nest template for BOM import spacing defaults, editable per group
BOM import now loads the nest template to populate plate size, part
spacing, edge spacing, and quadrant instead of hard-coding defaults.
Spacing columns are shown per material+thickness group on the Groups
tab so each combo can be adjusted independently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:10:06 -04:00
aj ea3c6afbdd fix: re-add drawings to list when parts are deleted with hide-depleted active
The timer-based list update only removed depleted drawings but never
added them back when they became un-depleted (e.g., after deleting a
part from the plate).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:43:01 -04:00
aj ba88ac253a fix: Circle.ToPoints ignores Rotation, breaking reverse direction for circular perimeters
Circle.ToPoints() always generated CCW points regardless of the Rotation
property, so reversing a circle contour in the CAD converter had no effect.
Now negates the step angle when Rotation is CW, matching Arc.ToPoints behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:29:42 -04:00
aj 250fdefaea refactor: merge DxfImporter and DxfExporter into single static Dxf class
Consolidated two stateless classes into one unified API: Dxf.Import(),
Dxf.GetGeometry(), Dxf.ExportPlate(), Dxf.ExportProgram(). Export
state moved into a private ExportContext. Removed bool+out pattern
from GetGeometry in favor of returning empty list on failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:17:49 -04:00
aj e92208b8c0 fix: remove import spline precision setting entirely
Spline import now uses SplineConverter (arc-based) so the configurable
precision parameter is obsolete. Removed the setting from the options
dialog, DxfImporter property, Settings files, and all callsites.
Hardcoded 200 as the sampling density for the intermediate point
evaluation that feeds into SplineConverter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:05:33 -04:00
aj 297ebee45b fix: stop plate list changes from forcing tab switch
PlateListChanged handler was setting tabControl1.SelectedIndex = 0,
which forced the UI to the plates tab whenever a sentinel plate was
auto-created during part placement, disrupting the workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:00:09 -04:00
aj 1eba3e7cde fix: improve DrawingListBox rendering and scroll stability
Add LightGray separator lines between items to visually distinguish
adjacent quantity bars. Preserve scroll position and selection when
updating the drawing list by saving/restoring TopIndex and SelectedItem.
Use incremental item removal instead of full list rebuild when hiding
depleted drawings. Wrap list modifications in BeginUpdate/EndUpdate to
reduce flicker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 07:53:53 -04:00
139 changed files with 9970 additions and 2303 deletions
-3
View File
@@ -211,8 +211,5 @@ FakesAssemblies/
.superpowers/
docs/superpowers/
# Documentation (manuals, templates, etc.)
docs/
# Launch settings
**/Properties/launchSettings.json
+3
View File
@@ -57,6 +57,8 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
- `ProgramReader` — G-code text parser.
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
### OpenNest.Console (console app, depends on Core + Engine + IO)
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
@@ -117,3 +119,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- `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).
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
+13 -10
View File
@@ -5,8 +5,6 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Api;
@@ -25,21 +23,26 @@ public static class NestRunner
// 1. Import DXFs → Drawings
var drawings = new List<Drawing>();
var importer = new DxfImporter();
foreach (var part in request.Parts)
{
if (!File.Exists(part.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
Drawing drawing;
try
{
drawing = CadImporter.ImportDrawing(part.DxfPath,
new CadImportOptions { Quantity = part.Quantity });
}
catch (System.Exception ex)
{
throw new InvalidOperationException(
$"Failed to import DXF: {part.DxfPath}", ex);
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(name);
drawing.Program = pgm;
drawings.Add(drawing);
}
+6 -23
View File
@@ -1,5 +1,4 @@
using OpenNest;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using System;
@@ -241,31 +240,15 @@ static class NestConsole
static Drawing ImportDxf(string path)
{
var importer = new DxfImporter();
if (!importer.GetGeometry(path, out var geometry))
try
{
Console.Error.WriteLine($"Error: failed to read DXF file: {path}");
return CadImporter.ImportDrawing(path);
}
catch (System.Exception ex)
{
Console.Error.WriteLine($"Error: failed to import DXF '{path}': {ex.Message}");
return null;
}
if (geometry.Count == 0)
{
Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}");
return null;
}
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm == null)
{
Console.Error.WriteLine($"Error: failed to convert geometry: {path}");
return null;
}
var name = Path.GetFileNameWithoutExtension(path);
return new Drawing(name, pgm);
}
static void ApplyTemplate(Plate plate, Options options)
@@ -1,5 +1,6 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
@@ -11,6 +12,11 @@ namespace OpenNest.CNC.CuttingStrategy
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
public CuttingResult Apply(Program partProgram, Vector approachPoint)
{
return Apply(partProgram, approachPoint, Vector.Invalid);
}
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
{
var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -20,14 +26,43 @@ namespace OpenNest.CNC.CuttingStrategy
var profile = new ShapeProfile(entities);
// Forward pass: sequence cutouts nearest-neighbor from perimeter
var perimeterPoint = profile.Perimeter.ClosestPointTo(approachPoint, out _);
var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint);
// Start from the bounding box corner opposite the origin (max X, max Y)
var bbox = entities.GetBoundingBox();
var startCorner = new Vector(bbox.Right, bbox.Top);
// Initial pass: sequence cutouts from bbox corner
var seedPoint = startCorner;
var orderedCutouts = SequenceCutouts(profile.Cutouts, seedPoint);
orderedCutouts.Reverse();
// Backward pass: walk from perimeter back through cutting order
// so each lead-in faces the next cutout to be cut, not the previous
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterPoint);
var perimeterSeed = profile.Perimeter.ClosestPointTo(seedPoint, out _);
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
Vector perimeterPt;
Entity perimeterEntity;
if (!double.IsNaN(nextPartStart.X) && cutoutEntries.Count > 0)
{
// Iterate: each pass refines the perimeter lead-in which changes
// the internal sequence which changes the last cutout position
for (var iter = 0; iter < 3; iter++)
{
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _);
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
orderedCutouts.Reverse();
cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
}
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
}
else
{
var perimeterRef = cutoutEntries.Count > 0 ? cutoutEntries[0].Point : approachPoint;
perimeterPt = profile.Perimeter.ClosestPointTo(perimeterRef, out perimeterEntity);
}
var result = new Program(Mode.Absolute);
@@ -36,9 +71,6 @@ namespace OpenNest.CNC.CuttingStrategy
foreach (var entry in cutoutEntries)
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
// Perimeter last
var lastRefPoint = cutoutEntries.Count > 0 ? cutoutEntries[cutoutEntries.Count - 1].Point : approachPoint;
var perimeterPt = profile.Perimeter.ClosestPointTo(lastRefPoint, out var perimeterEntity);
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
result.Mode = Mode.Incremental;
@@ -187,6 +219,40 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ContourEntry>(entries);
}
private static Vector FindPerimeterIntersection(Shape perimeter, Vector lastCutout, Vector nextPartStart, out Entity entity)
{
var ray = new Line(lastCutout, nextPartStart);
if (perimeter.Intersects(ray, out var pts) && pts.Count > 0)
{
// Pick the intersection closest to the last cutout
var best = pts[0];
var bestDist = best.DistanceTo(lastCutout);
for (var i = 1; i < pts.Count; i++)
{
var dist = pts[i].DistanceTo(lastCutout);
if (dist < bestDist)
{
best = pts[i];
bestDist = dist;
}
}
return perimeter.ClosestPointTo(best, out entity);
}
// Fallback: closest point on perimeter to the last cutout
return perimeter.ClosestPointTo(lastCutout, out entity);
}
private static int ComputeSubProgramKey(double radius, double normalAngle)
{
var r = System.Math.Round(radius, 6);
var a = System.Math.Round(normalAngle, 6);
return HashCode.Combine(r, a);
}
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
{
var contourType = forceType ?? DetectContourType(shape);
@@ -197,16 +263,62 @@ namespace OpenNest.CNC.CuttingStrategy
var leadOut = SelectLeadOut(contourType);
if (contourType == ContourType.ArcCircle && entity is Circle circle)
{
if (Parameters.RoundLeadInAngles && Parameters.LeadInAngleIncrement > 0)
{
var increment = Angle.ToRadians(Parameters.LeadInAngleIncrement);
normal = System.Math.Round(normal / increment) * increment;
normal = Angle.NormalizeRad(normal);
var outwardAngle = normal - System.Math.PI;
point = new Vector(
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle));
}
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
// Build hole sub-program relative to (0,0)
var holeCenter = circle.Center;
var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y);
var relativeCircle = new Circle(new Vector(0, 0), circle.Radius) { Rotation = circle.Rotation };
var relativeShape = new Shape();
relativeShape.Entities.Add(relativeCircle);
var subPgm = new Program(Mode.Absolute);
subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding));
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
reindexed = TrimShapeForTab(reindexed, relativePoint, Parameters.TabConfig.Size);
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
subPgm.Mode = Mode.Incremental;
// Deduplicate: check if an identical sub-program already exists
var key = ComputeSubProgramKey(circle.Radius, normal);
if (!program.SubPrograms.ContainsKey(key))
program.SubPrograms[key] = subPgm;
program.Codes.Add(new SubProgramCall
{
Id = key,
Program = program.SubPrograms[key],
Offset = holeCenter
});
return;
}
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
var reindexed = shape.ReindexAt(point, entity);
var reindexedShape = shape.ReindexAt(point, entity);
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
reindexed = TrimShapeForTab(reindexed, point, Parameters.TabConfig.Size);
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
program.Codes.AddRange(ConvertShapeToMoves(reindexed, point));
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
}
@@ -309,7 +421,12 @@ namespace OpenNest.CNC.CuttingStrategy
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
return circle.Rotation;
return shape.ToPolygon().RotationDirection();
var polygon = shape.ToPolygon();
if (polygon.Vertices.Count < 3)
return RotationType.CCW;
return polygon.RotationDirection();
}
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
@@ -23,6 +23,9 @@ namespace OpenNest.CNC.CuttingStrategy
public double PierceClearance { get; set; } = 0.0625;
public bool RoundLeadInAngles { get; set; }
public double LeadInAngleIncrement { get; set; } = 5.0;
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
@@ -1,16 +0,0 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
public class MicrotabLeadOut : LeadOut
{
public double GapSize { get; set; } = 0.03;
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{
return new List<ICode>();
}
}
}
+42 -4
View File
@@ -12,6 +12,8 @@ namespace OpenNest.CNC
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<int, Program> SubPrograms { get; } = new();
private Mode mode;
public Program(Mode mode = Mode.Absolute)
@@ -87,6 +89,17 @@ namespace OpenNest.CNC
{
var subpgm = (SubProgramCall)code;
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
{
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
var dx = subpgm.Offset.X - origin.X;
var dy = subpgm.Offset.Y - origin.Y;
subpgm.Offset = new Geometry.Vector(
origin.X + dx * cos - dy * sin,
origin.Y + dx * sin + dy * cos);
}
if (subpgm.Program != null)
subpgm.Program.Rotate(angle, origin);
}
@@ -115,6 +128,12 @@ namespace OpenNest.CNC
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + x, subpgm.Offset.Y + y);
}
if (code is Motion == false)
continue;
@@ -137,6 +156,12 @@ namespace OpenNest.CNC
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y);
}
if (code is Motion == false)
continue;
@@ -275,6 +300,10 @@ namespace OpenNest.CNC
private Box BoundingBox(ref Vector pos)
{
// Capture the frame origin at entry. Sub-program Offsets and
// absolute-mode endpoints are relative to this fixed origin.
var frameOrigin = pos;
double minX = 0.0;
double minY = 0.0;
double maxX = 0.0;
@@ -290,7 +319,7 @@ namespace OpenNest.CNC
{
var line = (LinearMove)code;
var pt = Mode == Mode.Absolute ?
line.EndPoint :
frameOrigin + line.EndPoint :
line.EndPoint + pos;
if (pt.X > maxX)
@@ -312,7 +341,7 @@ namespace OpenNest.CNC
{
var line = (RapidMove)code;
var pt = Mode == Mode.Absolute
? line.EndPoint
? frameOrigin + line.EndPoint
: line.EndPoint + pos;
if (pt.X > maxX)
@@ -345,8 +374,8 @@ namespace OpenNest.CNC
}
else
{
endpt = arc.EndPoint;
centerpt = arc.CenterPoint;
endpt = frameOrigin + arc.EndPoint;
centerpt = frameOrigin + arc.CenterPoint;
}
double minX1;
@@ -420,6 +449,12 @@ namespace OpenNest.CNC
case CodeType.SubProgramCall:
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program == null)
break;
// Sub-program frame origin in this program's frame
// is frameOrigin + Offset, regardless of current pos.
pos = frameOrigin + subpgm.Offset;
var box = subpgm.Program.BoundingBox(ref pos);
if (box.Left < minX)
@@ -460,6 +495,9 @@ namespace OpenNest.CNC
foreach (var kvp in Variables)
pgm.Variables[kvp.Key] = kvp.Value;
foreach (var kvp in SubPrograms)
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
return pgm;
}
+17 -3
View File
@@ -1,4 +1,6 @@
using OpenNest.Math;
using System.Text;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC
{
@@ -35,6 +37,12 @@ namespace OpenNest.CNC
}
}
/// <summary>
/// Gets or sets the offset (position) at which the sub-program is executed.
/// For hole sub-programs, this is the hole center.
/// </summary>
public Vector Offset { get; set; }
/// <summary>
/// Gets or sets the rotation of the program in degrees.
/// </summary>
@@ -78,12 +86,18 @@ namespace OpenNest.CNC
/// <returns></returns>
public ICode Clone()
{
return new SubProgramCall(program, Rotation);
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
}
public override string ToString()
{
return string.Format("G65 P{0} R{1}", Id, Rotation);
var sb = new StringBuilder();
sb.Append($"G65 P{Id}");
if (Offset.X != 0 || Offset.Y != 0)
sb.Append($" X{Offset.X} Y{Offset.Y}");
if (Rotation != 0)
sb.Append($" R{Rotation}");
return sb.ToString();
}
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ namespace OpenNest.Converters
if (startpt != lastpt)
pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, RotationType.CCW);
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
lastpt = startpt;
return lastpt;
+53 -9
View File
@@ -1,4 +1,4 @@
using OpenNest.CNC;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Converters
@@ -9,7 +9,6 @@ namespace OpenNest.Converters
/// Converts the program to absolute coordinates.
/// Does NOT check program mode before converting.
/// </summary>
/// <param name="pgm"></param>
public static void ToAbsolute(Program pgm)
{
var pos = new Vector(0, 0);
@@ -17,21 +16,27 @@ namespace OpenNest.Converters
for (int i = 0; i < pgm.Codes.Count; ++i)
{
var code = pgm.Codes[i];
var motion = code as Motion;
if (motion != null)
if (code is SubProgramCall subCall && subCall.Program != null)
{
motion.Offset(pos);
// Sub-program is placed at Offset in this program's frame.
// After it runs, the tool is at Offset + (sub's end in its own frame).
pos = ComputeEndPosition(subCall.Program, subCall.Offset);
continue;
}
if (code is Motion motion)
{
motion.Offset(pos.X, pos.Y);
pos = motion.EndPoint;
}
}
}
/// <summary>
/// Converts the program to intermental coordinates.
/// Converts the program to incremental coordinates.
/// Does NOT check program mode before converting.
/// </summary>
/// <param name="pgm"></param>
public static void ToIncremental(Program pgm)
{
var pos = new Vector(0, 0);
@@ -39,9 +44,16 @@ namespace OpenNest.Converters
for (int i = 0; i < pgm.Codes.Count; ++i)
{
var code = pgm.Codes[i];
var motion = code as Motion;
if (motion != null)
if (code is SubProgramCall subCall && subCall.Program != null)
{
// Sub-program is placed at Offset in this program's frame,
// regardless of where the tool was before the call.
pos = ComputeEndPosition(subCall.Program, subCall.Offset);
continue;
}
if (code is Motion motion)
{
var pos2 = motion.EndPoint;
motion.Offset(-pos.X, -pos.Y);
@@ -49,5 +61,37 @@ namespace OpenNest.Converters
}
}
}
/// <summary>
/// Computes the tool position after executing <paramref name="pgm"/>,
/// given that the program's frame origin is at <paramref name="startPos"/>
/// in the caller's frame. Walks nested sub-program calls recursively.
/// </summary>
private static Vector ComputeEndPosition(Program pgm, Vector startPos)
{
var pos = startPos;
for (int i = 0; i < pgm.Codes.Count; ++i)
{
var code = pgm.Codes[i];
if (code is SubProgramCall subCall && subCall.Program != null)
{
// Nested sub's frame origin in the caller's frame is startPos + Offset.
pos = ComputeEndPosition(subCall.Program, startPos + subCall.Offset);
continue;
}
if (code is Motion motion)
{
if (pgm.Mode == Mode.Incremental)
pos = pos + motion.EndPoint;
else
pos = startPos + motion.EndPoint;
}
}
return pos;
}
}
}
+12 -6
View File
@@ -20,6 +20,9 @@ namespace OpenNest.Converters
private static void AddProgram(Program program, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
{
// Capture the frame origin at entry. Sub-program Offsets are relative
// to this fixed origin, not to the current tool position.
var frameOrigin = curpos;
mode = program.Mode;
for (int i = 0; i < program.Length; ++i)
@@ -41,12 +44,15 @@ namespace OpenNest.Converters
break;
case CodeType.SubProgramCall:
var tmpmode = mode;
var subpgm = (SubProgramCall)code;
var geoProgram = new Shape();
AddProgram(subpgm.Program, ref mode, ref curpos, ref geoProgram.Entities);
geometry.Add(geoProgram);
mode = tmpmode;
var savedMode = mode;
// The sub-program's frame origin in this program's frame is
// frameOrigin + Offset — independent of current tool position.
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y);
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
mode = savedMode;
break;
}
}
@@ -106,7 +112,7 @@ namespace OpenNest.Converters
var layer = ConvertLayer(arcMove.Layer);
if (startAngle.IsEqualTo(endAngle))
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color });
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
else
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
+13
View File
@@ -2,6 +2,7 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
@@ -90,6 +91,18 @@ namespace OpenNest
public List<Bend> Bends { get; set; } = new List<Bend>();
/// <summary>
/// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file.
/// </summary>
public List<Entity> SourceEntities { get; set; }
/// <summary>
/// IDs of entities in <see cref="SourceEntities"/> that are suppressed (hidden).
/// Suppressed entities are excluded from the active Program but preserved for re-enabling.
/// </summary>
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
public double Area { get; protected set; }
public void UpdateArea()
+3 -1
View File
@@ -137,7 +137,9 @@ namespace OpenNest.Geometry
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
{
var points = new List<Vector>();
var stepAngle = Angle.TwoPI / segments;
var stepAngle = Rotation == RotationType.CW
? -Angle.TwoPI / segments
: Angle.TwoPI / segments;
var r = circumscribe && segments > 0
? Radius / System.Math.Cos(stepAngle / 2.0)
+7
View File
@@ -1,4 +1,5 @@
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Drawing;
@@ -10,10 +11,16 @@ namespace OpenNest.Geometry
protected Entity()
{
Id = Guid.NewGuid();
Layer = OpenNest.Geometry.Layer.Default;
boundingBox = new Box();
}
/// <summary>
/// Unique identifier for this entity, stable across edit sessions.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
/// </summary>
+2 -2
View File
@@ -605,7 +605,7 @@ namespace OpenNest.Geometry
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 });
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW });
break;
}
}
@@ -640,7 +640,7 @@ namespace OpenNest.Geometry
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 });
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW });
break;
}
}
+385 -104
View File
@@ -104,6 +104,98 @@ namespace OpenNest.Geometry
return double.MaxValue;
}
/// <summary>
/// Solves ray-circle intersection, returning the two parametric t values.
/// Returns false if no real intersection exists.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
private static bool SolveRayCircle(
double vx, double vy,
double cx, double cy, double r,
double dirX, double dirY,
out double t1, out double t2)
{
var ox = vx - cx;
var oy = vy - cy;
var a = dirX * dirX + dirY * dirY;
var b = 2.0 * (ox * dirX + oy * dirY);
var c = ox * ox + oy * oy - r * r;
var discriminant = b * b - 4.0 * a * c;
if (discriminant < 0)
{
t1 = t2 = double.MaxValue;
return false;
}
var sqrtD = System.Math.Sqrt(discriminant);
var inv2a = 1.0 / (2.0 * a);
t1 = (-b - sqrtD) * inv2a;
t2 = (-b + sqrtD) * inv2a;
return true;
}
/// <summary>
/// Computes the distance from a point along a direction to an arc.
/// Solves ray-circle intersection, then constrains hits to the arc's
/// angular span. Returns double.MaxValue if no hit.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayArcDistance(
double vx, double vy,
double cx, double cy, double r,
double startAngle, double endAngle, bool reversed,
double dirX, double dirY)
{
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
return double.MaxValue;
var best = double.MaxValue;
if (t1 > -Tolerance.Epsilon)
{
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
vy + t1 * dirY - cy, vx + t1 * dirX - cx));
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t1 > Tolerance.Epsilon ? t1 : 0;
}
if (t2 > -Tolerance.Epsilon && t2 < best)
{
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
vy + t2 * dirY - cy, vx + t2 * dirX - cx));
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t2 > Tolerance.Epsilon ? t2 : 0;
}
return best;
}
/// <summary>
/// Computes the distance from a point along a direction to a full circle.
/// Returns double.MaxValue if no hit.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayCircleDistance(
double vx, double vy,
double cx, double cy, double r,
double dirX, double dirY)
{
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
return double.MaxValue;
if (t1 > Tolerance.Epsilon) return t1;
if (t1 >= -Tolerance.Epsilon) return 0;
if (t2 > Tolerance.Epsilon) return t2;
if (t2 >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
/// <summary>
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
@@ -111,57 +203,7 @@ namespace OpenNest.Geometry
/// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
{
var minDist = double.MaxValue;
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
// Sort edges for pruning if not already sorted (usually they aren't here)
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite);
if (d < minDist) minDist = d;
}
return minDist;
return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
}
/// <summary>
@@ -176,21 +218,10 @@ namespace OpenNest.Geometry
var movingOffset = new Vector(movingDx, movingDy);
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1 + movingOffset);
movingVertices.Add(movingLines[i].pt2 + movingOffset);
}
var movingVertices = CollectVertices(movingLines, movingOffset);
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
var stationaryEdges = ToEdgeArray(stationaryLines);
SortEdgesForPruning(stationaryEdges, direction);
foreach (var mv in movingVertices)
{
@@ -200,21 +231,10 @@ namespace OpenNest.Geometry
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
var movingEdges = ToEdgeArray(movingLines);
SortEdgesForPruning(movingEdges, opposite);
foreach (var sv in stationaryVertices)
{
@@ -253,15 +273,11 @@ namespace OpenNest.Geometry
{
var minDist = double.MaxValue;
// Extract unique vertices from moving edges.
var movingVertices = new HashSet<Vector>();
for (var i = 0; i < movingEdges.Length; i++)
{
movingVertices.Add(movingEdges[i].start + movingOffset);
movingVertices.Add(movingEdges[i].end + movingOffset);
}
SortEdgesForPruning(stationaryEdges, direction);
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = CollectVertices(movingEdges, movingOffset);
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
@@ -270,12 +286,9 @@ namespace OpenNest.Geometry
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (var i = 0; i < stationaryEdges.Length; i++)
{
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
}
SortEdgesForPruning(movingEdges, opposite);
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
foreach (var sv in stationaryVertices)
{
@@ -467,12 +480,7 @@ namespace OpenNest.Geometry
var dirX = direction.X;
var dirY = direction.Y;
var movingVertices = new HashSet<Vector>();
for (var i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
var movingVertices = CollectVertices(movingLines, Vector.Zero);
foreach (var mv in movingVertices)
{
@@ -487,12 +495,7 @@ namespace OpenNest.Geometry
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = new HashSet<Vector>();
for (var i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
foreach (var sv in stationaryVertices)
{
@@ -507,6 +510,284 @@ namespace OpenNest.Geometry
return minDist;
}
/// <summary>
/// Computes the minimum translation distance along a push direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
/// stationaryEntities. Delegates to the Vector-based overload.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction)
{
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0));
}
/// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
/// stationaryEntities. Works with native Line, Arc, and Circle entities
/// without tessellation.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, Vector direction)
{
var minDist = double.MaxValue;
var dirX = direction.X;
var dirY = direction.Y;
var movingVertices = ExtractEntityVertices(movingEntities);
for (var v = 0; v < movingVertices.Length; v++)
{
var vx = movingVertices[v].X;
var vy = movingVertices[v].Y;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) return 0;
}
}
}
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
for (var v = 0; v < stationaryVertices.Length; v++)
{
var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) return 0;
}
}
}
// Phase 3: Arc-to-line closest-point check.
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
// closest point on a small corner arc to a straight edge may lie between
// those samples. Use ClosestPointTo to find it and fire a ray from there.
minDist = ArcToLineClosestDistance(movingEntities, stationaryEntities, dirX, dirY, minDist);
if (minDist <= 0) return 0;
minDist = ArcToLineClosestDistance(stationaryEntities, movingEntities, oppX, oppY, minDist);
if (minDist <= 0) return 0;
// Phase 4: Curve-to-curve direct distance.
// The vertex-to-entity approach misses the closest contact between two
// curved entities (circles/arcs) because only a few cardinal vertices are
// sampled. The true closest contact along the push direction is found by
// treating it as a ray from one center to an expanded circle at the other
// center (radius = r1 + r2).
for (var i = 0; i < movingEntities.Count; i++)
{
var me = movingEntities[i];
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
continue;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var se = stationaryEntities[j];
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
continue;
var d = RayCircleDistance(mcx, mcy, scx, scy, mr + sr, dirX, dirY);
if (d >= minDist)
continue;
// For arcs, verify the contact point falls within both arcs' angular ranges.
if (me is Arc || se is Arc)
{
var mx = mcx + d * dirX;
var my = mcy + d * dirY;
var toCx = scx - mx;
var toCy = scy - my;
if (me is Arc mArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
continue;
}
if (se is Arc sArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
continue;
}
}
minDist = d;
if (d <= 0) return 0;
}
}
return minDist;
}
private static double ArcToLineClosestDistance(
List<Entity> arcEntities, List<Entity> lineEntities,
double dirX, double dirY, double minDist)
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (arcEntities[i] is Arc arc)
{
for (var j = 0; j < lineEntities.Count; j++)
{
if (lineEntities[j] is Line line)
{
var linePt = line.ClosestPointTo(arc.Center);
var arcPt = arc.ClosestPointTo(linePt);
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
dirX, dirY);
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
}
}
}
}
return minDist;
}
private static double RayEntityDistance(
double vx, double vy, Entity entity, double dirX, double dirY)
{
if (entity is Line line)
{
return RayEdgeDistance(vx, vy,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
dirX, dirY);
}
if (entity is Arc arc)
{
return RayArcDistance(vx, vy,
arc.Center.X, arc.Center.Y, arc.Radius,
arc.StartAngle, arc.EndAngle, arc.IsReversed,
dirX, dirY);
}
if (entity is Circle circle)
{
return RayCircleDistance(vx, vy,
circle.Center.X, circle.Center.Y, circle.Radius,
dirX, dirY);
}
return double.MaxValue;
}
private static Vector[] ExtractEntityVertices(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity is Line line)
{
vertices.Add(line.pt1);
vertices.Add(line.pt2);
}
else if (entity is Arc arc)
{
vertices.Add(arc.StartPoint());
vertices.Add(arc.EndPoint());
AddArcExtremeVertices(vertices, arc);
}
else if (entity is Circle circle)
{
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
}
}
return vertices.ToArray();
}
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
if (arc.IsReversed)
Generic.Swap(ref a1, ref a2);
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
private static HashSet<Vector> CollectVertices(List<Line> lines, Vector offset)
{
return CollectVertices(ToEdgeArray(lines), offset);
}
private static HashSet<Vector> CollectVertices((Vector start, Vector end)[] edges, Vector offset)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < edges.Length; i++)
{
vertices.Add(edges[i].start + offset);
vertices.Add(edges[i].end + offset);
}
return vertices;
}
private static (Vector start, Vector end)[] ToEdgeArray(List<Line> lines)
{
var edges = new (Vector start, Vector end)[lines.Count];
for (var i = 0; i < lines.Count; i++)
edges[i] = (lines[i].pt1, lines[i].pt2);
return edges;
}
private static void SortEdgesForPruning((Vector start, Vector end)[] edges, PushDirection direction)
{
if (direction == PushDirection.Left || direction == PushDirection.Right)
System.Array.Sort(edges, (a, b) =>
System.Math.Min(a.start.Y, a.end.Y).CompareTo(System.Math.Min(b.start.Y, b.end.Y)));
else
System.Array.Sort(edges, (a, b) =>
System.Math.Min(a.start.X, a.end.X).CompareTo(System.Math.Min(b.start.X, b.end.X)));
}
private static bool TryGetCurveParams(Entity entity, out double cx, out double cy, out double r)
{
if (entity is Circle circle)
{
cx = circle.Center.X; cy = circle.Center.Y; r = circle.Radius;
return true;
}
if (entity is Arc arc)
{
cx = arc.Center.X; cy = arc.Center.Y; r = arc.Radius;
return true;
}
cx = cy = r = 0;
return false;
}
private static double BoxProjectionMin(Box box, double dx, double dy)
{
var x = dx >= 0 ? box.Left : box.Right;
@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace OpenNest
{
public interface IMaterialProvidingPostProcessor
{
IEnumerable<string> GetMaterialNames();
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace OpenNest
{
public interface IPostProcessorNestAware
{
void PrepareForNest(Nest nest);
}
}
+14 -2
View File
@@ -62,10 +62,15 @@ namespace OpenNest
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint)
{
ApplyLeadIns(parameters, approachPoint, Geometry.Vector.Invalid);
}
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint, Vector nextPartStart)
{
preLeadInRotation = Rotation;
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
var result = strategy.Apply(Program, approachPoint);
var result = strategy.Apply(Program, approachPoint, nextPartStart);
Program = result.Program;
CuttingParameters = parameters;
HasManualLeadIns = true;
@@ -190,7 +195,14 @@ namespace OpenNest
{
var rotation = Rotation;
Program = BaseDrawing.Program.Clone() as Program;
Program.Rotate(Program.Rotation - rotation);
if (!Math.Tolerance.IsEqualTo(rotation, 0))
Program.Rotate(rotation);
HasManualLeadIns = false;
LeadInsLocked = false;
CuttingParameters = null;
UpdateBounds();
}
/// <summary>
+112 -1
View File
@@ -39,7 +39,115 @@ namespace OpenNest
return lines;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
/// <summary>
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
/// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs.
/// </summary>
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
if (offsetShape == null)
return new List<Entity>();
// Offset the shape's entities to the part's location.
// OffsetOutward creates a new Shape, so mutating is safe.
foreach (var entity in offsetShape.Entities)
entity.Offset(part.Location);
return offsetShape.Entities;
}
/// <summary>
/// Returns all entities (perimeter + cutouts) with spacing offset applied,
/// without tessellation. Perimeter is offset outward, cutouts inward.
/// </summary>
public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var entities = new List<Entity>();
var perimeter = profile.Perimeter.OffsetOutward(spacing);
if (perimeter != null)
{
foreach (var entity in perimeter.Entities)
entity.Offset(part.Location);
entities.AddRange(perimeter.Entities);
}
foreach (var cutout in profile.Cutouts)
{
var inset = cutout.OffsetInward(spacing);
if (inset == null) continue;
foreach (var entity in inset.Entities)
entity.Offset(part.Location);
entities.AddRange(inset.Entities);
}
return entities;
}
/// <summary>
/// Returns perimeter entities at the part's world location, without tessellation
/// or spacing offset.
/// </summary>
public static List<Entity> GetPerimeterEntities(Part part)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
}
/// <summary>
/// Returns all entities (perimeter + cutouts) at the part's world location,
/// without tessellation or spacing offset.
/// </summary>
public static List<Entity> GetPartEntities(Part part)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
foreach (var cutout in profile.Cutouts)
entities.AddRange(CopyEntitiesAtLocation(cutout.Entities, part.Location));
return entities;
}
private static List<Entity> CopyEntitiesAtLocation(List<Entity> source, Vector location)
{
var result = new List<Entity>(source.Count);
for (var i = 0; i < source.Count; i++)
{
var entity = source[i];
Entity copy;
if (entity is Line line)
copy = new Line(line.StartPoint + location, line.EndPoint + location);
else if (entity is Arc arc)
copy = new Arc(arc.Center + location, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed);
else if (entity is Circle circle)
copy = new Circle(circle.Center + location, circle.Radius);
else
continue;
result.Add(copy);
}
return result;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001,
bool perimeterOnly = false)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
@@ -50,9 +158,12 @@ namespace OpenNest
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location);
if (!perimeterOnly)
{
foreach (var cutout in profile.Cutouts)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location);
}
return lines;
}
+5
View File
@@ -7,6 +7,11 @@ namespace OpenNest.Shapes
{
public double Diameter { get; set; }
public override void SetPreviewDefaults()
{
Diameter = 8;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>
+9
View File
@@ -11,6 +11,15 @@ namespace OpenNest.Shapes
public double HolePatternDiameter { get; set; }
public int HoleCount { get; set; }
public override void SetPreviewDefaults()
{
NominalPipeSize = 2;
OD = 7.5;
HoleDiameter = 0.875;
HolePatternDiameter = 5.5;
HoleCount = 8;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>();
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
public double Base { get; set; }
public double Height { get; set; }
public override void SetPreviewDefaults()
{
Base = 8;
Height = 10;
}
public override Drawing GetDrawing()
{
var midX = Base / 2.0;
+8
View File
@@ -10,6 +10,14 @@ namespace OpenNest.Shapes
public double LegWidth { get; set; }
public double LegHeight { get; set; }
public override void SetPreviewDefaults()
{
Width = 8;
Height = 10;
LegWidth = 3;
LegHeight = 3;
}
public override Drawing GetDrawing()
{
var lw = LegWidth > 0 ? LegWidth : Width / 2.0;
+5
View File
@@ -7,6 +7,11 @@ namespace OpenNest.Shapes
{
public double Width { get; set; }
public override void SetPreviewDefaults()
{
Width = 8;
}
public override Drawing GetDrawing()
{
var center = Width / 2.0;
+6
View File
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
public double Length { get; set; }
public double Width { get; set; }
public override void SetPreviewDefaults()
{
Length = 12;
Width = 6;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
public double Width { get; set; }
public double Height { get; set; }
public override void SetPreviewDefaults()
{
Width = 8;
Height = 6;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>
+6
View File
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
public double OuterDiameter { get; set; }
public double InnerDiameter { get; set; }
public override void SetPreviewDefaults()
{
OuterDiameter = 10;
InnerDiameter = 6;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>
@@ -10,6 +10,13 @@ namespace OpenNest.Shapes
public double Width { get; set; }
public double Radius { get; set; }
public override void SetPreviewDefaults()
{
Length = 12;
Width = 6;
Radius = 1;
}
public override Drawing GetDrawing()
{
var r = Radius;
+2
View File
@@ -26,6 +26,8 @@ namespace OpenNest.Shapes
public abstract Drawing GetDrawing();
public virtual void SetPreviewDefaults() { }
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
{
var json = File.ReadAllText(path);
+8
View File
@@ -10,6 +10,14 @@ namespace OpenNest.Shapes
public double StemWidth { get; set; }
public double BarHeight { get; set; }
public override void SetPreviewDefaults()
{
Width = 10;
Height = 8;
StemWidth = 3;
BarHeight = 3;
}
public override Drawing GetDrawing()
{
var sw = StemWidth > 0 ? StemWidth : Width / 3.0;
+7
View File
@@ -9,6 +9,13 @@ namespace OpenNest.Shapes
public double BottomWidth { get; set; }
public double Height { get; set; }
public override void SetPreviewDefaults()
{
TopWidth = 6;
BottomWidth = 10;
Height = 6;
}
public override Drawing GetDrawing()
{
var offset = (BottomWidth - TopWidth) / 2.0;
+2 -1
View File
@@ -17,7 +17,8 @@ namespace OpenNest.Engine.BestFit
if (!result.Keep)
continue;
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight))
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight) ||
result.LongestSide > System.Math.Max(MaxPlateWidth, MaxPlateHeight))
{
result.Keep = false;
result.Reason = "Exceeds plate dimensions";
+3
View File
@@ -4,6 +4,7 @@ using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
@@ -49,6 +50,8 @@ namespace OpenNest.Engine.BestFit
var allCandidates = candidateBags.SelectMany(c => c).ToList();
Debug.WriteLine($"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates");
var results = _evaluator.EvaluateAll(allCandidates);
_filter.Apply(results);
+248 -8
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
@@ -17,7 +18,6 @@ namespace OpenNest.Engine.BestFit
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
@@ -43,7 +43,6 @@ namespace OpenNest.Engine.BestFit
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary edges
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
@@ -66,7 +65,6 @@ namespace OpenNest.Engine.BestFit
}
}
// Case 2: Facing stationary vertices → moving edges (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
@@ -95,6 +93,253 @@ namespace OpenNest.Engine.BestFit
return results;
}
public double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets)
{
var count = offsets.Length;
var results = new double[count];
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
var movingCurves = ExtractCurveParams(movingEntities);
var stationaryCurves = ExtractCurveParams(stationaryEntities);
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var offset = offsets[i];
var dirX = offset.DirX;
var dirY = offset.DirY;
var oppX = -dirX;
var oppY = -dirY;
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary entities
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
var vy = leadingMoving[v].Y + offset.Dy;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], 0, 0, dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Case 2: Facing stationary vertices → moving entities (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
var svy = facingStationary[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(svx, svy, movingEntities[j], offset.Dx, offset.Dy, oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Phase 3: Curve-to-curve direct distance.
// Vertex sampling misses the true contact between two curved entities
// when the approach angle doesn't align with a sampled vertex.
for (var m = 0; m < movingCurves.Length; m++)
{
var mc = movingCurves[m];
var mcx = mc.Cx + offset.Dx;
var mcy = mc.Cy + offset.Dy;
for (var s = 0; s < stationaryCurves.Length; s++)
{
var sc = stationaryCurves[s];
var d = SpatialQuery.RayCircleDistance(
mcx, mcy, sc.Cx, sc.Cy, mc.Radius + sc.Radius, dirX, dirY);
if (d >= minDist || d == double.MaxValue)
continue;
if (mc.Entity is Arc || sc.Entity is Arc)
{
var mx = mcx + d * dirX;
var my = mcy + d * dirY;
var toCx = sc.Cx - mx;
var toCy = sc.Cy - my;
if (mc.Entity is Arc mArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
continue;
}
if (sc.Entity is Arc sArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
continue;
}
}
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
results[i] = minDist;
});
return results;
}
private readonly struct CurveParams
{
public readonly Entity Entity;
public readonly double Cx, Cy, Radius;
public CurveParams(Entity entity, double cx, double cy, double radius)
{
Entity = entity;
Cx = cx;
Cy = cy;
Radius = radius;
}
}
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
{
var curves = new List<CurveParams>();
for (var i = 0; i < entities.Count; i++)
{
if (entities[i] is Circle circle)
curves.Add(new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius));
else if (entities[i] is Arc arc)
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
}
return curves.ToArray();
}
private static double RayEntityDistance(
double vx, double vy, Entity entity,
double entityOffsetX, double entityOffsetY,
double dirX, double dirY)
{
if (entity is Line line)
{
return SpatialQuery.RayEdgeDistance(
vx, vy,
line.StartPoint.X + entityOffsetX, line.StartPoint.Y + entityOffsetY,
line.EndPoint.X + entityOffsetX, line.EndPoint.Y + entityOffsetY,
dirX, dirY);
}
if (entity is Arc arc)
{
return SpatialQuery.RayArcDistance(
vx, vy,
arc.Center.X + entityOffsetX, arc.Center.Y + entityOffsetY,
arc.Radius,
arc.StartAngle, arc.EndAngle, arc.IsReversed,
dirX, dirY);
}
if (entity is Circle circle)
{
return SpatialQuery.RayCircleDistance(
vx, vy,
circle.Center.X + entityOffsetX, circle.Center.Y + entityOffsetY,
circle.Radius,
dirX, dirY);
}
return double.MaxValue;
}
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity is Line line)
{
vertices.Add(line.StartPoint);
vertices.Add(line.EndPoint);
}
else if (entity is Arc arc)
{
vertices.Add(arc.StartPoint());
vertices.Add(arc.EndPoint());
AddArcExtremes(vertices, arc);
}
else if (entity is Circle circle)
{
// Four cardinal points
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
}
}
return vertices.ToArray();
}
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
var reversed = arc.IsReversed;
if (reversed)
Generic.Swap(ref a1, ref a2);
// Right (0°)
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
// Top (90°)
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
// Left (180°)
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
// Bottom (270°)
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
private static Vector[] ExtractUniqueVertices(List<Line> lines)
{
var vertices = new HashSet<Vector>();
@@ -106,11 +351,6 @@ namespace OpenNest.Engine.BestFit
return vertices.ToArray();
}
/// <summary>
/// Filters vertices by their projection onto the push direction.
/// keepHigh=true returns the leading half (front face, closest to target).
/// keepHigh=false returns the facing half (side facing the approaching part).
/// </summary>
private static Vector[] FilterVerticesByProjection(
Vector[] vertices, double dirX, double dirY, bool keepHigh)
{
@@ -36,6 +36,16 @@ namespace OpenNest.Engine.BestFit
flatOffsets, count, directions);
}
public double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets)
{
// GPU path doesn't support native entities yet — fall back to CPU.
var cpu = new CpuDistanceComputer();
return cpu.ComputeDistances(stationaryEntities, movingEntities, offsets);
}
/// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
/// Left=0, Down=1, Right=2, Up=3.
@@ -9,5 +9,10 @@ namespace OpenNest.Engine.BestFit
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets);
double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets);
}
}
+35 -33
View File
@@ -15,11 +15,18 @@ namespace OpenNest.Engine.BestFit
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
if (candidates.Count == 0)
return new List<BestFitResult>();
// Build a perimeter-only drawing once — all candidates share the same drawing.
// This avoids cloning the full program (with all cutouts) for every candidate.
var perimeterDrawing = CreatePerimeterDrawing(candidates[0].Drawing);
var resultBag = new ConcurrentBag<BestFitResult>();
Parallel.ForEach(candidates, c =>
{
resultBag.Add(Evaluate(c));
resultBag.Add(Evaluate(c, perimeterDrawing));
});
return resultBag.ToList();
@@ -27,18 +34,24 @@ namespace OpenNest.Engine.BestFit
public BestFitResult Evaluate(PairCandidate candidate)
{
var drawing = candidate.Drawing;
var perimeterDrawing = CreatePerimeterDrawing(candidate.Drawing);
return Evaluate(candidate, perimeterDrawing);
}
var part1 = Part.CreateAtOrigin(drawing);
private BestFitResult Evaluate(PairCandidate candidate, Drawing perimeterDrawing)
{
var part1 = Part.CreateAtOrigin(perimeterDrawing);
var part2 = Part.CreateAtOrigin(drawing, candidate.Part2Rotation);
var part2 = Part.CreateAtOrigin(perimeterDrawing, candidate.Part2Rotation);
part2.Location = candidate.Part2Offset;
part2.UpdateBounds();
// Check overlap via shape intersection
var overlaps = CheckOverlap(part1, part2);
// Overlap check — perimeter vs perimeter
var shape1 = GetPerimeterShape(part1);
var shape2 = GetPerimeterShape(part2);
var overlaps = shape1 != null && shape2 != null && shape1.Intersects(shape2, out _);
// Collect all polygon vertices for convex hull / optimal rotation
// Convex hull vertices from perimeter polygons only
var allPoints = GetPartVertices(part1);
allPoints.AddRange(GetPartVertices(part2));
@@ -66,7 +79,7 @@ namespace OpenNest.Engine.BestFit
hullAngles = new List<double> { 0 };
}
var trueArea = drawing.Area * 2;
var trueArea = candidate.Drawing.Area * 2;
// Normalize to landscape (width >= height) for consistent display.
if (bestHeight > bestWidth)
@@ -91,38 +104,29 @@ namespace OpenNest.Engine.BestFit
};
}
private bool CheckOverlap(Part part1, Part part2)
private static Drawing CreatePerimeterDrawing(Drawing source)
{
var shapes1 = GetPartShapes(part1);
var shapes2 = GetPartShapes(part2);
for (var i = 0; i < shapes1.Count; i++)
{
for (var j = 0; j < shapes2.Count; j++)
{
List<Vector> pts;
if (shapes1[i].Intersects(shapes2[j], out pts))
return true;
}
var entities = ConvertProgram.ToGeometry(source.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
var profile = new ShapeProfile(entities);
var program = ConvertGeometry.ToProgram(profile.Perimeter);
return new Drawing(source.Name, program);
}
return false;
}
private List<Shape> GetPartShapes(Part part)
private static Shape GetPerimeterShape(Part part)
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
var shapes = ShapeBuilder.GetShapes(entities);
shapes.ForEach(s => s.Offset(part.Location));
return shapes;
if (shapes.Count == 0) return null;
shapes[0].Offset(part.Location);
return shapes[0];
}
private List<Vector> GetPartVertices(Part part)
private static List<Vector> GetPartVertices(Part part)
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>();
@@ -130,9 +134,7 @@ namespace OpenNest.Engine.BestFit
{
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
polygon.Offset(part.Location);
foreach (var vertex in polygon.Vertices)
points.Add(vertex);
points.AddRange(polygon.Vertices);
}
return points;
@@ -36,8 +36,8 @@ namespace OpenNest.Engine.BestFit
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
var halfSpacing = spacing / 2;
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
var part1Entities = PartGeometry.GetOffsetPerimeterEntities(part1, halfSpacing);
var part2Entities = PartGeometry.GetOffsetPerimeterEntities(part2Template, halfSpacing);
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
@@ -48,7 +48,7 @@ namespace OpenNest.Engine.BestFit
return candidates;
var distances = _distanceComputer.ComputeDistances(
part1Lines, part2TemplateLines, offsets);
part1Entities, part2Entities, offsets);
var testNumber = 0;
@@ -90,15 +90,18 @@ namespace OpenNest.Engine.BestFit
if (isHorizontalPush)
{
// Perpendicular sweep along Y → Width; push extent along X → Length
perpMin = -(bbox2.Width + spacing);
perpMax = bbox1.Width + bbox2.Width + spacing;
// Trim to offsets where the parts overlap by at least 50%.
var halfOverlap = bbox2.Width * 0.5;
perpMin = -(halfOverlap - spacing);
perpMax = bbox1.Width + halfOverlap + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
}
else
{
// Perpendicular sweep along X → Length; push extent along Y → Width
perpMin = -(bbox2.Length + spacing);
perpMax = bbox1.Length + bbox2.Length + spacing;
var halfOverlap = bbox2.Length * 0.5;
perpMin = -(halfOverlap - spacing);
perpMax = bbox1.Length + halfOverlap + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
}
+29 -11
View File
@@ -139,24 +139,42 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
var best = SelectBestFitPair(bestFits);
if (best == null)
return null;
List<Part> bestPlacement = null;
// BuildParts produces landscape orientation (Width >= Height).
// Try both landscape and portrait (90° rotated) and let the
// engine's comparer pick the better orientation.
var landscape = best.BuildParts(drawing);
foreach (var fit in bestFits)
{
if (!fit.Keep)
continue;
// Skip pairs that can't possibly fit the work area in either orientation.
if (fit.ShortestSide > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue;
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue;
var landscape = fit.BuildParts(drawing);
var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea);
var pFits = TryOffsetToWorkArea(portrait, workArea);
if (!lFits && !pFits)
return null;
// Pick the better orientation for this pair.
List<Part> candidate = null;
if (lFits && pFits)
return IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
return lFits ? landscape : portrait;
candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
else if (lFits)
candidate = landscape;
else if (pFits)
candidate = portrait;
if (candidate == null)
continue;
if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea))
bestPlacement = candidate;
}
return bestPlacement;
}
private static List<Part> RotatePair90(List<Part> parts)
+26 -12
View File
@@ -11,8 +11,6 @@ namespace OpenNest.Engine.Fill
/// </summary>
public static class Compactor
{
private const double ChordTolerance = 0.001;
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{
var obstacleParts = plate.Parts
@@ -44,7 +42,7 @@ namespace OpenNest.Engine.Fill
var opposite = -direction;
var obstacleBoxes = new Box[obstacleParts.Count];
var obstacleLines = new List<Line>[obstacleParts.Count];
var obstacleEntities = new List<Entity>[obstacleParts.Count];
for (var i = 0; i < obstacleParts.Count; i++)
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
@@ -61,7 +59,19 @@ namespace OpenNest.Engine.Fill
distance = edgeDist;
var movingBox = moving.BoundingBox;
List<Line> movingLines = null;
List<Entity> movingEntities = null;
// Check if any obstacle is inside the moving part — only then
// do we need cutout entities on the moving part.
var needCutouts = false;
for (var i = 0; i < obstacleBoxes.Length; i++)
{
if (movingBox.Contains(obstacleBoxes[i]))
{
needCutouts = true;
break;
}
}
for (var i = 0; i < obstacleBoxes.Length; i++)
{
@@ -76,15 +86,19 @@ namespace OpenNest.Engine.Fill
if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
continue;
movingLines ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
movingEntities ??= halfSpacing > 0
? (needCutouts
? PartGeometry.GetOffsetPartEntities(moving, halfSpacing)
: PartGeometry.GetOffsetPerimeterEntities(moving, halfSpacing))
: (needCutouts
? PartGeometry.GetPartEntities(moving)
: PartGeometry.GetPerimeterEntities(moving));
obstacleLines[i] ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
obstacleEntities[i] ??= halfSpacing > 0
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
if (d < distance)
distance = d;
}
@@ -157,7 +171,7 @@ namespace OpenNest.Engine.Fill
continue;
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
var d = gap - partSpacing - 2 * ChordTolerance;
var d = gap - partSpacing - 0.002;
if (d < 0) d = 0;
if (d < distance)
distance = d;
+5 -4
View File
@@ -119,10 +119,11 @@ namespace OpenNest.Engine.Fill
var maxCopyDistance = FindMaxPairDistance(
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
if (maxCopyDistance < Tolerance.Epsilon)
return bboxDim + PartSpacing;
return maxCopyDistance;
// The copy distance must be at least bboxDim + PartSpacing to prevent
// bounding box overlap. Cross-pair slides can underestimate when the
// circumscribed polygon boundary overshoots the true arc, creating
// spurious contacts between diagonal parts in adjacent copies.
return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing);
}
/// <summary>
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -27,20 +26,6 @@ namespace OpenNest
public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
protected override BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
BestFitResult best = null;
foreach (var r in results)
{
if (!r.Keep) continue;
if (best == null || r.BoundingHeight < best.BoundingHeight)
best = r;
}
return best;
}
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
+43 -4
View File
@@ -1,5 +1,7 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine
@@ -15,14 +17,28 @@ namespace OpenNest.Engine
return;
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
var currentPoint = PlateHelper.GetExitPoint(plate);
var exitPoint = PlateHelper.GetExitPoint(plate);
foreach (var sp in sequenced)
// Pass 1: assign lead-ins to establish pierce points
var piercePoints = AssignPass(sequenced, parameters, exitPoint, nextPiercePoints: null);
// Pass 2: re-assign with knowledge of next part's start point
AssignPass(sequenced, parameters, exitPoint, nextPiercePoints: piercePoints);
}
private Vector[] AssignPass(List<SequencedPart> sequenced, CuttingParameters parameters,
Vector exitPoint, Vector[] nextPiercePoints)
{
var part = sp.Part;
var piercePoints = new Vector[sequenced.Count];
var currentPoint = exitPoint;
for (var i = 0; i < sequenced.Count; i++)
{
var part = sequenced[i].Part;
if (part.LeadInsLocked)
{
piercePoints[i] = GetPiercePoint(part);
currentPoint = part.Location;
continue;
}
@@ -31,10 +47,33 @@ namespace OpenNest.Engine
part.RemoveLeadIns();
var localApproach = currentPoint - part.Location;
part.ApplyLeadIns(parameters, localApproach);
if (nextPiercePoints != null && i + 1 < sequenced.Count)
{
var nextStart = nextPiercePoints[i + 1] - part.Location;
part.ApplyLeadIns(parameters, localApproach, nextStart);
}
else
{
part.ApplyLeadIns(parameters, localApproach);
}
piercePoints[i] = GetPiercePoint(part);
currentPoint = part.Location;
}
return piercePoints;
}
private static Vector GetPiercePoint(Part part)
{
foreach (var code in part.Program.Codes)
{
if (code is CNC.Motion motion)
return motion.EndPoint + part.Location;
}
return part.Location;
}
}
}
+661
View File
@@ -0,0 +1,661 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
public enum PartClass
{
Large,
Medium,
Small,
}
public class MultiPlateNester
{
private readonly Plate _template;
private readonly List<PlateOption> _plateOptions;
private readonly List<PlateOption> _sortedOptions;
private readonly double _salvageRate;
private readonly double _minRemnantSize;
private readonly List<PlateResult> _platePool;
private readonly IProgress<NestProgress> _progress;
private readonly CancellationToken _token;
private readonly MultiPlateNestOptions _options;
private bool HasPlateOptions => _plateOptions != null && _plateOptions.Count > 0;
private MultiPlateNester(
MultiPlateNestOptions options,
List<Plate> existingPlates,
IProgress<NestProgress> progress, CancellationToken token)
{
_options = options;
_template = options.Template;
_plateOptions = options.PlateOptions;
_sortedOptions = options.PlateOptions?.OrderBy(o => o.Cost).ToList();
_salvageRate = options.SalvageRate;
_minRemnantSize = options.MinRemnantSize;
_platePool = InitializePlatePool(existingPlates);
_progress = progress;
_token = token;
}
// --- Static Utility Methods ---
public static bool FitsBounds(Box container, Box part)
{
var fitsNormal = container.Width >= part.Width - Tolerance.Epsilon
&& container.Length >= part.Length - Tolerance.Epsilon;
var fitsRotated = container.Width >= part.Length - Tolerance.Epsilon
&& container.Length >= part.Width - Tolerance.Epsilon;
return fitsNormal || fitsRotated;
}
public static List<NestItem> SortItems(List<NestItem> items, PartSortOrder sortOrder)
{
var withBounds = items.Select(i => (Item: i, Bounds: i.Drawing.Program.BoundingBox())).ToList();
switch (sortOrder)
{
case PartSortOrder.BoundingBoxArea:
return withBounds
.OrderByDescending(x => x.Bounds.Width * x.Bounds.Length)
.Select(x => x.Item)
.ToList();
case PartSortOrder.Size:
return withBounds
.OrderByDescending(x => System.Math.Max(x.Bounds.Width, x.Bounds.Length))
.Select(x => x.Item)
.ToList();
default:
return items.ToList();
}
}
public static PartClass Classify(Box partBounds, Box workArea)
{
var halfWidth = workArea.Width / 2.0;
var halfLength = workArea.Length / 2.0;
if (partBounds.Width > halfWidth || partBounds.Length > halfLength)
return PartClass.Large;
var workAreaArea = workArea.Width * workArea.Length;
var partArea = partBounds.Width * partBounds.Length;
if (partArea > workAreaArea / 9.0)
return PartClass.Medium;
return PartClass.Small;
}
public static bool IsScrapRemnant(Box remnant, double minRemnantSize)
{
return remnant.Width < minRemnantSize && remnant.Length < minRemnantSize;
}
public static List<Box> FindRemnants(Plate plate, double minRemnantSize, bool scrapOnly)
{
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
return remnants.Where(r => IsScrapRemnant(r, minRemnantSize) == scrapOnly).ToList();
}
public struct UpgradeDecision
{
public bool ShouldUpgrade;
public double UpgradeCost;
public double NewPlateCost;
}
public static Plate CreatePlate(Plate template, List<PlateOption> options, Box minBounds)
{
var plate = new Plate(template.Size)
{
PartSpacing = template.PartSpacing,
Quadrant = template.Quadrant,
};
plate.EdgeSpacing = new Spacing
{
Left = template.EdgeSpacing.Left,
Right = template.EdgeSpacing.Right,
Top = template.EdgeSpacing.Top,
Bottom = template.EdgeSpacing.Bottom,
};
if (options == null || options.Count == 0 || minBounds == null)
return plate;
var sorted = options.OrderBy(o => o.Cost).ToList();
foreach (var option in sorted)
{
if (FitsBounds(OptionWorkArea(option, template), minBounds))
{
plate.Size = new Size(option.Width, option.Length);
return plate;
}
}
return plate;
}
public static UpgradeDecision EvaluateUpgradeVsNew(
PlateOption currentSize,
PlateOption upgradeSize,
PlateOption newPlateSize,
double salvageRate,
double estimatedNewPlateUtilization)
{
var upgradeCost = upgradeSize.Cost - currentSize.Cost;
var newPlateCost = newPlateSize.Cost;
var remnantFraction = 1.0 - estimatedNewPlateUtilization;
var salvageCredit = remnantFraction * newPlateSize.Cost * salvageRate;
var netNewCost = newPlateCost - salvageCredit;
return new UpgradeDecision
{
ShouldUpgrade = upgradeCost <= netNewCost,
UpgradeCost = upgradeCost,
NewPlateCost = netNewCost,
};
}
// --- Main Entry Point ---
public static MultiPlateResult Nest(
List<NestItem> items,
MultiPlateNestOptions options,
List<Plate> existingPlates = null,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
var nester = new MultiPlateNester(options, existingPlates, progress, token);
return nester.Run(items, options.SortOrder, options.AllowPlateCreation);
}
// --- Private Helpers ---
private static Box OptionWorkArea(PlateOption option, Plate template)
{
var w = option.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right;
var h = option.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom;
return new Box(0, 0, w, h);
}
private static double ScoreZone(Box zone, Box partBounds)
{
if (!FitsBounds(zone, partBounds))
return -1;
var cols = (int)(zone.Width / partBounds.Width);
var rows = (int)(zone.Length / partBounds.Length);
var colsR = (int)(zone.Width / partBounds.Length);
var rowsR = (int)(zone.Length / partBounds.Width);
var estimatedCount = System.Math.Max(cols * rows, colsR * rowsR);
var utilization = (estimatedCount * partBounds.Width * partBounds.Length) / zone.Area();
var zoneAspect = zone.Width / zone.Length;
var partAspect = partBounds.Width / partBounds.Length;
var aspectMatch = System.Math.Min(zoneAspect, partAspect) / System.Math.Max(zoneAspect, partAspect);
return utilization * 0.7 + aspectMatch * 0.3;
}
private static void DecrementQuantity(NestItem item, int placed)
{
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
private int FillAndPlace(PlateResult pr, Box zone, NestItem item)
{
var engine = NestEngineRegistry.Create(pr.Plate);
var clonedItem = CloneItem(item);
var parts = engine.Fill(clonedItem, zone, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
DecrementQuantity(item, parts.Count);
}
return parts.Count;
}
private PlateResult CreateNewPlateResult(Plate plate)
{
var pr = new PlateResult { Plate = plate, IsNew = true };
if (HasPlateOptions)
{
pr.ChosenSize = _plateOptions.FirstOrDefault(o =>
o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length));
}
return pr;
}
private static NestItem CloneItem(NestItem item)
{
return new NestItem
{
Drawing = item.Drawing,
Priority = item.Priority,
Quantity = item.Quantity,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
};
}
private static List<PlateResult> InitializePlatePool(List<Plate> existingPlates)
{
var pool = new List<PlateResult>();
if (existingPlates != null)
{
foreach (var plate in existingPlates)
pool.Add(new PlateResult { Plate = plate, IsNew = false });
}
return pool;
}
private bool TryWithUpgradedSize(PlateResult pr, PlateOption upgradeOption, Func<List<Box>, bool> tryFill)
{
var oldSize = pr.Plate.Size;
var oldChosenSize = pr.ChosenSize;
pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
pr.ChosenSize = upgradeOption;
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
if (remnants.Count > 0 && tryFill(remnants))
return true;
pr.Plate.Size = oldSize;
pr.ChosenSize = oldChosenSize;
return false;
}
private PlateOption FindSmallestFittingOption(Box partBounds)
{
return _sortedOptions?.FirstOrDefault(o => FitsBounds(OptionWorkArea(o, _template), partBounds));
}
// --- Orchestration ---
private MultiPlateResult Run(List<NestItem> items, PartSortOrder sortOrder, bool allowPlateCreation)
{
var result = new MultiPlateResult();
if (items == null || items.Count == 0)
return result;
var sorted = SortItems(items.Where(i => i.Quantity > 0).ToList(), sortOrder);
foreach (var item in sorted)
{
if (_token.IsCancellationRequested || item.Quantity <= 0)
continue;
var bb = item.Drawing.Program.BoundingBox();
TryPlaceOnExistingPlates(item, bb);
var templateClass = Classify(bb, _template.WorkArea());
if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small)
{
PlaceOnNewPlates(item, bb);
if (item.Quantity > 0 && HasPlateOptions)
TryUpgradeOrNewPlate(item, bb);
}
}
var leftovers = sorted.Where(i => i.Quantity > 0).ToList();
if (leftovers.Count > 0 && allowPlateCreation && !_token.IsCancellationRequested)
{
PackIntoExistingRemnants(leftovers);
CreateSharedPlates(leftovers);
}
if (HasPlateOptions && !_token.IsCancellationRequested)
TryConsolidateTailPlates();
foreach (var item in sorted.Where(i => i.Quantity > 0))
result.UnplacedItems.Add(item);
result.Plates.AddRange(_platePool.Where(p => p.Parts.Count > 0 || p.IsNew));
return result;
}
private void PackIntoExistingRemnants(List<NestItem> leftovers)
{
foreach (var pr in _platePool)
{
if (_token.IsCancellationRequested)
break;
var anyPlaced = true;
while (anyPlaced && !_token.IsCancellationRequested)
{
anyPlaced = false;
var remaining = leftovers.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
if (remnants.Count == 0)
break;
var engine = NestEngineRegistry.Create(pr.Plate);
foreach (var remnant in remnants)
{
remaining = leftovers.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var cloned = remaining.Select(CloneItem).ToList();
var parts = engine.PackArea(remnant, cloned, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
anyPlaced = true;
foreach (var item in remaining)
{
var placed = parts.Count(p => p.BaseDrawing == item.Drawing);
DecrementQuantity(item, placed);
}
}
}
}
}
}
private void CreateSharedPlates(List<NestItem> leftovers)
{
leftovers.RemoveAll(i => i.Quantity <= 0);
while (leftovers.Count > 0 && !_token.IsCancellationRequested)
{
var plate = CreatePlate(_template, _plateOptions, null);
var pr = CreateNewPlateResult(plate);
var placedAny = false;
foreach (var item in leftovers)
{
if (item.Quantity <= 0 || _token.IsCancellationRequested)
continue;
var remnants = !placedAny
? new List<Box> { plate.WorkArea() }
: RemnantFinder.FromPlate(plate).FindRemnants();
if (remnants.Count == 0)
break;
var engine = NestEngineRegistry.Create(plate);
foreach (var remnant in remnants)
{
if (item.Quantity <= 0)
break;
var clonedItem = CloneItem(item);
var parts = engine.Fill(clonedItem, remnant, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
DecrementQuantity(item, parts.Count);
placedAny = true;
}
}
}
if (!placedAny)
break;
_platePool.Add(pr);
leftovers.RemoveAll(i => i.Quantity <= 0);
}
}
private bool TryPlaceOnExistingPlates(NestItem item, Box partBounds)
{
var anyPlaced = false;
var remnantCache = new Dictionary<PlateResult, List<Box>>();
PlateResult lastModified = null;
while (item.Quantity > 0 && !_token.IsCancellationRequested)
{
PlateResult bestPlate = null;
Box bestZone = null;
var bestScore = double.MinValue;
foreach (var pr in _platePool)
{
if (_token.IsCancellationRequested)
break;
if (pr == lastModified || !remnantCache.ContainsKey(pr))
{
var workArea = pr.Plate.WorkArea();
var classification = Classify(partBounds, workArea);
remnantCache[pr] = classification == PartClass.Small
? FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: true)
: FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: false);
}
foreach (var zone in remnantCache[pr])
{
var score = ScoreZone(zone, partBounds);
if (score > bestScore)
{
bestPlate = pr;
bestZone = zone;
bestScore = score;
}
}
}
if (bestPlate == null || bestZone == null)
break;
if (FillAndPlace(bestPlate, bestZone, item) == 0)
break;
lastModified = bestPlate;
anyPlaced = true;
}
return anyPlaced;
}
private bool PlaceOnNewPlates(NestItem item, Box partBounds)
{
var anyPlaced = false;
while (item.Quantity > 0 && !_token.IsCancellationRequested)
{
var plate = CreatePlate(_template, _plateOptions, partBounds);
var workArea = plate.WorkArea();
if (!FitsBounds(workArea, partBounds))
break;
var pr = CreateNewPlateResult(plate);
if (FillAndPlace(pr, workArea, item) == 0)
break;
_platePool.Add(pr);
anyPlaced = true;
}
return anyPlaced;
}
private bool TryUpgradeOrNewPlate(NestItem item, Box partBounds)
{
if (!HasPlateOptions)
return false;
foreach (var pr in _platePool.Where(p => p.IsNew && p.ChosenSize != null))
{
var currentOption = pr.ChosenSize;
var currentIdx = _sortedOptions.FindIndex(o =>
o.Width.IsEqualTo(currentOption.Width) && o.Length.IsEqualTo(currentOption.Length));
if (currentIdx < 0 || currentIdx >= _sortedOptions.Count - 1)
continue;
for (var i = currentIdx + 1; i < _sortedOptions.Count; i++)
{
var upgradeOption = _sortedOptions[i];
if (upgradeOption.Width < currentOption.Width - Tolerance.Epsilon
|| upgradeOption.Length < currentOption.Length - Tolerance.Epsilon)
continue;
var smallestNew = FindSmallestFittingOption(partBounds);
if (smallestNew == null)
continue;
var utilEst = pr.Plate.Utilization();
var decision = EvaluateUpgradeVsNew(currentOption, upgradeOption, smallestNew,
_salvageRate, utilEst);
if (decision.ShouldUpgrade)
{
var placed = TryWithUpgradedSize(pr, upgradeOption, remnants =>
{
foreach (var remnant in remnants)
{
if (FillAndPlace(pr, remnant, item) > 0)
return true;
}
return false;
});
if (placed)
return true;
}
}
}
return false;
}
private void TryConsolidateTailPlates()
{
var consolidated = true;
while (consolidated)
{
consolidated = false;
var activePlates = _platePool.Where(p => p.Parts.Count > 0 && p.IsNew).ToList();
if (activePlates.Count < 2)
return;
var donors = activePlates.OrderBy(p => p.Plate.Utilization()).ToList();
foreach (var donor in donors)
{
if (donor.Parts.Count == 0)
continue;
var donorParts = donor.Parts.ToList();
var absorbed = false;
foreach (var target in activePlates)
{
if (target == donor || target.ChosenSize == null || target.Parts.Count == 0)
continue;
var currentOption = target.ChosenSize;
foreach (var upgradeOption in _sortedOptions.Where(o =>
o.Width >= currentOption.Width - Tolerance.Epsilon
&& o.Length >= currentOption.Length - Tolerance.Epsilon
&& (o.Width > currentOption.Width + Tolerance.Epsilon
|| o.Length > currentOption.Length + Tolerance.Epsilon)))
{
absorbed = TryWithUpgradedSize(target, upgradeOption, remnants =>
{
var engine = NestEngineRegistry.Create(target.Plate);
var tempItems = donorParts
.GroupBy(p => p.BaseDrawing)
.Select(g => new NestItem
{
Drawing = g.Key,
Quantity = g.Count(),
})
.ToList();
var totalPlaced = new List<Part>();
foreach (var remnant in remnants)
{
var placed = engine.PackArea(remnant, tempItems, _progress, _token);
totalPlaced.AddRange(placed);
foreach (var ti in tempItems)
{
var count = placed.Count(p => p.BaseDrawing == ti.Drawing);
ti.Quantity = System.Math.Max(0, ti.Quantity - count);
}
if (tempItems.All(ti => ti.Quantity <= 0))
break;
}
if (totalPlaced.Count >= donorParts.Count)
{
target.AddParts(totalPlaced);
foreach (var p in donorParts)
donor.Plate.Parts.Remove(p);
donor.Parts.Clear();
_platePool.Remove(donor);
return true;
}
return false;
});
if (absorbed)
break;
}
if (absorbed)
break;
}
if (absorbed)
{
consolidated = true;
break;
}
}
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace OpenNest
{
public class MultiPlateNestOptions
{
public Plate Template { get; set; }
public List<PlateOption> PlateOptions { get; set; }
public double SalvageRate { get; set; } = 0.5;
public PartSortOrder SortOrder { get; set; } = PartSortOrder.BoundingBoxArea;
public double MinRemnantSize { get; set; } = 12.0;
public bool AllowPlateCreation { get; set; } = true;
}
public class MultiPlateResult
{
public List<PlateResult> Plates { get; set; } = new();
public List<NestItem> UnplacedItems { get; set; } = new();
}
public class PlateResult
{
public Plate Plate { get; set; }
public List<Part> Parts { get; set; } = new();
public PlateOption ChosenSize { get; set; }
public bool IsNew { get; set; }
public void AddParts(IList<Part> parts)
{
Plate.Parts.AddRange(parts);
Parts.AddRange(parts);
}
}
}
+26 -20
View File
@@ -56,11 +56,6 @@ namespace OpenNest
protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection);
protected virtual BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
return results.FirstOrDefault(r => r.Keep);
}
// --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> Fill(NestItem item, Box workArea,
@@ -338,45 +333,56 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
var bestFit = SelectBestFitPair(bestFits);
if (bestFit == null) continue;
var parts = bestFit.BuildParts(item.Drawing);
List<Part> bestPlacement = null;
Box bestTarget = null;
foreach (var fit in bestFits)
{
if (!fit.Keep)
continue;
var parts = fit.BuildParts(item.Drawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width;
var pairL = pairBbox.Length;
var minDim = System.Math.Min(pairW, pairL);
var remnants = finder.FindRemnants(minDim);
Box target = null;
foreach (var r in remnants)
{
if (pairW <= r.Width + Tolerance.Epsilon &&
pairL <= r.Length + Tolerance.Epsilon)
{
target = r;
break;
}
}
if (target == null) continue;
var offset = target.Location - pairBbox.Location;
var offset = r.Location - pairBbox.Location;
foreach (var p in parts)
{
p.Offset(offset);
p.UpdateBounds();
}
result.AddRange(parts);
if (bestPlacement == null || IsBetterFill(parts, bestPlacement, r))
{
bestPlacement = parts;
bestTarget = r;
}
break;
}
}
}
if (bestPlacement == null) continue;
result.AddRange(bestPlacement);
item.Quantity = 0;
var envelope = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var envelope = ((IEnumerable<IBoundable>)bestPlacement).GetBoundingBox();
finder.AddObstacle(envelope.Offset(Plate.PartSpacing));
Debug.WriteLine($"[Nest] Placed best-fit pair for {item.Drawing.Name} " +
$"at ({target.X:F1},{target.Y:F1}), size {pairW:F1}x{pairL:F1}");
$"at ({bestTarget.X:F1},{bestTarget.Y:F1}), " +
$"size {envelope.Width:F1}x{envelope.Length:F1}");
}
return result;
+8
View File
@@ -0,0 +1,8 @@
namespace OpenNest
{
public enum PartSortOrder
{
BoundingBoxArea,
Size,
}
}
+30 -6
View File
@@ -1,4 +1,5 @@
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
@@ -22,7 +23,8 @@ namespace OpenNest
if (items == null || items.Count == 0 || plateOptions == null || plateOptions.Count == 0)
return null;
// Find the minimum dimension needed to fit the largest part.
// Find the minimum dimension needed to fit the largest part,
// skipping items that are too large for every plate option.
var minPartWidth = 0.0;
var minPartLength = 0.0;
foreach (var item in items)
@@ -31,6 +33,14 @@ namespace OpenNest
var bb = item.Drawing.Program.BoundingBox();
var shortSide = System.Math.Min(bb.Width, bb.Length);
var longSide = System.Math.Max(bb.Width, bb.Length);
if (!plateOptions.Any(o => FitsPart(o, shortSide, longSide, templatePlate.EdgeSpacing)))
{
Debug.WriteLine($"[PlateOptimizer] Skipping oversized item '{item.Drawing.Name}' " +
$"({shortSide:F1}x{longSide:F1}) — does not fit any plate option");
continue;
}
if (shortSide > minPartWidth) minPartWidth = shortSide;
if (longSide > minPartLength) minPartLength = longSide;
}
@@ -44,6 +54,19 @@ namespace OpenNest
if (candidates.Count == 0)
return null;
// Pre-compute best fits for all candidate plate sizes at once.
// This runs the expensive GPU evaluation once on the largest plate
// and filters the results for each smaller size.
var plateSizes = candidates
.Select(o => (Width: o.Length, Height: o.Width))
.ToList();
foreach (var item in items)
{
if (item.Quantity <= 0) continue;
BestFitCache.ComputeForSizes(item.Drawing, templatePlate.PartSpacing, plateSizes);
}
PlateOptimizerResult best = null;
foreach (var option in candidates)
@@ -58,9 +81,10 @@ namespace OpenNest
if (IsBetter(result, best))
best = result;
// Early exit: when salvage is zero, cheapest plate that fits everything wins.
// With salvage > 0, larger plates may have lower net cost, so keep searching.
if (salvageRate <= 0)
// Early exit: when all items fit, larger plates can only have
// worse utilization and higher cost. With salvage < 100%, the
// remnant credit never offsets the extra plate cost, so skip.
if (salvageRate < 1.0)
{
var allPlaced = items.All(i => i.Quantity <= 0 ||
result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) >= i.Quantity);
@@ -158,8 +182,8 @@ namespace OpenNest
if (!candidate.NetCost.IsEqualTo(current.NetCost))
return candidate.NetCost < current.NetCost;
// 3. Smaller plate area as tiebreak.
return candidate.ChosenSize.Area < current.ChosenSize.Area;
// 3. Higher utilization (tighter density) as tiebreak.
return candidate.Utilization > current.Utilization;
}
}
}
@@ -4,7 +4,7 @@ using System.Collections.Generic;
namespace OpenNest.Engine
{
public class PlateResult
public class PlateProcessingResult
{
public List<ProcessedPart> Parts { get; init; }
}
+42 -12
View File
@@ -14,18 +14,41 @@ namespace OpenNest.Engine
public ContourCuttingStrategy CuttingStrategy { get; set; }
public IRapidPlanner RapidPlanner { get; set; }
public PlateResult Process(Plate plate)
public PlateProcessingResult Process(Plate plate)
{
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
var exitPoint = PlateHelper.GetExitPoint(plate);
// Pass 1: process each part to collect pierce points
var piercePoints = new Vector[sequenced.Count];
var currentPoint = exitPoint;
for (var i = 0; i < sequenced.Count; i++)
{
var part = sequenced[i].Part;
if (!part.HasManualLeadIns && CuttingStrategy != null)
{
var localApproach = ToPartLocal(currentPoint, part);
var result = CuttingStrategy.Apply(part.Program, localApproach);
piercePoints[i] = ToPlateSpace(GetProgramStartPoint(result.Program), part);
currentPoint = ToPlateSpace(result.LastCutPoint, part);
}
else
{
piercePoints[i] = ToPlateSpace(GetProgramStartPoint(part.Program), part);
currentPoint = ToPlateSpace(GetProgramEndPoint(part.Program), part);
}
}
// Pass 2: re-process with next part's start point for perimeter lead-in refinement
var results = new List<ProcessedPart>(sequenced.Count);
var cutAreas = new List<Shape>();
var currentPoint = PlateHelper.GetExitPoint(plate);
currentPoint = exitPoint;
foreach (var sp in sequenced)
for (var i = 0; i < sequenced.Count; i++)
{
var part = sp.Part;
// Compute approach point in part-local space
var part = sequenced[i].Part;
var localApproach = ToPartLocal(currentPoint, part);
Program processedProgram;
@@ -33,7 +56,18 @@ namespace OpenNest.Engine
if (!part.HasManualLeadIns && CuttingStrategy != null)
{
var cuttingResult = CuttingStrategy.Apply(part.Program, localApproach);
CuttingResult cuttingResult;
if (i + 1 < sequenced.Count)
{
var nextStart = ToPartLocal(piercePoints[i + 1], part);
cuttingResult = CuttingStrategy.Apply(part.Program, localApproach, nextStart);
}
else
{
cuttingResult = CuttingStrategy.Apply(part.Program, localApproach);
}
processedProgram = cuttingResult.Program;
lastCutLocal = cuttingResult.LastCutPoint;
}
@@ -43,11 +77,9 @@ namespace OpenNest.Engine
lastCutLocal = GetProgramEndPoint(part.Program);
}
// Pierce point: program start point in plate space
var pierceLocal = GetProgramStartPoint(processedProgram);
var piercePoint = ToPlateSpace(pierceLocal, part);
// Plan rapid from currentPoint to pierce point
var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
results.Add(new ProcessedPart
@@ -57,16 +89,14 @@ namespace OpenNest.Engine
RapidPath = rapidPath
});
// Update cut areas with part perimeter
var perimeter = GetPartPerimeter(part);
if (perimeter != null)
cutAreas.Add(perimeter);
// Update current point to last cut point in plate space
currentPoint = ToPlateSpace(lastCutLocal, part);
}
return new PlateResult { Parts = results };
return new PlateProcessingResult { Parts = results };
}
private static Vector ToPartLocal(Vector platePoint, Part part)
+1 -1
View File
@@ -15,7 +15,7 @@ namespace OpenNest.Engine.Strategies
public int PlateNumber { get; init; }
public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; }
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
public int MaxQuantity { get; init; }
public PartType PartType { get; set; }
-15
View File
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -25,20 +24,6 @@ namespace OpenNest
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
protected override BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
BestFitResult best = null;
foreach (var r in results)
{
if (!r.Keep) continue;
if (best == null || r.BoundingHeight < best.BoundingHeight)
best = r;
}
return best;
}
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
+39
View File
@@ -0,0 +1,39 @@
namespace OpenNest.IO
{
/// <summary>
/// Options controlling how <see cref="CadImporter"/> loads a CAD file
/// and builds a <see cref="Drawing"/>.
/// </summary>
public class CadImportOptions
{
/// <summary>
/// Detector name to use for bend detection. Null = auto-detect.
/// </summary>
public string BendDetectorName { get; set; }
/// <summary>
/// When false, skips bend detection entirely. Default true.
/// </summary>
public bool DetectBends { get; set; } = true;
/// <summary>
/// Override the drawing name. Null = filename without extension.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Required quantity on the produced drawing. Default 1.
/// </summary>
public int Quantity { get; set; } = 1;
/// <summary>
/// Customer name on the produced drawing. Default null.
/// </summary>
public string Customer { get; set; }
/// <summary>
/// Returns a default options instance.
/// </summary>
public static CadImportOptions Default => new CadImportOptions();
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.Collections.Generic;
using OpenNest.Bending;
using OpenNest.Geometry;
namespace OpenNest.IO
{
/// <summary>
/// Intermediate result of <see cref="CadImporter.Import"/>. Holds raw loaded
/// geometry and detected bends. Callers may mutate <see cref="Entities"/> and
/// <see cref="Bends"/> before passing to <see cref="CadImporter.BuildDrawing"/>.
/// </summary>
public class CadImportResult
{
/// <summary>
/// All entities loaded from the source file, including promoted bend
/// source entities. Mutable.
/// </summary>
public List<Entity> Entities { get; set; } = new List<Entity>();
/// <summary>
/// Bends detected during import. Mutable — callers may add, remove,
/// or replace entries before building the drawing.
/// </summary>
public List<Bend> Bends { get; set; } = new List<Bend>();
/// <summary>
/// Bounding box of <see cref="Entities"/> at import time. May be stale
/// if callers mutate <see cref="Entities"/>; recompute if needed.
/// </summary>
public Box Bounds { get; set; }
/// <summary>
/// Absolute path to the source file.
/// </summary>
public string SourcePath { get; set; }
/// <summary>
/// Default drawing name (filename without extension, unless overridden).
/// </summary>
public string Name { get; set; }
}
}
+140
View File
@@ -0,0 +1,140 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Bending;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
namespace OpenNest.IO
{
/// <summary>
/// Shared service that converts a CAD source file into a fully-populated
/// <see cref="Drawing"/>. Used by the UI, console, MCP, API, and training
/// tools so all code paths produce identical drawings.
/// </summary>
public static class CadImporter
{
/// <summary>
/// Load a DXF file, run bend detection, and return a mutable result
/// ready for interactive editing or direct conversion to a Drawing.
/// </summary>
public static CadImportResult Import(string path, CadImportOptions options = null)
{
options ??= CadImportOptions.Default;
var dxf = Dxf.Import(path);
var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null)
{
bends = options.BendDetectorName == null
? BendDetectorRegistry.AutoDetect(dxf.Document)
: BendDetectorRegistry.GetByName(options.BendDetectorName)
?.DetectBends(dxf.Document)
?? new List<Bend>();
}
Bend.UpdateEtchEntities(dxf.Entities, bends);
return new CadImportResult
{
Entities = dxf.Entities,
Bends = bends,
Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
};
}
/// <summary>
/// Convenience for headless callers: Import a file and build a Drawing
/// in a single call, using all loaded entities and detected bends.
/// </summary>
public static Drawing ImportDrawing(string path, CadImportOptions options = null)
{
options ??= CadImportOptions.Default;
var result = Import(path, options);
return BuildDrawing(
result,
result.Entities,
result.Bends,
options.Quantity,
options.Customer,
editedProgram: null);
}
/// <summary>
/// Build a fully-populated <see cref="Drawing"/> from an import result plus
/// the caller's current entity and bend state. UI callers pass the currently
/// visible subset; headless callers pass the full lists.
///
/// The produced drawing has:
/// - Program generated from the visible entities, with its first rapid moved
/// to the origin and the pierce location stored in Source.Offset
/// - SourceEntities containing all non-bend-source entities from the result
/// - SuppressedEntityIds containing entities whose layer or IsVisible is false
/// - Bends copied from the provided list
/// - Customer, Quantity, Source.Path from options / result
/// </summary>
/// <param name="result">Import result from <see cref="Import"/>.</param>
/// <param name="entities">
/// Entities to build the program from. Typically the currently visible subset.
/// </param>
/// <param name="bends">Bends to attach to the drawing.</param>
/// <param name="quantity">Required quantity.</param>
/// <param name="customer">Customer name, or null.</param>
/// <param name="editedProgram">
/// When non-null, replaces the generated program (used by the UI to honor
/// in-place G-code edits). Source.Offset is still populated from the
/// generated program so round-trips stay consistent.
/// </param>
public static Drawing BuildDrawing(
CadImportResult result,
IEnumerable<Entity> entities,
IEnumerable<Bend> bends,
int quantity,
string customer,
OpenNest.CNC.Program editedProgram)
{
var visible = entities as IList<Entity> ?? new List<Entity>(entities);
var bendList = bends as IList<Bend> ?? new List<Bend>(bends);
var normalized = ShapeProfile.NormalizeEntities(visible);
var pgm = ConvertGeometry.ToProgram(normalized);
var offset = Vector.Zero;
if (pgm != null && pgm.Codes.Count > 0 && pgm[0].Type == OpenNest.CNC.CodeType.RapidMove)
{
var rapid = (OpenNest.CNC.RapidMove)pgm[0];
offset = rapid.EndPoint;
pgm.Offset(-offset);
}
var drawing = new Drawing(result.Name)
{
Color = Drawing.GetNextColor(),
Customer = customer,
};
drawing.Source.Path = result.SourcePath;
drawing.Source.Offset = offset;
drawing.Quantity.Required = quantity;
drawing.Bends.AddRange(bendList);
drawing.Program = editedProgram ?? pgm;
var bendSources = new HashSet<Entity>(
bendList.Where(b => b.SourceEntity != null).Select(b => b.SourceEntity));
drawing.SourceEntities = result.Entities
.Where(e => !bendSources.Contains(e))
.ToList();
drawing.SuppressedEntityIds = new HashSet<System.Guid>(
drawing.SourceEntities
.Where(e => !(e.Layer != null && e.Layer.IsVisible && e.IsVisible))
.Select(e => e.Id));
return drawing;
}
}
}
+378
View File
@@ -0,0 +1,378 @@
using ACadSharp;
using ACadSharp.IO;
using CSMath;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
namespace OpenNest.IO
{
using AcadArc = ACadSharp.Entities.Arc;
using AcadCircle = ACadSharp.Entities.Circle;
using AcadLine = ACadSharp.Entities.Line;
using Layer = ACadSharp.Tables.Layer;
public static class Dxf
{
#region Import
/// <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 static DxfImportResult Import(string path)
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return new DxfImportResult
{
Entities = ConvertEntities(doc),
Document = doc
};
}
public static List<Entity> GetGeometry(string path)
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return ConvertEntities(doc);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(Stream stream)
{
try
{
using var reader = new DxfReader(stream);
var doc = reader.Read();
return ConvertEntities(doc);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
#endregion
#region Export
public static void ExportProgram(Program program, string path)
{
using var stream = File.Create(path);
ExportProgram(program, stream);
}
public static void ExportProgram(Program program, Stream stream)
{
var ctx = new ExportContext();
ctx.AddProgram(program);
using var writer = new DxfWriter(stream, ctx.Document, false);
writer.Write();
}
public static void ExportPlate(Plate plate, string path)
{
using var stream = File.Create(path);
ExportPlate(plate, stream);
}
public static void ExportPlate(Plate plate, Stream stream)
{
var ctx = new ExportContext();
ctx.AddPlateOutline(plate);
foreach (var part in plate.Parts)
{
var endpt = part.Location.ToAcadXYZ();
ctx.AddLine(ctx.CurPos, endpt, ctx.RapidLayer);
ctx.CurPos = part.Location.ToAcadXYZ();
ctx.AddProgram(part.Program);
}
using var writer = new DxfWriter(stream, ctx.Document, false);
writer.Write();
}
#endregion
#region Private
private static List<Entity> ConvertEntities(CadDocument doc)
{
var entities = new List<Entity>();
var lines = new List<Line>();
var arcs = new List<Arc>();
foreach (var entity in doc.Entities)
{
if (IsNonCutLayer(entity.Layer?.Name))
continue;
switch (entity)
{
case ACadSharp.Entities.Line line:
lines.Add(line.ToOpenNest());
break;
case ACadSharp.Entities.Arc arc:
arcs.Add(arc.ToOpenNest());
break;
case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest());
break;
case ACadSharp.Entities.Spline spline:
foreach (var e in spline.ToOpenNest())
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
case ACadSharp.Entities.LwPolyline lwPolyline:
lines.AddRange(lwPolyline.ToOpenNest());
break;
case ACadSharp.Entities.Polyline polyline:
lines.AddRange(polyline.ToOpenNest());
break;
case ACadSharp.Entities.Ellipse ellipse:
foreach (var e in ellipse.ToOpenNest())
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
}
}
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
entities.AddRange(lines);
entities.AddRange(arcs);
return entities;
}
private static bool IsNonCutLayer(string layerName)
{
return string.Equals(layerName, "BEND", StringComparison.OrdinalIgnoreCase)
|| string.Equals(layerName, "ETCH", StringComparison.OrdinalIgnoreCase);
}
private class ExportContext
{
public CadDocument Document { get; }
public XYZ CurPos { get; set; }
public Layer CutLayer { get; }
public Layer RapidLayer { get; }
public Layer PlateLayer { get; }
private Mode mode;
public ExportContext()
{
Document = new CadDocument();
CutLayer = new Layer("Cut") { Color = new Color(1) };
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
PlateLayer = new Layer("Plate") { Color = new Color(4) };
Document.Layers.Add(CutLayer);
Document.Layers.Add(RapidLayer);
Document.Layers.Add(PlateLayer);
}
public void AddLine(XYZ start, XYZ end, Layer layer)
{
var ln = new AcadLine
{
StartPoint = start,
EndPoint = end,
Layer = layer
};
Document.Entities.Add(ln);
}
public void AddPlateOutline(Plate plate)
{
XYZ pt1, pt2, pt3, pt4;
switch (plate.Quadrant)
{
case 1:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, plate.Size.Width, 0);
pt3 = new XYZ(plate.Size.Length, plate.Size.Width, 0);
pt4 = new XYZ(plate.Size.Length, 0, 0);
break;
case 2:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, plate.Size.Width, 0);
pt3 = new XYZ(-plate.Size.Length, plate.Size.Width, 0);
pt4 = new XYZ(-plate.Size.Length, 0, 0);
break;
case 3:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, -plate.Size.Width, 0);
pt3 = new XYZ(-plate.Size.Length, -plate.Size.Width, 0);
pt4 = new XYZ(-plate.Size.Length, 0, 0);
break;
case 4:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, -plate.Size.Width, 0);
pt3 = new XYZ(plate.Size.Length, -plate.Size.Width, 0);
pt4 = new XYZ(plate.Size.Length, 0, 0);
break;
default:
return;
}
AddLine(pt1, pt2, PlateLayer);
AddLine(pt2, pt3, PlateLayer);
AddLine(pt3, pt4, PlateLayer);
AddLine(pt4, pt1, PlateLayer);
var m1 = new XYZ(pt1.X + plate.EdgeSpacing.Left, pt1.Y + plate.EdgeSpacing.Bottom, 0);
var m2 = new XYZ(m1.X, pt2.Y - plate.EdgeSpacing.Top, 0);
var m3 = new XYZ(pt3.X - plate.EdgeSpacing.Right, m2.Y, 0);
var m4 = new XYZ(m3.X, m1.Y, 0);
AddLine(m1, m2, PlateLayer);
AddLine(m2, m3, PlateLayer);
AddLine(m3, m4, PlateLayer);
AddLine(m4, m1, PlateLayer);
}
public void AddProgram(Program program)
{
mode = program.Mode;
for (var i = 0; i < program.Length; ++i)
{
var code = program[i];
switch (code.Type)
{
case CodeType.ArcMove:
AddArcMove((ArcMove)code);
break;
case CodeType.LinearMove:
AddLinearMove((LinearMove)code);
break;
case CodeType.RapidMove:
AddRapidMove((RapidMove)code);
break;
case CodeType.SubProgramCall:
var tmpmode = mode;
AddProgram(((SubProgramCall)code).Program);
mode = tmpmode;
break;
}
}
}
private void AddLinearMove(LinearMove line)
{
var pt = line.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
pt = new XYZ(pt.X + CurPos.X, pt.Y + CurPos.Y, 0);
AddLine(CurPos, pt, CutLayer);
CurPos = pt;
}
private void AddRapidMove(RapidMove rapid)
{
var pt = rapid.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
pt = new XYZ(pt.X + CurPos.X, pt.Y + CurPos.Y, 0);
AddLine(CurPos, pt, RapidLayer);
CurPos = pt;
}
private void AddArcMove(ArcMove arc)
{
var center = arc.CenterPoint.ToAcadXYZ();
var endpt = arc.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
{
endpt = new XYZ(endpt.X + CurPos.X, endpt.Y + CurPos.Y, 0);
center = new XYZ(center.X + CurPos.X, center.Y + CurPos.Y, 0);
}
var startAngle = System.Math.Atan2(
CurPos.Y - center.Y,
CurPos.X - center.X);
var endAngle = System.Math.Atan2(
endpt.Y - center.Y,
endpt.X - center.X);
if (arc.Rotation == RotationType.CW)
Generic.Swap(ref startAngle, ref endAngle);
var dx = endpt.X - center.X;
var dy = endpt.Y - center.Y;
var radius = System.Math.Sqrt(dx * dx + dy * dy);
if (startAngle.IsEqualTo(endAngle))
{
var circle = new AcadCircle
{
Center = center,
Radius = radius,
Layer = CutLayer
};
Document.Entities.Add(circle);
}
else
{
var acadArc = new AcadArc
{
Center = center,
Radius = radius,
StartAngle = startAngle,
EndAngle = endAngle,
Layer = CutLayer
};
Document.Entities.Add(acadArc);
}
CurPos = endpt;
}
}
#endregion
}
}
-297
View File
@@ -1,297 +0,0 @@
using ACadSharp;
using ACadSharp.IO;
using CSMath;
using OpenNest.CNC;
using OpenNest.Math;
using System.Diagnostics;
using System.IO;
namespace OpenNest.IO
{
using AcadArc = ACadSharp.Entities.Arc;
using AcadCircle = ACadSharp.Entities.Circle;
using AcadLine = ACadSharp.Entities.Line;
using Layer = ACadSharp.Tables.Layer;
public class DxfExporter
{
private CadDocument doc;
private XYZ curpos;
private Mode mode;
private readonly Layer cutLayer;
private readonly Layer rapidLayer;
private readonly Layer plateLayer;
public DxfExporter()
{
doc = new CadDocument();
cutLayer = new Layer("Cut");
cutLayer.Color = new Color(1);
rapidLayer = new Layer("Rapid");
rapidLayer.Color = new Color(5);
plateLayer = new Layer("Plate");
plateLayer.Color = new Color(4);
}
public void ExportProgram(Program program, Stream stream)
{
doc = new CadDocument();
EnsureLayers();
AddProgram(program);
using (var writer = new DxfWriter(stream, doc, false))
{
writer.Write();
}
}
public bool ExportProgram(Program program, string path)
{
Stream stream = null;
var success = false;
try
{
stream = File.Create(path);
ExportProgram(program, stream);
success = true;
}
catch
{
Debug.Fail("DxfExporter.ExportProgram failed to write program to file: " + path);
}
finally
{
if (stream != null)
stream.Close();
}
return success;
}
public void ExportPlate(Plate plate, Stream stream)
{
doc = new CadDocument();
EnsureLayers();
AddPlateOutline(plate);
foreach (var part in plate.Parts)
{
var endpt = part.Location.ToAcadXYZ();
AddLine(curpos, endpt, rapidLayer);
curpos = part.Location.ToAcadXYZ();
AddProgram(part.Program);
}
using (var writer = new DxfWriter(stream, doc, false))
{
writer.Write();
}
}
public bool ExportPlate(Plate plate, string path)
{
Stream stream = null;
var success = false;
try
{
stream = File.Create(path);
ExportPlate(plate, stream);
success = true;
}
catch
{
Debug.Fail("DxfExporter.ExportPlate failed to write plate to file: " + path);
}
finally
{
if (stream != null)
stream.Close();
}
return success;
}
private void EnsureLayers()
{
doc.Layers.Add(cutLayer);
doc.Layers.Add(rapidLayer);
doc.Layers.Add(plateLayer);
}
private void AddLine(XYZ start, XYZ end, Layer layer)
{
var ln = new AcadLine();
ln.StartPoint = start;
ln.EndPoint = end;
ln.Layer = layer;
doc.Entities.Add(ln);
}
private void AddPlateOutline(Plate plate)
{
XYZ pt1;
XYZ pt2;
XYZ pt3;
XYZ pt4;
switch (plate.Quadrant)
{
case 1:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, plate.Size.Width, 0);
pt3 = new XYZ(plate.Size.Length, plate.Size.Width, 0);
pt4 = new XYZ(plate.Size.Length, 0, 0);
break;
case 2:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, plate.Size.Width, 0);
pt3 = new XYZ(-plate.Size.Length, plate.Size.Width, 0);
pt4 = new XYZ(-plate.Size.Length, 0, 0);
break;
case 3:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, -plate.Size.Width, 0);
pt3 = new XYZ(-plate.Size.Length, -plate.Size.Width, 0);
pt4 = new XYZ(-plate.Size.Length, 0, 0);
break;
case 4:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, -plate.Size.Width, 0);
pt3 = new XYZ(plate.Size.Length, -plate.Size.Width, 0);
pt4 = new XYZ(plate.Size.Length, 0, 0);
break;
default:
return;
}
AddLine(pt1, pt2, plateLayer);
AddLine(pt2, pt3, plateLayer);
AddLine(pt3, pt4, plateLayer);
AddLine(pt4, pt1, plateLayer);
var m1 = new XYZ(pt1.X + plate.EdgeSpacing.Left, pt1.Y + plate.EdgeSpacing.Bottom, 0);
var m2 = new XYZ(m1.X, pt2.Y - plate.EdgeSpacing.Top, 0);
var m3 = new XYZ(pt3.X - plate.EdgeSpacing.Right, m2.Y, 0);
var m4 = new XYZ(m3.X, m1.Y, 0);
AddLine(m1, m2, plateLayer);
AddLine(m2, m3, plateLayer);
AddLine(m3, m4, plateLayer);
AddLine(m4, m1, plateLayer);
}
private void AddProgram(Program program)
{
mode = program.Mode;
for (var i = 0; i < program.Length; ++i)
{
var code = program[i];
switch (code.Type)
{
case CodeType.ArcMove:
var arc = (ArcMove)code;
AddArcMove(arc);
break;
case CodeType.LinearMove:
var line = (LinearMove)code;
AddLinearMove(line);
break;
case CodeType.RapidMove:
var rapid = (RapidMove)code;
AddRapidMove(rapid);
break;
case CodeType.SubProgramCall:
var tmpmode = mode;
var subpgm = (CNC.SubProgramCall)code;
AddProgram(subpgm.Program);
mode = tmpmode;
break;
}
}
}
private void AddLinearMove(LinearMove line)
{
var pt = line.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0);
AddLine(curpos, pt, cutLayer);
curpos = pt;
}
private void AddRapidMove(RapidMove rapid)
{
var pt = rapid.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0);
AddLine(curpos, pt, rapidLayer);
curpos = pt;
}
private void AddArcMove(ArcMove arc)
{
var center = arc.CenterPoint.ToAcadXYZ();
var endpt = arc.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
{
endpt = new XYZ(endpt.X + curpos.X, endpt.Y + curpos.Y, 0);
center = new XYZ(center.X + curpos.X, center.Y + curpos.Y, 0);
}
var startAngle = System.Math.Atan2(
curpos.Y - center.Y,
curpos.X - center.X);
var endAngle = System.Math.Atan2(
endpt.Y - center.Y,
endpt.X - center.X);
if (arc.Rotation == OpenNest.RotationType.CW)
Generic.Swap(ref startAngle, ref endAngle);
var dx = endpt.X - center.X;
var dy = endpt.Y - center.Y;
var radius = System.Math.Sqrt(dx * dx + dy * dy);
if (startAngle.IsEqualTo(endAngle))
{
var circle = new AcadCircle();
circle.Center = center;
circle.Radius = radius;
circle.Layer = cutLayer;
doc.Entities.Add(circle);
}
else
{
var arc2 = new AcadArc();
arc2.Center = center;
arc2.Radius = radius;
arc2.StartAngle = startAngle;
arc2.EndAngle = endAngle;
arc2.Layer = cutLayer;
doc.Entities.Add(arc2);
}
curpos = endpt;
}
}
}
-155
View File
@@ -1,155 +0,0 @@
using ACadSharp;
using ACadSharp.IO;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
namespace OpenNest.IO
{
public class DxfImporter
{
public int SplinePrecision { get; set; }
public DxfImporter()
{
}
private List<Entity> GetGeometry(CadDocument doc)
{
var entities = new List<Entity>();
var lines = new List<Line>();
var arcs = new List<Arc>();
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:
lines.Add(line.ToOpenNest());
break;
case ACadSharp.Entities.Arc arc:
arcs.Add(arc.ToOpenNest());
break;
case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest());
break;
case ACadSharp.Entities.Spline spline:
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:
lines.AddRange(lwPolyline.ToOpenNest());
break;
case ACadSharp.Entities.Polyline polyline:
lines.AddRange(polyline.ToOpenNest());
break;
case ACadSharp.Entities.Ellipse ellipse:
foreach (var e in ellipse.ToOpenNest())
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
}
}
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
entities.AddRange(lines);
entities.AddRange(arcs);
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;
try
{
using (var reader = new DxfReader(stream))
{
var doc = reader.Read();
geometry = GetGeometry(doc);
success = true;
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
geometry = new List<Entity>();
}
finally
{
if (stream != null)
stream.Close();
}
return success;
}
public bool GetGeometry(string path, out List<Entity> geometry)
{
var success = false;
try
{
using (var reader = new DxfReader(path))
{
var doc = reader.Read();
geometry = GetGeometry(doc);
success = true;
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
geometry = new List<Entity>();
}
return success;
}
private static bool IsNonCutLayer(string layerName)
{
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(layerName, "ETCH", System.StringComparison.OrdinalIgnoreCase);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Linq;
using static OpenNest.IO.NestFormat;
namespace OpenNest.IO
{
public static class EntitySerializer
{
public static EntitySetDto ToDto(List<Entity> entities, HashSet<Guid> suppressed)
{
return new EntitySetDto
{
Entities = entities.Select(ToEntityDto).ToList(),
Suppressed = suppressed.Select(id => id.ToString()).ToList()
};
}
public static (List<Entity> entities, HashSet<Guid> suppressed) FromDto(EntitySetDto dto)
{
var entities = dto.Entities.Select(FromEntityDto).ToList();
var suppressed = new HashSet<Guid>(dto.Suppressed.Select(Guid.Parse));
return (entities, suppressed);
}
private static EntityDto ToEntityDto(Entity entity)
{
switch (entity.Type)
{
case EntityType.Line:
var line = (Line)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "line",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
X1 = line.StartPoint.X,
Y1 = line.StartPoint.Y,
X2 = line.EndPoint.X,
Y2 = line.EndPoint.Y
};
case EntityType.Arc:
var arc = (Arc)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "arc",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
CX = arc.Center.X,
CY = arc.Center.Y,
R = arc.Radius,
StartAngle = arc.StartAngle,
EndAngle = arc.EndAngle,
Reversed = arc.IsReversed
};
case EntityType.Circle:
var circle = (Circle)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "circle",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
CX = circle.Center.X,
CY = circle.Center.Y,
R = circle.Radius,
Rotation = circle.Rotation == RotationType.CW ? "CW" : "CCW"
};
default:
throw new NotSupportedException($"Entity type {entity.Type} is not supported for serialization.");
}
}
private static Entity FromEntityDto(EntityDto dto)
{
Entity entity;
switch (dto.Type)
{
case "line":
entity = new Line(
new Vector(dto.X1, dto.Y1),
new Vector(dto.X2, dto.Y2));
break;
case "arc":
entity = new Arc(
new Vector(dto.CX, dto.CY),
dto.R,
dto.StartAngle,
dto.EndAngle,
dto.Reversed);
break;
case "circle":
var circle = new Circle(new Vector(dto.CX, dto.CY), dto.R);
circle.Rotation = dto.Rotation == "CW" ? RotationType.CW : RotationType.CCW;
entity = circle;
break;
default:
throw new NotSupportedException($"Entity type '{dto.Type}' is not supported for deserialization.");
}
entity.Id = Guid.Parse(dto.Id);
entity.Layer = ResolveLayer(dto.Layer);
entity.LineTypeName = dto.LineType;
return entity;
}
private static Layer ResolveLayer(string name)
{
if (string.IsNullOrEmpty(name) || name == "0")
return Layer.Default;
if (string.Equals(name, SpecialLayers.Cut.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Cut;
if (string.Equals(name, SpecialLayers.Rapid.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Rapid;
if (string.Equals(name, SpecialLayers.Display.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Display;
if (string.Equals(name, SpecialLayers.Leadin.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Leadin;
if (string.Equals(name, SpecialLayers.Leadout.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Leadout;
if (string.Equals(name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Scribe;
return new Layer(name);
}
}
}
+2 -2
View File
@@ -57,7 +57,7 @@ namespace OpenNest.IO
return result;
}
public static List<Geometry.Entity> ToOpenNest(this Spline spline, int precision)
public static List<Geometry.Entity> ToOpenNest(this Spline spline)
{
var layer = spline.Layer.ToOpenNest();
var color = spline.ResolveColor();
@@ -67,7 +67,7 @@ namespace OpenNest.IO
List<XYZ> curvePoints;
try
{
curvePoints = spline.PolygonalVertexes(precision > 0 ? precision : 200);
curvePoints = spline.PolygonalVertexes(200);
}
catch (Exception ex)
{
+29
View File
@@ -162,6 +162,35 @@ namespace OpenNest.IO
public double Cost { get; init; }
}
public record EntitySetDto
{
public List<EntityDto> Entities { get; init; } = new();
public List<string> Suppressed { get; init; } = new();
}
public record EntityDto
{
public string Id { get; init; } = "";
public string Type { get; init; } = "";
public string Layer { get; init; } = "";
public string LineType { get; init; } = "";
// Line
public double X1 { get; init; }
public double Y1 { get; init; }
public double X2 { get; init; }
public double Y2 { get; init; }
// Arc / Circle
public double CX { get; init; }
public double CY { get; init; }
public double R { get; init; }
public double StartAngle { get; init; }
public double EndAngle { get; init; }
public bool Reversed { get; init; }
public string Rotation { get; init; } = "";
}
public record BestFitSetDto
{
public double PlateWidth { get; init; }
+85 -2
View File
@@ -36,7 +36,8 @@ namespace OpenNest.IO
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
var programs = ReadPrograms(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs);
var entitySets = ReadEntitySets(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs, entitySets);
ReadBestFits(drawingMap);
var nest = BuildNest(dto, drawingMap);
@@ -70,11 +71,87 @@ namespace OpenNest.IO
var reader = new ProgramReader(memStream);
programs[i] = reader.Read();
// Read sub-programs if present
var subsEntry = zipArchive.GetEntry($"programs/program-{i}-subs");
if (subsEntry != null)
{
using var subsStream = subsEntry.Open();
ReadSubPrograms(programs[i], subsStream);
}
}
return programs;
}
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs)
private static void ReadSubPrograms(Program parent, Stream stream)
{
using var reader = new StreamReader(stream);
var currentId = -1;
var lines = new List<string>();
string line;
while ((line = reader.ReadLine()) != null)
{
var trimmed = line.Trim();
if (trimmed.StartsWith(":") && int.TryParse(trimmed.Substring(1), out var id))
{
// Flush previous sub-program
if (currentId >= 0 && lines.Count > 0)
parent.SubPrograms[currentId] = ParseSubProgram(lines);
currentId = id;
lines.Clear();
}
else if (trimmed == "M99")
{
if (currentId >= 0 && lines.Count > 0)
parent.SubPrograms[currentId] = ParseSubProgram(lines);
currentId = -1;
lines.Clear();
}
else
{
lines.Add(trimmed);
}
}
// Wire up SubProgramCall.Program references
foreach (var code in parent.Codes)
{
if (code is SubProgramCall call && parent.SubPrograms.TryGetValue(call.Id, out var sub))
call.Program = sub;
}
}
private static Program ParseSubProgram(List<string> lines)
{
var text = string.Join("\n", lines);
var memStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
var reader = new ProgramReader(memStream);
return reader.Read();
}
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(int count)
{
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
for (var i = 1; i <= count; i++)
{
var entry = zipArchive.GetEntry($"entities/entities-{i}");
if (entry == null) continue;
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
var json = reader.ReadToEnd();
var dto = JsonSerializer.Deserialize<EntitySetDto>(json, JsonOptions);
result[i] = EntitySerializer.FromDto(dto);
}
return result;
}
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs,
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> entitySets)
{
var map = new Dictionary<int, Drawing>();
foreach (var d in dto.Drawings)
@@ -112,6 +189,12 @@ namespace OpenNest.IO
if (programs.TryGetValue(d.Id, out var pgm))
drawing.Program = pgm;
if (entitySets.TryGetValue(d.Id, out var entitySet))
{
drawing.SourceEntities = entitySet.entities;
drawing.SuppressedEntityIds = entitySet.suppressed;
}
map[d.Id] = drawing;
}
return map;
+47 -2
View File
@@ -41,6 +41,7 @@ namespace OpenNest.IO
WriteNestJson(zipArchive);
WritePrograms(zipArchive);
WriteEntities(zipArchive);
WriteBestFits(zipArchive);
return true;
@@ -307,9 +308,51 @@ namespace OpenNest.IO
WriteDrawing(stream, kvp.Value);
var entry = zipArchive.CreateEntry(name);
using var entryStream = entry.Open();
using (var entryStream = entry.Open())
{
stream.CopyTo(entryStream);
}
// Write sub-programs if present
if (kvp.Value.Program.SubPrograms.Count > 0)
WriteSubPrograms(zipArchive, kvp.Key, kvp.Value.Program.SubPrograms);
}
}
private void WriteSubPrograms(ZipArchive zipArchive, int drawingId, Dictionary<int, Program> subPrograms)
{
var entry = zipArchive.CreateEntry($"programs/program-{drawingId}-subs");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
foreach (var kvp in subPrograms.OrderBy(k => k.Key))
{
writer.WriteLine($":{kvp.Key}");
writer.WriteLine(kvp.Value.Mode == Mode.Absolute ? "G90" : "G91");
foreach (var code in kvp.Value.Codes)
writer.WriteLine(GetCodeString(code));
writer.WriteLine("M99");
}
}
private void WriteEntities(ZipArchive zipArchive)
{
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{
var drawing = kvp.Value;
if (drawing.SourceEntities == null || drawing.SourceEntities.Count == 0)
continue;
var dto = EntitySerializer.ToDto(drawing.SourceEntities, drawing.SuppressedEntityIds);
var json = JsonSerializer.Serialize(dto, JsonOptions);
var entry = zipArchive.CreateEntry($"entities/entities-{kvp.Key}");
using var stream = entry.Open();
using var writer = new StreamWriter(stream, Encoding.UTF8);
writer.Write(json);
}
}
private void WriteDrawing(Stream stream, Drawing drawing)
@@ -429,7 +472,9 @@ namespace OpenNest.IO
case CodeType.SubProgramCall:
{
var subProgramCall = (SubProgramCall)code;
break;
var x = System.Math.Round(subProgramCall.Offset.X, OutputPrecision).ToString(CoordinateFormat);
var y = System.Math.Round(subProgramCall.Offset.Y, OutputPrecision).ToString(CoordinateFormat);
return $"G65P{subProgramCall.Id}X{x}Y{y}";
}
}
+16 -1
View File
@@ -374,6 +374,8 @@ namespace OpenNest.IO
{
var p = 0;
var r = 0.0;
var x = 0.0;
var y = 0.0;
while (section == CodeSection.SubProgram)
{
@@ -395,13 +397,26 @@ namespace OpenNest.IO
r = double.Parse(code.Value);
break;
case 'X':
x = double.Parse(code.Value);
break;
case 'Y':
y = double.Parse(code.Value);
break;
default:
section = CodeSection.Unknown;
break;
}
}
program.Codes.Add(new SubProgramCall() { Id = p, Rotation = r });
program.Codes.Add(new SubProgramCall
{
Id = p,
Rotation = r,
Offset = new Geometry.Vector(x, y)
});
}
private Code GetNextCode()
+10 -21
View File
@@ -1,6 +1,4 @@
using ModelContextProtocol.Server;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Shapes;
using System.ComponentModel;
@@ -96,27 +94,18 @@ namespace OpenNest.Mcp.Tools
if (!File.Exists(path))
return $"Error: file not found: {path}";
var importer = new DxfImporter();
if (!importer.GetGeometry(path, out var geometry))
return "Error: failed to read DXF file";
if (geometry.Count == 0)
return "Error: no geometry found in DXF file";
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm == null)
return "Error: failed to convert geometry to program";
var drawingName = name ?? Path.GetFileNameWithoutExtension(path);
var drawing = new Drawing(drawingName, pgm);
drawing.Color = Drawing.GetNextColor();
try
{
var drawing = CadImporter.ImportDrawing(path, new CadImportOptions { Name = name });
_session.Drawings.Add(drawing);
var bbox = pgm.BoundingBox();
return $"Imported drawing '{drawingName}': bbox={bbox.Width:F2} x {bbox.Length:F2}";
var bbox = drawing.Program.BoundingBox();
return $"Imported drawing '{drawing.Name}': bbox={bbox.Width:F2} x {bbox.Length:F2}";
}
catch (System.Exception ex)
{
return $"Error: failed to import '{path}': {ex.Message}";
}
}
[McpServerTool(Name = "create_drawing")]
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
@@ -15,11 +16,16 @@ public sealed class CincinnatiPartSubprogramWriter
{
private readonly CincinnatiPostConfig _config;
private readonly CincinnatiFeatureWriter _featureWriter;
private readonly CoordinateFormatter _fmt;
private readonly Dictionary<int, int> _holeSubprograms;
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config)
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config,
Dictionary<int, int> holeSubprograms = null)
{
_config = config;
_featureWriter = new CincinnatiFeatureWriter(config);
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_holeSubprograms = holeSubprograms;
}
/// <summary>
@@ -43,6 +49,15 @@ public sealed class CincinnatiPartSubprogramWriter
for (var i = 0; i < ordered.Count; i++)
{
var (codes, isEtch) = ordered[i];
var isLastFeature = i == ordered.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
continue;
}
var featureNumber = i == 0
? _config.FeatureLineNumberStart
: 1000 + i + 1;
@@ -54,7 +69,7 @@ public sealed class CincinnatiPartSubprogramWriter
FeatureNumber = featureNumber,
PartName = drawingName,
IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = i == ordered.Count - 1,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = false,
IsExteriorFeature = false,
IsEtch = isEtch,
@@ -69,6 +84,30 @@ public sealed class CincinnatiPartSubprogramWriter
w.WriteLine($"M99 (END OF {drawingName})");
}
private void WriteHoleSubprogramCall(TextWriter w, SubProgramCall call,
int featureIndex, bool isLastFeature)
{
var postSubNum = _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
? num : call.Id;
var featureNumber = featureIndex == 0
? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1;
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
w.WriteLine(sb.ToString());
w.WriteLine($"M98 P{postSubNum}");
w.WriteLine("G52 X0 Y0");
if (!isLastFeature)
w.WriteLine("M47");
}
/// <summary>
/// If the program has no leading rapid, inserts a synthetic rapid at the
/// last motion endpoint (the contour return point). This ensures the feature
@@ -136,4 +175,61 @@ public sealed class CincinnatiPartSubprogramWriter
return (mapping, entries);
}
/// <summary>
/// Scans all parts across all plates and builds a nest-level registry of unique
/// hole sub-programs. Deduplicates by comparing sub-program code content.
/// </summary>
internal static (Dictionary<int, int> modelToPostMapping, List<(int subNum, Program program)> entries)
BuildHoleRegistry(IEnumerable<Plate> plates, int startNumber)
{
var mapping = new Dictionary<int, int>();
var entries = new List<(int, Program)>();
var contentIndex = new Dictionary<string, int>();
var nextSubNum = startNumber;
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff) continue;
foreach (var code in part.Program.Codes)
{
if (code is not SubProgramCall call) continue;
if (mapping.ContainsKey(call.Id)) continue;
var canonical = ProgramToCanonical(call.Program);
if (contentIndex.TryGetValue(canonical, out var existingNum))
{
mapping[call.Id] = existingNum;
}
else
{
var subNum = nextSubNum++;
mapping[call.Id] = subNum;
contentIndex[canonical] = subNum;
entries.Add((subNum, call.Program));
}
}
}
}
return (mapping, entries);
}
private static string ProgramToCanonical(Program pgm)
{
var sb = new StringBuilder();
sb.Append(pgm.Mode == Mode.Absolute ? "A" : "I");
foreach (var code in pgm.Codes)
{
if (code is LinearMove lm)
sb.Append($"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}");
else if (code is ArcMove am)
sb.Append($"A{am.EndPoint.X:F6},{am.EndPoint.Y:F6},{am.CenterPoint.X:F6},{am.CenterPoint.Y:F6},{(int)am.Rotation},{(int)am.Layer}");
else if (code is RapidMove rm)
sb.Append($"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
}
return sb.ToString();
}
}
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
@@ -277,6 +279,24 @@ namespace OpenNest.Posts.Cincinnati
[DisplayName("Etch Libraries")]
[Description("Gas-to-library mapping for etch operations.")]
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
[Category("B. Libraries")]
[DisplayName("Selected Library")]
[Description("Overrides Material/Thickness/Gas auto-resolution. Pick an existing entry from Material Libraries, or leave blank to auto-resolve.")]
[TypeConverter(typeof(MaterialLibraryNameConverter))]
public string SelectedLibrary { get; set; } = "";
public string FindBestLibrary(string materialName, double thickness)
{
if (MaterialLibraries == null || string.IsNullOrEmpty(materialName))
return "";
return MaterialLibraries
.Where(e => string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase))
.OrderBy(e => System.Math.Abs(e.Thickness - thickness))
.Select(e => e.Library)
.FirstOrDefault() ?? "";
}
}
public class MaterialLibraryEntry
@@ -9,7 +9,7 @@ using OpenNest.CNC;
namespace OpenNest.Posts.Cincinnati
{
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor, IPostProcessorNestAware, IMaterialProvidingPostProcessor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -25,6 +25,23 @@ namespace OpenNest.Posts.Cincinnati
object IConfigurablePostProcessor.Config => Config;
public IEnumerable<string> GetMaterialNames()
{
if (Config?.MaterialLibraries == null)
return System.Array.Empty<string>();
return Config.MaterialLibraries
.Select(e => e.Material)
.Where(s => !string.IsNullOrWhiteSpace(s));
}
public void PrepareForNest(Nest nest)
{
var materialName = nest?.Material?.Name ?? "";
var thickness = nest?.Thickness ?? 0.0;
Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness);
}
public CincinnatiPostProcessor()
{
var configPath = GetConfigPath();
@@ -89,9 +106,15 @@ namespace OpenNest.Posts.Cincinnati
if (Config.UsePartSubprograms)
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
// 5b. Build hole sub-program registry (SubProgramCalls across all parts)
var holeStartNumber = Config.PartSubprogramStart
+ (subprogramEntries?.Count ?? 0);
var (holeMapping, holeEntries) = CincinnatiPartSubprogramWriter.BuildHoleRegistry(plates, holeStartNumber);
// 6. Create writers
var preamble = new CincinnatiPreambleWriter(Config);
var sheetWriter = new CincinnatiSheetWriter(Config, vars);
var sheetWriter = new CincinnatiSheetWriter(Config, vars,
holeMapping.Count > 0 ? holeMapping : null);
// 7. Build material description from nest
var material = nest.Material;
@@ -122,7 +145,8 @@ namespace OpenNest.Posts.Cincinnati
// Part sub-programs (if enabled)
if (subprogramEntries != null)
{
var partSubWriter = new CincinnatiPartSubprogramWriter(Config);
var partSubWriter = new CincinnatiPartSubprogramWriter(Config,
holeMapping.Count > 0 ? holeMapping : null);
var sheetDiagonal = firstPlate != null
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length)
@@ -135,6 +159,23 @@ namespace OpenNest.Posts.Cincinnati
}
}
// Hole sub-programs (SubProgramCall definitions)
if (holeEntries.Count > 0)
{
var holeSubWriter = new CincinnatiPartSubprogramWriter(Config);
var sheetDiagonal = firstPlate != null
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length)
: 100.0;
foreach (var (subNum, pgm) in holeEntries)
{
CincinnatiPartSubprogramWriter.EnsureLeadingRapid(pgm);
holeSubWriter.Write(writer, pgm, "HOLE", subNum,
initialCutLibrary, etchLibrary, sheetDiagonal);
}
}
writer.Flush();
}
@@ -17,13 +17,16 @@ public sealed class CincinnatiSheetWriter
private readonly ProgramVariableManager _vars;
private readonly CoordinateFormatter _fmt;
private readonly CincinnatiFeatureWriter _featureWriter;
private readonly Dictionary<int, int> _holeSubprograms;
public CincinnatiSheetWriter(CincinnatiPostConfig config, ProgramVariableManager vars)
public CincinnatiSheetWriter(CincinnatiPostConfig config, ProgramVariableManager vars,
Dictionary<int, int> holeSubprograms = null)
{
_config = config;
_vars = vars;
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_featureWriter = new CincinnatiFeatureWriter(config);
_holeSubprograms = holeSubprograms;
}
/// <summary>
@@ -132,11 +135,21 @@ public sealed class CincinnatiSheetWriter
for (var f = 0; f < features.Count; f++)
{
var (codes, isEtch) = features[f];
var isLastFeature = isLastPart && f == features.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, featureIndex, isLastFeature);
featureIndex++;
lastPartName = partName;
continue;
}
var featureNumber = featureIndex == 0
? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1;
var isLastFeature = isLastPart && f == features.Count - 1;
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext
@@ -204,6 +217,36 @@ public sealed class CincinnatiSheetWriter
w.WriteLine("M47");
}
private void WriteHoleSubprogramCall(TextWriter w, SubProgramCall call, int featureIndex, bool isLastFeature)
{
var postSubNum = _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
? num : call.Id;
var featureNumber = featureIndex == 0
? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1;
// Shift the local origin to the hole center via G52 (manual §1.52).
// G52 does not move the nozzle, so the sub-program's first rapid
// (the lead-in to the pierce point) takes the tool straight from the
// previous feature's end to pierce. The hole sub-program is authored
// in hole-local coordinates and resolves to `hole + local` under the
// shift. See docs/cincinnati-post-output.md for the full bracket.
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
w.WriteLine(sb.ToString());
w.WriteLine($"M98 P{postSubNum}");
// Cancel the local shift (manual §1.52).
w.WriteLine("G52 X0 Y0");
if (!isLastFeature)
w.WriteLine("M47");
}
private void WritePartsInline(TextWriter w, List<Part> allParts,
string cutLibrary, string etchLibrary, double sheetDiagonal,
double plateWidth, double plateLength,
@@ -228,6 +271,14 @@ public sealed class CincinnatiSheetWriter
var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
var isLastFeature = i == features.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
lastPartName = partName;
continue;
}
var featureNumber = i == 0
? _config.FeatureLineNumberStart
: 1000 + i + 1;
+10 -1
View File
@@ -21,7 +21,16 @@ public static class FeatureUtils
foreach (var code in codes)
{
if (code is RapidMove)
if (code is SubProgramCall)
{
// Flush any pending feature
if (current != null)
features.Add(current);
// SubProgramCall is its own feature
features.Add(new List<ICode> { code });
current = null;
}
else if (code is RapidMove)
{
if (current != null)
features.Add(current);
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
public sealed class MaterialLibraryNameConverter : StringConverter
{
public override bool GetStandardValuesSupported(ITypeDescriptorContext context) => true;
public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) => false;
public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
{
var config = context?.Instance as CincinnatiPostConfig;
var names = new List<string> { "" };
if (config?.MaterialLibraries != null)
{
names.AddRange(config.MaterialLibraries
.Select(e => e.Library)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
}
return new StandardValuesCollection(names);
}
}
}
@@ -10,15 +10,20 @@ public sealed class MaterialLibraryResolver
private readonly List<MaterialLibraryEntry> _materialLibraries;
private readonly List<EtchLibraryEntry> _etchLibraries;
private readonly string _selectedLibrary;
public MaterialLibraryResolver(CincinnatiPostConfig config)
{
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
_selectedLibrary = config.SelectedLibrary ?? "";
}
public string ResolveCutLibrary(string materialName, double thickness, string gas)
{
if (!string.IsNullOrEmpty(_selectedLibrary))
return EnsureLibExtension(_selectedLibrary);
var entry = _materialLibraries.FirstOrDefault(e =>
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) &&
System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance &&
@@ -6,11 +6,19 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="OpenNest.Posts.Cincinnati.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="CopyToPostsDir" AfterTargets="Build">
<PropertyGroup>
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
<ConfigJson>$(MSBuildProjectDirectory)\OpenNest.Posts.Cincinnati.json</ConfigJson>
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.Cincinnati.json</DeployedConfigJson>
</PropertyGroup>
<MakeDir Directories="$(PostsDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
<Copy SourceFiles="$(ConfigJson)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" Condition="!Exists('$(DeployedConfigJson)')" />
</Target>
</Project>
@@ -0,0 +1,163 @@
{
"ConfigurationName": "CL940",
"PostedUnits": "Inches",
"PostedAccuracy": 4,
"UseLineNumbers": true,
"FeatureLineNumberStart": 1,
"UseSheetSubprograms": true,
"SheetSubprogramStart": 101,
"UsePartSubprograms": false,
"PartSubprogramStart": 200,
"VariableDeclarationSubprogram": 100,
"CoordModeBetweenParts": "G92",
"ProcessParameterMode": "LibraryFile",
"DefaultAssistGas": "O2",
"DefaultEtchGas": "N2",
"UseExactStopMode": false,
"UseSpeedGas": false,
"UseAntiDive": true,
"UseSmartRapids": false,
"KerfCompensation": "ControllerSide",
"DefaultKerfSide": "Left",
"InteriorM47": "Always",
"ExteriorM47": "Always",
"M47OverrideDistanceThreshold": null,
"SafetyHeadraiseDistance": 2000,
"PalletExchange": "EndOfSheet",
"LeadInFeedratePercent": 0.5,
"LeadInArcLine2FeedratePercent": 0.5,
"LeadOutFeedratePercent": 0.5,
"CircleFeedrateMultiplier": 0.8,
"ArcFeedrate": "None",
"ArcFeedrateRanges": [
{ "MaxRadius": 0.125, "FeedratePercent": 0.25, "VariableNumber": 123 },
{ "MaxRadius": 0.75, "FeedratePercent": 0.5, "VariableNumber": 124 },
{ "MaxRadius": 4.5, "FeedratePercent": 0.8, "VariableNumber": 125 }
],
"UserVariableStart": 200,
"SheetWidthVariable": 110,
"SheetLengthVariable": 111,
"MaterialLibraries": [
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "AIR", "Library": "AL032AIR" },
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "N2", "Library": "AL032N2" },
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "O2", "Library": "AL032O2" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "AIR", "Library": "AL050AIR" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "N2", "Library": "AL050N2" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "O2", "Library": "AL050O2" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "AIR", "Library": "AL063AIR" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "N2", "Library": "AL063N2" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "O2", "Library": "AL063O2" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "AIR", "Library": "AL080AIR" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "N2", "Library": "AL080N2" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "O2", "Library": "AL080O2" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "AIR", "Library": "AL090AIR" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "N2", "Library": "AL090N2" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "O2", "Library": "AL090O2" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "AIR", "Library": "AL100AIR" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "N2", "Library": "AL100N2" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "O2", "Library": "AL100O2" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "AIR", "Library": "AL125AIR" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "N2", "Library": "AL125N2" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "O2", "Library": "AL125O2" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "AIR", "Library": "AL190AIR" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "N2", "Library": "AL190N2" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "O2", "Library": "AL190O2" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "AIR", "Library": "AL250AIR" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "N2", "Library": "AL250N2" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "O2", "Library": "AL250O2" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "AIR", "Library": "AL375AIR" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "N2", "Library": "AL375N2" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "O2", "Library": "AL375O2" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "AIR", "Library": "AL500AIR" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "N2", "Library": "AL500N2" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "O2", "Library": "AL500O2" },
{ "Material": "Aluminum", "Thickness": 0.625, "Gas": "N2", "Library": "AL625N2" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "AIR", "Library": "AL750AIR" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "N2", "Library": "AL750N2" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "O2", "Library": "AL750O2" },
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "AIR", "Library": "AL1000AIR" },
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "N2", "Library": "AL1000N2" },
{ "Material": "Galvanized Steel", "Thickness": 0.135, "Gas": "N2", "Library": "GALV135N2" },
{ "Material": "Galvanized Steel", "Thickness": 0.188, "Gas": "N2", "Library": "GALV188N2" },
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "MS036AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "N2", "Library": "MS036N2" },
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "MS048AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "N2", "Library": "MS048N2" },
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "MS060AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "N2", "Library": "MS060N2" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "MS075AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.090, "Gas": "N2", "Library": "MS090N2" },
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "MS105AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "N2", "Library": "MS105N2" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "MS120AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "MS135AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2Panel" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "MS188AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2FLOORPLATE" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "O2", "Library": "MS188O2" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "MS250AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2FLOORPLATE" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "O2", "Library": "MS250O2" },
{ "Material": "Carbon Steel", "Thickness": 0.313, "Gas": "O2", "Library": "MS313O2" },
{ "Material": "Carbon Steel", "Thickness": 0.375, "Gas": "O2", "Library": "MS375O2" },
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "N2", "Library": "MS500N2" },
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "O2", "Library": "MS500O2" },
{ "Material": "Carbon Steel", "Thickness": 0.625, "Gas": "O2", "Library": "MS625O2" },
{ "Material": "Carbon Steel", "Thickness": 0.750, "Gas": "O2", "Library": "MS750O2" },
{ "Material": "Carbon Steel", "Thickness": 1.000, "Gas": "O2", "Library": "MS1000O2" },
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "SS036AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "N2", "Library": "SS036N2" },
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "SS048AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "N2", "Library": "SS048N2" },
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "SS060AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "N2", "Library": "SS060N2" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "SS075AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "SS105AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "SS120AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "SS135AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "SS188AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "N2", "Library": "SS188N2" },
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "SS250AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "N2", "Library": "SS250N2" },
{ "Material": "Stainless Steel", "Thickness": 0.313, "Gas": "N2", "Library": "SS313N2" },
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "AIR", "Library": "SS375AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "N2", "Library": "SS375N2" },
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "AIR", "Library": "SS500AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "N2", "Library": "SS500N2" },
{ "Material": "Stainless Steel", "Thickness": 0.625, "Gas": "N2", "Library": "SS625N2" },
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "AIR", "Library": "SS750AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "N2", "Library": "SS750N2" },
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "AIR", "Library": "SS1000AIR" },
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "N2", "Library": "SS1000N2" },
{ "Material": "Phenolic", "Thickness": 0.0, "Gas": "", "Library": "Phenolic" },
{ "Material": "Gasket", "Thickness": 0.250, "Gas": "N2", "Library": "GASKET250N2" }
],
"EtchLibraries": [
{ "Gas": "AIR", "Library": "EtchAIR" },
{ "Gas": "N2", "Library": "EtchN2" },
{ "Gas": "N2", "Library": "EtchN2_fast" },
{ "Gas": "N2", "Library": "Etchn2_no_mark_pvc" },
{ "Gas": "O2", "Library": "EtchO2" },
{ "Gas": "O2", "Library": "ETCHO2FINE" }
]
}
+1 -2
View File
@@ -70,8 +70,7 @@ public class NestRunnerTests
var pgm = ConvertGeometry.ToProgram(shape);
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
var exporter = new DxfExporter();
exporter.ExportProgram(pgm, path);
Dxf.ExportProgram(pgm, path);
return path;
}
@@ -35,8 +35,7 @@ public class SolidWorksBendDetectorTests
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11 Test.dxf");
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 };
var result = importer.Import(path);
var result = OpenNest.IO.Dxf.Import(path);
// EllipseConverter now produces arcs directly during import,
// so the imported entities should contain Arc instances from the ellipses
@@ -61,8 +60,7 @@ public class SolidWorksBendDetectorTests
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11.dxf");
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
var importer = new OpenNest.IO.DxfImporter();
var result = importer.Import(path);
var result = OpenNest.IO.Dxf.Import(path);
// The DXF has 2 trimmed ellipses forming an oblong slot.
// Trimmed ellipses must not generate a closing chord line.
@@ -32,8 +32,7 @@ public class BestFitOverlapTests
if (!File.Exists(DxfPath))
return null;
var importer = new DxfImporter();
importer.GetGeometry(DxfPath, out var geometry);
var geometry = Dxf.GetGeometry(DxfPath);
var pgm = ConvertGeometry.ToProgram(geometry);
return new Drawing("PT16", pgm);
}
@@ -0,0 +1,55 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.Converters;
public class SubProgramExpansionTests
{
[Fact]
public void ToGeometry_ExpandsSubProgramCall_WithOffset()
{
// Sub-program: a small line relative to (0,0)
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new LinearMove(0.5, 0));
// Main program: call sub at offset (10,20)
var main = new Program(Mode.Absolute);
main.SubPrograms[1] = sub;
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(10, 20) });
var geometry = ConvertProgram.ToGeometry(main);
// The sub-program's line should be offset by (10,20)
// Sub emits incremental (0.5,0) from current position.
// Since offset is (10,20), the line goes from (10,20) to (10.5,20).
Assert.True(geometry.Count > 0);
var line = geometry.OfType<Line>().FirstOrDefault();
Assert.NotNull(line);
Assert.Equal(10.5, line.EndPoint.X, 4);
Assert.Equal(20, line.EndPoint.Y, 4);
}
[Fact]
public void ToGeometry_MultipleSubProgramCalls_DifferentOffsets()
{
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new LinearMove(1, 0));
var main = new Program(Mode.Absolute);
main.SubPrograms[1] = sub;
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(0, 0) });
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(5, 5) });
var geometry = ConvertProgram.ToGeometry(main);
var lines = geometry.OfType<Line>().ToList();
Assert.Equal(2, lines.Count);
// First call at (0,0): line from (0,0) to (1,0)
Assert.Equal(1, lines[0].EndPoint.X, 4);
Assert.Equal(0, lines[0].EndPoint.Y, 4);
// Second call at (5,5): line from (5,5) to (6,5)
Assert.Equal(6, lines[1].EndPoint.X, 4);
Assert.Equal(5, lines[1].EndPoint.Y, 4);
}
}
@@ -0,0 +1,338 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Converters;
using OpenNest.Geometry;
using System.Linq;
namespace OpenNest.Tests.CuttingStrategy;
public class HoleSubProgramTests
{
[Fact]
public void SubProgramCall_Offset_DefaultsToZero()
{
var call = new SubProgramCall();
Assert.Equal(0, call.Offset.X);
Assert.Equal(0, call.Offset.Y);
}
[Fact]
public void SubProgramCall_Offset_StoresValue()
{
var call = new SubProgramCall { Offset = new Vector(1.5, 2.5) };
Assert.Equal(1.5, call.Offset.X);
Assert.Equal(2.5, call.Offset.Y);
}
[Fact]
public void SubProgramCall_Clone_CopiesOffset()
{
var call = new SubProgramCall { Id = 1, Offset = new Vector(3, 4) };
var clone = (SubProgramCall)call.Clone();
Assert.Equal(3, clone.Offset.X);
Assert.Equal(4, clone.Offset.Y);
Assert.Equal(1, clone.Id);
}
[Fact]
public void SubProgramCall_ToString_IncludesOffset()
{
var call = new SubProgramCall { Id = 1000, Offset = new Vector(1.5, 2.5) };
var str = call.ToString();
Assert.Contains("P1000", str);
Assert.Contains("X1.5", str);
Assert.Contains("Y2.5", str);
}
[Fact]
public void SubProgramCall_ToString_IncludesOffsetAndRotation()
{
var call = new SubProgramCall { Id = 1000, Offset = new Vector(1.5, 2.5), Rotation = 30 };
var str = call.ToString();
Assert.Contains("P1000", str);
Assert.Contains("X1.5", str);
Assert.Contains("Y2.5", str);
Assert.Contains("R30", str);
}
[Fact]
public void SubProgramCall_ToString_OmitsZeroFields()
{
var call = new SubProgramCall { Id = 1000 };
var str = call.ToString();
Assert.Equal("G65 P1000", str);
}
[Fact]
public void Program_SubPrograms_EmptyByDefault()
{
var pgm = new Program();
Assert.NotNull(pgm.SubPrograms);
Assert.Empty(pgm.SubPrograms);
}
[Fact]
public void Program_SubPrograms_StoresAndRetrieves()
{
var pgm = new Program();
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new LinearMove(0.1, 0.2));
pgm.SubPrograms[1] = sub;
Assert.Single(pgm.SubPrograms);
Assert.Same(sub, pgm.SubPrograms[1]);
}
[Fact]
public void Program_Clone_DeepCopiesSubPrograms()
{
var pgm = new Program();
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new LinearMove(0.1, 0.2));
pgm.SubPrograms[1] = sub;
var clone = (Program)pgm.Clone();
Assert.Single(clone.SubPrograms);
Assert.NotSame(sub, clone.SubPrograms[1]);
Assert.Equal(Mode.Incremental, clone.SubPrograms[1].Mode);
}
[Fact]
public void Apply_CircleHole_EmitsSubProgramCall()
{
// Create a program with a square perimeter and a circle hole at (5, 5) radius 0.5
var pgm = new Program(Mode.Absolute);
// Square perimeter
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(0, 10));
pgm.Codes.Add(new LinearMove(10, 10));
pgm.Codes.Add(new LinearMove(10, 0));
pgm.Codes.Add(new LinearMove(0, 0));
// Circle hole at (5, 5) radius 0.5
pgm.Codes.Add(new RapidMove(5.5, 5));
pgm.Codes.Add(new ArcMove(new Vector(5.5, 5), new Vector(5, 5), RotationType.CW));
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
ArcCircleLeadOut = new NoLeadOut()
}
};
var result = strategy.Apply(pgm, new Vector(10, 10));
// Should contain at least one SubProgramCall
var calls = result.Program.Codes.OfType<SubProgramCall>().ToList();
Assert.Single(calls);
// The call's offset should be approximately at the hole center (5, 5)
var call = calls[0];
Assert.Equal(5, call.Offset.X, 1);
Assert.Equal(5, call.Offset.Y, 1);
// The parent program should have a sub-program registered
Assert.True(result.Program.SubPrograms.ContainsKey(call.Id));
}
[Fact]
public void Apply_TwoIdenticalCircles_ShareSubProgram()
{
// Square perimeter with two identical circle holes at different positions
var pgm = new Program(Mode.Absolute);
// Square perimeter
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(0, 10));
pgm.Codes.Add(new LinearMove(10, 10));
pgm.Codes.Add(new LinearMove(10, 0));
pgm.Codes.Add(new LinearMove(0, 0));
// Circle 1 at (2, 2) radius 0.5
pgm.Codes.Add(new RapidMove(2.5, 2));
pgm.Codes.Add(new ArcMove(new Vector(2.5, 2), new Vector(2, 2), RotationType.CW));
// Circle 2 at (6, 6) radius 0.5
pgm.Codes.Add(new RapidMove(6.5, 6));
pgm.Codes.Add(new ArcMove(new Vector(6.5, 6), new Vector(6, 6), RotationType.CW));
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
RoundLeadInAngles = true,
LeadInAngleIncrement = 5.0,
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
ArcCircleLeadOut = new NoLeadOut()
}
};
var result = strategy.Apply(pgm, new Vector(10, 10));
var calls = result.Program.Codes.OfType<SubProgramCall>().ToList();
Assert.Equal(2, calls.Count);
// Both calls should reference the same sub-program ID (same radius, same quantized angle)
Assert.Equal(calls[0].Id, calls[1].Id);
// But different offsets
Assert.NotEqual(calls[0].Offset.X, calls[1].Offset.X);
}
[Fact]
public void Apply_HoleCenters_PreservedInGeometry()
{
// Square perimeter 10x10 with two circle holes at known positions
var holeCenter1 = new Vector(3, 3);
var holeCenter2 = new Vector(7, 5);
var holeRadius = 0.5;
var pgm = new Program(Mode.Absolute);
// Perimeter
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(10, 0));
pgm.Codes.Add(new LinearMove(10, 10));
pgm.Codes.Add(new LinearMove(0, 10));
pgm.Codes.Add(new LinearMove(0, 0));
// Hole 1 at (3, 3)
pgm.Codes.Add(new RapidMove(holeCenter1.X + holeRadius, holeCenter1.Y));
pgm.Codes.Add(new ArcMove(
new Vector(holeCenter1.X + holeRadius, holeCenter1.Y),
holeCenter1, RotationType.CW));
// Hole 2 at (7, 5)
pgm.Codes.Add(new RapidMove(holeCenter2.X + holeRadius, holeCenter2.Y));
pgm.Codes.Add(new ArcMove(
new Vector(holeCenter2.X + holeRadius, holeCenter2.Y),
holeCenter2, RotationType.CW));
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
ArcCircleLeadOut = new NoLeadOut()
}
};
var result = strategy.Apply(pgm, new Vector(10, 10));
// Convert to geometry — this is what PlateView renders
var geometry = ConvertProgram.ToGeometry(result.Program);
var circles = geometry.OfType<Circle>().ToList();
Assert.Equal(2, circles.Count);
// Circle centers must match the original hole positions
var center1 = circles[0].Center;
var center2 = circles[1].Center;
Assert.Equal(holeCenter1.X, center1.X, 2);
Assert.Equal(holeCenter1.Y, center1.Y, 2);
Assert.Equal(holeCenter2.X, center2.X, 2);
Assert.Equal(holeCenter2.Y, center2.Y, 2);
}
[Fact]
public void Part_ApplyLeadIns_HolesAndPerimeter_CorrectPositions()
{
// Build a drawing with a square and two holes
var holeCenter1 = new Vector(3, 3);
var holeCenter2 = new Vector(7, 5);
var holeRadius = 0.5;
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(10, 0));
pgm.Codes.Add(new LinearMove(10, 10));
pgm.Codes.Add(new LinearMove(0, 10));
pgm.Codes.Add(new LinearMove(0, 0));
pgm.Codes.Add(new RapidMove(holeCenter1.X + holeRadius, holeCenter1.Y));
pgm.Codes.Add(new ArcMove(
new Vector(holeCenter1.X + holeRadius, holeCenter1.Y),
holeCenter1, RotationType.CW));
pgm.Codes.Add(new RapidMove(holeCenter2.X + holeRadius, holeCenter2.Y));
pgm.Codes.Add(new ArcMove(
new Vector(holeCenter2.X + holeRadius, holeCenter2.Y),
holeCenter2, RotationType.CW));
var drawing = new Drawing("TestPart") { Program = pgm };
var part = new Part(drawing);
var parameters = new CuttingParameters
{
RoundLeadInAngles = true,
LeadInAngleIncrement = 5.0,
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
ArcCircleLeadOut = new NoLeadOut(),
ExternalLeadIn = new LineLeadIn { Length = 0.25, ApproachAngle = 90 },
ExternalLeadOut = new NoLeadOut()
};
part.ApplyLeadIns(parameters, new Vector(10, 10));
// Convert to geometry — this is what PlateView renders
var geometry = ConvertProgram.ToGeometry(part.Program);
var circles = geometry.OfType<Circle>().ToList();
var lines = geometry.OfType<Line>().Where(l => l.Layer != SpecialLayers.Rapid).ToList();
// Hole circles must be at correct positions
Assert.Equal(2, circles.Count);
Assert.Equal(holeCenter1.X, circles[0].Center.X, 2);
Assert.Equal(holeCenter1.Y, circles[0].Center.Y, 2);
Assert.Equal(holeCenter2.X, circles[1].Center.X, 2);
Assert.Equal(holeCenter2.Y, circles[1].Center.Y, 2);
Assert.Equal(holeRadius, circles[0].Radius, 2);
Assert.Equal(holeRadius, circles[1].Radius, 2);
// Perimeter lines must stay within the original 10x10 bounding box.
// This catches the mode conversion bug where perimeter gets shifted
// by the last hole's position.
foreach (var line in lines)
{
Assert.True(line.StartPoint.X >= -1 && line.StartPoint.X <= 11,
$"Perimeter line start X={line.StartPoint.X} is outside the 10x10 part bounds");
Assert.True(line.StartPoint.Y >= -1 && line.StartPoint.Y <= 11,
$"Perimeter line start Y={line.StartPoint.Y} is outside the 10x10 part bounds");
Assert.True(line.EndPoint.X >= -1 && line.EndPoint.X <= 11,
$"Perimeter line end X={line.EndPoint.X} is outside the 10x10 part bounds");
Assert.True(line.EndPoint.Y >= -1 && line.EndPoint.Y <= 11,
$"Perimeter line end Y={line.EndPoint.Y} is outside the 10x10 part bounds");
}
}
[Fact]
public void Program_BoundingBox_IncludesSubProgramOffset()
{
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new LinearMove(1, 0));
var main = new Program(Mode.Absolute);
main.SubPrograms[1] = sub;
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(10, 20) });
var box = main.BoundingBox();
// Sub-program line goes from (10,20) to (11,20)
Assert.True(box.Right >= 11);
Assert.True(box.Top >= 20);
}
[Fact]
public void Program_Rotate_RotatesSubProgramCallOffsets()
{
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new LinearMove(1, 0));
var main = new Program(Mode.Absolute);
main.SubPrograms[1] = sub;
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(10, 0) });
// Rotate 90 degrees CCW around origin
main.Rotate(System.Math.PI / 2);
var call = main.Codes.OfType<SubProgramCall>().First();
// (10, 0) rotated 90 CCW = (0, 10)
Assert.Equal(0, call.Offset.X, 1);
Assert.Equal(10, call.Offset.Y, 1);
}
}
+1 -2
View File
@@ -20,8 +20,7 @@ public class EngineOverlapTests
if (!System.IO.File.Exists(DxfPath))
return null;
var importer = new DxfImporter();
importer.GetGeometry(DxfPath, out var geometry);
var geometry = Dxf.GetGeometry(DxfPath);
var pgm = ConvertGeometry.ToProgram(geometry);
return new Drawing("PT15", pgm);
}
@@ -0,0 +1,411 @@
using OpenNest.Geometry;
using OpenNest.IO;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Xunit;
using Xunit.Abstractions;
namespace OpenNest.Tests.Engine;
public class MultiPlateNesterTests
{
private readonly ITestOutputHelper _output;
public MultiPlateNesterTests(ITestOutputHelper output)
{
_output = output;
}
private static Drawing MakeDrawing(string name, double width, double length)
{
var program = new OpenNest.CNC.Program();
program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(width, 0)));
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(width, length)));
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, length)));
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
var drawing = new Drawing(name, program);
drawing.UpdateArea();
return drawing;
}
private static NestItem MakeItem(string name, double width, double length, int qty = 1)
{
return new NestItem
{
Drawing = MakeDrawing(name, width, length),
Quantity = qty,
};
}
[Fact]
public void SortByBoundingBoxArea_OrdersLargestFirst()
{
var items = new List<NestItem>
{
MakeItem("small", 10, 10),
MakeItem("large", 40, 60),
MakeItem("medium", 20, 30),
};
var sorted = MultiPlateNester.SortItems(items, PartSortOrder.BoundingBoxArea);
Assert.Equal("large", sorted[0].Drawing.Name);
Assert.Equal("medium", sorted[1].Drawing.Name);
Assert.Equal("small", sorted[2].Drawing.Name);
}
[Fact]
public void SortBySize_OrdersByLongestDimension()
{
var items = new List<NestItem>
{
MakeItem("short-wide", 50, 20), // longest = 50
MakeItem("tall-narrow", 10, 80), // longest = 80
MakeItem("square", 30, 30), // longest = 30
};
var sorted = MultiPlateNester.SortItems(items, PartSortOrder.Size);
Assert.Equal("tall-narrow", sorted[0].Drawing.Name);
Assert.Equal("short-wide", sorted[1].Drawing.Name);
Assert.Equal("square", sorted[2].Drawing.Name);
}
// --- Task 4: Part Classification ---
[Fact]
public void Classify_LargePart_WhenWidthExceedsHalfWorkArea()
{
var workArea = new Box(0, 0, 96, 48);
var bb = new Box(0, 0, 50, 20); // width 50 > half of 96 = 48
var result = MultiPlateNester.Classify(bb, workArea);
Assert.Equal(PartClass.Large, result);
}
[Fact]
public void Classify_LargePart_WhenLengthExceedsHalfWorkArea()
{
var workArea = new Box(0, 0, 96, 48);
var bb = new Box(0, 0, 20, 30); // length 30 > half of 48 = 24
var result = MultiPlateNester.Classify(bb, workArea);
Assert.Equal(PartClass.Large, result);
}
[Fact]
public void Classify_MediumPart_NotLargeButAreaAboveThreshold()
{
var workArea = new Box(0, 0, 96, 48);
// workArea = 4608, 1/9 = 512. bb = 40*15 = 600 > 512
// 40 < 48 (half of 96), 15 < 24 (half of 48) — not Large
var bb = new Box(0, 0, 40, 15);
var result = MultiPlateNester.Classify(bb, workArea);
Assert.Equal(PartClass.Medium, result);
}
[Fact]
public void Classify_SmallPart()
{
var workArea = new Box(0, 0, 96, 48);
// workArea = 4608, 1/9 = 512. bb = 10*10 = 100 < 512
var bb = new Box(0, 0, 10, 10);
var result = MultiPlateNester.Classify(bb, workArea);
Assert.Equal(PartClass.Small, result);
}
// --- Task 5: Scrap Zone Identification ---
[Fact]
public void IsScrapRemnant_BothDimensionsBelowThreshold_ReturnsTrue()
{
var remnant = new Box(0, 0, 10, 8);
Assert.True(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
}
[Fact]
public void IsScrapRemnant_OneDimensionAboveThreshold_ReturnsFalse()
{
// 11 x 120 — narrow but long, should be preserved
var remnant = new Box(0, 0, 11, 120);
Assert.False(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
}
[Fact]
public void IsScrapRemnant_BothDimensionsAboveThreshold_ReturnsFalse()
{
var remnant = new Box(0, 0, 20, 30);
Assert.False(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
}
[Fact]
public void FindRemnants_ScrapOnly_ReturnsOnlyScrapRemnants()
{
// 96x48 plate with a 70x40 part placed at origin
var plate = new Plate(96, 48) { PartSpacing = 0.25 };
var drawing = MakeDrawing("big", 70, 40);
var part = new Part(drawing);
plate.Parts.Add(part);
var scrap = MultiPlateNester.FindRemnants(plate, 12.0, scrapOnly: true);
// All returned zones should have both dims < 12
foreach (var zone in scrap)
{
Assert.True(zone.Width < 12.0 && zone.Length < 12.0,
$"Zone {zone.Width:F1}x{zone.Length:F1} is not scrap — at least one dimension >= 12");
}
}
// --- Task 6: Plate Creation Helper ---
[Fact]
public void CreatePlate_UsesTemplateWhenNoOptions()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing { Left = 1, Right = 1, Top = 1, Bottom = 1 };
var plate = MultiPlateNester.CreatePlate(template, null, null);
Assert.Equal(96, plate.Size.Width);
Assert.Equal(48, plate.Size.Length);
Assert.Equal(0.25, plate.PartSpacing);
Assert.Equal(1, plate.Quadrant);
}
[Fact]
public void CreatePlate_PicksSmallestFittingOption()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing { Left = 1, Right = 1, Top = 1, Bottom = 1 };
var options = new List<PlateOption>
{
new() { Width = 48, Length = 96, Cost = 100 },
new() { Width = 60, Length = 120, Cost = 200 },
new() { Width = 72, Length = 144, Cost = 300 },
};
// Part needs 50x50 work area — 48x96 (after edge spacing: 46x94) — 46 < 50, doesn't fit.
// 60x120 (58x118) does fit.
var minBounds = new Box(0, 0, 50, 50);
var plate = MultiPlateNester.CreatePlate(template, options, minBounds);
Assert.Equal(60, plate.Size.Width);
Assert.Equal(120, plate.Size.Length);
}
[Fact]
public void EvaluateUpgrade_PrefersCheaperOption()
{
var currentOption = new PlateOption { Width = 48, Length = 96, Cost = 100 };
var upgradeOption = new PlateOption { Width = 60, Length = 120, Cost = 160 };
var newPlateOption = new PlateOption { Width = 48, Length = 96, Cost = 100 };
// Upgrade cost = 160 - 100 = 60
// New plate cost with 50% utilization, 50% salvage:
// remnantFraction = 0.5, salvageCredit = 0.5 * 100 * 0.5 = 25
// netNewCost = 100 - 25 = 75
// Upgrade (60) < new plate (75), so upgrade wins
var decision = MultiPlateNester.EvaluateUpgradeVsNew(
currentOption, upgradeOption, newPlateOption, 0.5, 0.5);
Assert.True(decision.ShouldUpgrade);
}
// --- Task 7: Main Orchestration ---
[Fact]
public void Nest_LargePartsGetOwnPlates()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
var items = new List<NestItem>
{
MakeItem("big1", 80, 40, 1),
MakeItem("big2", 70, 35, 1),
};
var options = new MultiPlateNestOptions { Template = template };
var result = MultiPlateNester.Nest(items, options);
// Each large part should be on its own plate.
Assert.True(result.Plates.Count >= 2,
$"Expected at least 2 plates, got {result.Plates.Count}");
}
[Fact]
public void Nest_SmallPartsConsolidateOntoSharedPlates()
{
// Small parts should be packed together on shared plates rather than
// each drawing getting its own plate. The consolidation pass fills
// small parts into remaining space on existing plates.
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
var items = new List<NestItem>
{
MakeItem("big", 80, 40, 1),
MakeItem("tinyA", 5, 5, 3),
MakeItem("tinyB", 4, 4, 3),
};
var options = new MultiPlateNestOptions { Template = template };
var result = MultiPlateNester.Nest(items, options);
// Both small drawing types should share space — not each on their own plate.
// With consolidation, they pack into remaining space alongside the big part.
Assert.True(result.Plates.Count <= 2,
$"Expected at most 2 plates (small parts consolidated), got {result.Plates.Count}");
Assert.Equal(0, result.UnplacedItems.Count);
}
[Fact]
public void Nest_RespectsAllowPlateCreation()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
var items = new List<NestItem>
{
MakeItem("big1", 80, 40, 1),
MakeItem("big2", 70, 35, 1),
};
var options = new MultiPlateNestOptions
{
Template = template,
AllowPlateCreation = false,
};
var result = MultiPlateNester.Nest(items, options);
// No existing plates and no plate creation — nothing can be placed.
Assert.Empty(result.Plates);
Assert.Equal(2, result.UnplacedItems.Count);
}
[Fact]
public void Nest_UsesExistingPlates()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
var existingPlate = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
existingPlate.EdgeSpacing = new Spacing();
// Use a part small enough to be classified as Medium on a 96x48 plate.
// Plate WorkArea: Width=96, Length=48. Half: 48, 24.
// Part 24x22: Length=24 (not > 24), Width=22 (not > 48) — not Large.
// Area = 528 > 4608/9 = 512 — Medium.
var items = new List<NestItem>
{
MakeItem("medium", 24, 22, 1),
};
var options = new MultiPlateNestOptions { Template = template };
var result = MultiPlateNester.Nest(items, options,
existingPlates: new List<Plate> { existingPlate });
// Part should be placed on the existing plate, not a new one.
Assert.Single(result.Plates);
Assert.False(result.Plates[0].IsNew);
}
[Fact]
public void Nest_RealNestFile_PartFirst()
{
var nestPath = @"C:\Users\aisaacs\Desktop\4526 A14 - 0.188 AISI 304.nest";
if (!File.Exists(nestPath))
{
_output.WriteLine("SKIP: nest file not found");
return;
}
var nest = new NestReader(nestPath).Read();
var template = nest.PlateDefaults.CreateNew();
_output.WriteLine($"Plate: {template.Size.Width}x{template.Size.Length}, " +
$"spacing={template.PartSpacing}, edge=({template.EdgeSpacing.Left},{template.EdgeSpacing.Bottom},{template.EdgeSpacing.Right},{template.EdgeSpacing.Top})");
var wa = template.WorkArea();
_output.WriteLine($"Work area: {wa.Width:F1}x{wa.Length:F1}");
_output.WriteLine($"Classification thresholds: Large if dim > {wa.Width / 2:F1} or {wa.Length / 2:F1}, " +
$"Medium if area > {wa.Width * wa.Length / 9:F0}");
_output.WriteLine("---");
var items = new List<NestItem>();
foreach (var d in nest.Drawings)
{
var qty = d.Quantity.Required > 0 ? d.Quantity.Required : d.Quantity.Remaining;
if (qty <= 0) qty = 1;
var bb = d.Program.BoundingBox();
var classification = MultiPlateNester.Classify(bb, wa);
_output.WriteLine($" {d.Name,-25} {bb.Width:F1}x{bb.Length:F1} (area={bb.Width * bb.Length:F0}) qty={qty} class={classification}");
items.Add(new NestItem
{
Drawing = d,
Quantity = qty,
StepAngle = d.Constraints.StepAngle,
RotationStart = d.Constraints.StartAngle,
RotationEnd = d.Constraints.EndAngle,
});
}
_output.WriteLine("---");
_output.WriteLine($"Total: {items.Count} drawings, {items.Sum(i => i.Quantity)} parts");
var plateOptions = new List<PlateOption>
{
new() { Width = 48, Length = 96, Cost = 0 },
new() { Width = 48, Length = 120, Cost = 0 },
new() { Width = 48, Length = 144, Cost = 0 },
new() { Width = 60, Length = 96, Cost = 0 },
new() { Width = 60, Length = 120, Cost = 0 },
new() { Width = 60, Length = 144, Cost = 0 },
new() { Width = 72, Length = 96, Cost = 0 },
new() { Width = 72, Length = 120, Cost = 0 },
new() { Width = 72, Length = 144, Cost = 0 },
};
_output.WriteLine($"Plate options: {string.Join(", ", plateOptions.Select(o => $"{o.Width}x{o.Length}"))}");
_output.WriteLine("");
var options = new MultiPlateNestOptions
{
Template = template,
PlateOptions = plateOptions,
};
var result = MultiPlateNester.Nest(items, options);
_output.WriteLine($"=== RESULTS: {result.Plates.Count} plates ===");
for (var i = 0; i < result.Plates.Count; i++)
{
var pr = result.Plates[i];
var groups = pr.Parts.GroupBy(p => p.BaseDrawing.Name)
.Select(g => $"{g.Key} x{g.Count()}")
.ToList();
_output.WriteLine($" Plate {i + 1} ({pr.Plate.Size.Width}x{pr.Plate.Size.Length}): " +
$"{pr.Parts.Count} parts, util={pr.Plate.Utilization():P1} [{string.Join(", ", groups)}]");
}
if (result.UnplacedItems.Count > 0)
{
_output.WriteLine($" Unplaced: {string.Join(", ", result.UnplacedItems.Select(i => $"{i.Drawing.Name} x{i.Quantity}"))}");
}
_output.WriteLine($"\nTotal parts placed: {result.Plates.Sum(p => p.Parts.Count)}");
_output.WriteLine($"Total plates used: {result.Plates.Count}");
}
}
@@ -0,0 +1,130 @@
using OpenNest;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
using Xunit;
using Xunit.Abstractions;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Tests.Fill
{
public class FillLinearCircleTests
{
private readonly ITestOutputHelper _output;
public FillLinearCircleTests(ITestOutputHelper output) => _output = output;
private static Drawing MakeCircleDrawing(double radius)
{
var pgm = new Program();
var startPt = new Vector(radius * 2, radius); // rightmost point
pgm.Codes.Add(new RapidMove(startPt));
pgm.Codes.Add(new ArcMove(startPt, new Vector(radius, radius), RotationType.CCW));
return new Drawing("circle", pgm);
}
private static Drawing MakeRingDrawing(double outerRadius, double innerRadius)
{
var pgm = new Program();
// Outer circle (CCW)
var outerStart = new Vector(outerRadius * 2, outerRadius);
pgm.Codes.Add(new RapidMove(outerStart));
pgm.Codes.Add(new ArcMove(outerStart, new Vector(outerRadius, outerRadius), RotationType.CCW));
// Inner circle (CW = hole)
var innerStart = new Vector(outerRadius + innerRadius, outerRadius);
pgm.Codes.Add(new RapidMove(innerStart));
pgm.Codes.Add(new ArcMove(innerStart, new Vector(outerRadius, outerRadius), RotationType.CW));
return new Drawing("ring", pgm);
}
[Theory]
[InlineData(2.0, 0.125)] // 4" diameter circle, 1/8" spacing
[InlineData(1.0, 0.125)] // 2" diameter circle
[InlineData(3.0, 0.0625)] // 6" diameter circle, 1/16" spacing
[InlineData(0.5, 0.25)] // 1" diameter circle, 1/4" spacing
public void CircleFill_OffsetBoundaries_DoNotOverlap(double radius, double spacing)
{
var drawing = MakeCircleDrawing(radius);
var workArea = new Box(0, 0, 48, 48);
var engine = new FillLinear(workArea, spacing);
var parts = engine.Fill(drawing, 0, NestDirection.Horizontal);
_output.WriteLine($"Circle R={radius}, spacing={spacing}: {parts.Count} parts");
AssertNoOffsetOverlap(parts, spacing, radius * 2);
}
[Theory]
[InlineData(2.0, 1.5, 0.125)] // Ring: outer R=2, inner R=1.5
[InlineData(1.5, 1.0, 0.125)] // Ring: outer R=1.5, inner R=1.0
public void RingFill_OffsetBoundaries_DoNotOverlap(double outerR, double innerR, double spacing)
{
var drawing = MakeRingDrawing(outerR, innerR);
var workArea = new Box(0, 0, 48, 48);
var engine = new FillLinear(workArea, spacing);
var parts = engine.Fill(drawing, 0, NestDirection.Horizontal);
_output.WriteLine($"Ring outerR={outerR}, innerR={innerR}, spacing={spacing}: {parts.Count} parts");
AssertNoOffsetOverlap(parts, spacing, outerR * 2);
}
private void AssertNoOffsetOverlap(List<Part> parts, double spacing, double expectedDiameter)
{
if (parts.Count < 2)
{
_output.WriteLine(" Only 1 part placed, skipping overlap check");
return;
}
var halfSpacing = spacing / 2;
var radius = expectedDiameter / 2;
var minGap = double.MaxValue;
var violationCount = 0;
// For circular parts, the center is at Location + (radius, radius).
for (var i = 0; i < parts.Count; i++)
{
var ci = parts[i].Location + new Vector(radius, radius);
for (var j = i + 1; j < parts.Count; j++)
{
var cj = parts[j].Location + new Vector(radius, radius);
var centerDist = ci.DistanceTo(cj);
// Gap between raw circle perimeters
var rawGap = centerDist - expectedDiameter;
// Gap between offset circle perimeters (halfSpacing each side)
var offsetGap = centerDist - expectedDiameter - spacing;
if (rawGap < minGap)
minGap = rawGap;
if (rawGap < spacing - Tolerance.Epsilon)
{
violationCount++;
if (violationCount <= 5)
{
_output.WriteLine($" SPACING VIOLATION parts[{i}] vs parts[{j}]: " +
$"centerDist={centerDist:F6}, rawGap={rawGap:F6}, offsetGap={offsetGap:F6}, " +
$"expected>={spacing:F4}");
}
}
}
}
_output.WriteLine($" Min gap={minGap:F6}, expected>={spacing:F4}, violations={violationCount}");
if (violationCount > 0)
{
var maxDeficit = spacing - minGap;
_output.WriteLine($" Max deficit={maxDeficit:F6}");
Assert.Fail($"{violationCount} pairs violate spacing: min gap={minGap:F6}, expected>={spacing}, deficit={maxDeficit:F6}");
}
}
}
}
@@ -154,4 +154,18 @@ public class ContourClassificationTests
Assert.NotEqual(originalDirection, newDirection);
}
[Fact]
public void Reverse_changes_direction_label_for_circle()
{
var shape = MakeCircleShape(0, 0, 10);
var contours = ContourInfo.Classify(new List<Shape> { shape });
var contour = contours[0];
var originalDirection = contour.DirectionLabel;
contour.Reverse();
var newDirection = contour.DirectionLabel;
Assert.NotEqual(originalDirection, newDirection);
}
}
@@ -228,8 +228,7 @@ public class EllipseConverterTests
using (var writer = new ACadSharp.IO.DxfWriter(stream, doc, false))
writer.Write();
var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 };
var result = importer.Import(tempPath);
var result = OpenNest.IO.Dxf.Import(tempPath);
var arcCount = result.Entities.Count(e => e is Arc);
var lineCount = result.Entities.Count(e => e is Line);
@@ -138,8 +138,7 @@ public class GeometrySimplifierTests
if (!File.Exists(path))
return; // skip if file not available
var importer = new DxfImporter();
var result = importer.Import(path);
var result = Dxf.Import(path);
var shapes = ShapeBuilder.GetShapes(result.Entities);
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
@@ -0,0 +1,349 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Tests.Geometry;
public class SpatialQueryTests
{
#region Helpers
private static List<Entity> MakeSquare(double size)
{
return new List<Entity>
{
new Line(0, 0, size, 0),
new Line(size, 0, size, size),
new Line(size, size, 0, size),
new Line(0, size, 0, 0),
};
}
private static List<Entity> MakeRoundedRect(double length, double width, double r)
{
return new List<Entity>
{
new Line(r, 0, length - r, 0),
new Arc(length - r, r, r, Angle.ToRadians(270), Angle.ToRadians(360)),
new Line(length, r, length, width - r),
new Arc(length - r, width - r, r, Angle.ToRadians(0), Angle.ToRadians(90)),
new Line(length - r, width, r, width),
new Arc(r, width - r, r, Angle.ToRadians(90), Angle.ToRadians(180)),
new Line(0, width - r, 0, r),
new Arc(r, r, r, Angle.ToRadians(180), Angle.ToRadians(270)),
};
}
private static List<Entity> MakeCircle(double cx, double cy, double radius)
{
return new List<Entity> { new Circle(cx, cy, radius) };
}
private static List<Entity> Translate(List<Entity> entities, double dx, double dy)
{
var result = new List<Entity>();
foreach (var e in entities)
{
if (e is Line line)
result.Add(new Line(line.pt1.X + dx, line.pt1.Y + dy, line.pt2.X + dx, line.pt2.Y + dy));
else if (e is Arc arc)
result.Add(new Arc(arc.Center.X + dx, arc.Center.Y + dy, arc.Radius, arc.StartAngle, arc.EndAngle));
else if (e is Circle circle)
result.Add(new Circle(circle.Center.X + dx, circle.Center.Y + dy, circle.Radius));
}
return result;
}
#endregion
#region Circle vs Circle
[Fact]
public void CircleToCircle_Right_ReturnsGap()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(20, 0, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void CircleToCircle_Left_ReturnsGap()
{
var a = MakeCircle(20, 0, 5);
var b = MakeCircle(0, 0, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(-1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void CircleToCircle_Up_ReturnsGap()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(0, 20, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(0, 1));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void CircleToCircle_Touching_ReturnsZero()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(10, 0, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, -0.01, 0.01);
}
[Fact]
public void CircleToCircle_NoPath_ReturnsMaxValue()
{
var a = MakeCircle(0, 0, 3);
var b = MakeCircle(0, 20, 3);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.Equal(double.MaxValue, dist);
}
[Fact]
public void CircleToCircle_PushDirection_Right()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(20, 0, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, PushDirection.Right);
Assert.InRange(dist, 9.9, 10.1);
}
#endregion
#region Square vs Square
[Fact]
public void SquareToSquare_Right_ReturnsGap()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 25, 0);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, 14.9, 15.1);
}
[Fact]
public void SquareToSquare_Left_ReturnsGap()
{
var a = Translate(MakeSquare(10), 25, 0);
var b = MakeSquare(10);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(-1, 0));
Assert.InRange(dist, 14.9, 15.1);
}
[Fact]
public void SquareToSquare_Down_ReturnsGap()
{
var a = Translate(MakeSquare(10), 0, 25);
var b = MakeSquare(10);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(0, -1));
Assert.InRange(dist, 14.9, 15.1);
}
[Fact]
public void SquareToSquare_Touching_ReturnsZero()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 10, 0);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, -0.01, 0.01);
}
[Fact]
public void SquareToSquare_NoOverlap_ReturnsMaxValue()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 0, 20);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.Equal(double.MaxValue, dist);
}
[Fact]
public void SquareToSquare_PartialOverlap_Right()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 20, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
#endregion
#region Rounded Rectangle
[Fact]
public void RoundedRect_Right_ReturnsGap()
{
var a = MakeRoundedRect(20, 10, 2);
var b = Translate(MakeRoundedRect(20, 10, 2), 30, 0);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void RoundedRect_Up_ReturnsGap()
{
var a = MakeRoundedRect(20, 10, 2);
var b = Translate(MakeRoundedRect(20, 10, 2), 0, 25);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(0, 1));
Assert.InRange(dist, 14.9, 15.1);
}
[Fact]
public void RoundedRect_Touching_ReturnsZero()
{
var a = MakeRoundedRect(20, 10, 2);
var b = Translate(MakeRoundedRect(20, 10, 2), 20, 0);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, -0.01, 0.01);
}
[Fact]
public void RoundedRect_Diagonal_ReturnsDistance()
{
var dir = new Vector(1 / System.Math.Sqrt(2), 1 / System.Math.Sqrt(2));
var a = MakeRoundedRect(10, 10, 2);
var b = Translate(MakeRoundedRect(10, 10, 2), 20, 20);
var dist = SpatialQuery.DirectionalDistance(a, b, dir);
Assert.True(dist > 0 && dist < double.MaxValue);
}
#endregion
#region Circle vs Square
[Fact]
public void CircleToSquare_Right_ReturnsGap()
{
var circle = MakeCircle(0, 5, 5);
var square = Translate(MakeSquare(10), 15, 0);
var dist = SpatialQuery.DirectionalDistance(circle, square, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void SquareToCircle_Right_ReturnsGap()
{
var square = MakeSquare(10);
var circle = MakeCircle(25, 5, 5);
var dist = SpatialQuery.DirectionalDistance(square, circle, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void CircleToSquare_Touching_ReturnsZero()
{
var circle = MakeCircle(0, 5, 5);
var square = Translate(MakeSquare(10), 5, 0);
var dist = SpatialQuery.DirectionalDistance(circle, square, new Vector(1, 0));
Assert.InRange(dist, -0.01, 0.01);
}
#endregion
#region Circle vs Rounded Rectangle
[Fact]
public void CircleToRoundedRect_Right_ReturnsGap()
{
var circle = MakeCircle(0, 5, 5);
var rect = Translate(MakeRoundedRect(20, 10, 2), 15, 0);
var dist = SpatialQuery.DirectionalDistance(circle, rect, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void RoundedRectToCircle_Left_ReturnsGap()
{
var rect = Translate(MakeRoundedRect(20, 10, 2), 15, 0);
var circle = MakeCircle(0, 5, 5);
var dist = SpatialQuery.DirectionalDistance(rect, circle, new Vector(-1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
#endregion
#region Edge cases
[Fact]
public void EmptyLists_ReturnsMaxValue()
{
var a = new List<Entity>();
var b = new List<Entity>();
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.Equal(double.MaxValue, dist);
}
[Fact]
public void Symmetry_LeftRightReturnSameDistance()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 25, 0);
var right = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
var left = SpatialQuery.DirectionalDistance(b, a, new Vector(-1, 0));
Assert.InRange(System.Math.Abs(right - left), 0, 0.01);
}
[Fact]
public void Symmetry_CirclesLeftRightSame()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(20, 0, 5);
var right = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
var left = SpatialQuery.DirectionalDistance(b, a, new Vector(-1, 0));
Assert.InRange(System.Math.Abs(right - left), 0, 0.01);
}
#endregion
}
+138
View File
@@ -0,0 +1,138 @@
using System.IO;
using System.Linq;
using OpenNest.IO;
using Xunit;
namespace OpenNest.Tests.IO
{
public class CadImporterTests
{
private static string TestDxf =>
Path.Combine("Bending", "TestData", "4526 A14 PT11.dxf");
[Fact]
public void Import_LoadsEntitiesAndDetectsBends()
{
var result = CadImporter.Import(TestDxf);
Assert.NotNull(result);
Assert.NotEmpty(result.Entities);
Assert.NotNull(result.Bends);
Assert.NotNull(result.Bounds);
Assert.Equal(TestDxf, result.SourcePath);
Assert.Equal("4526 A14 PT11", result.Name);
}
[Fact]
public void Import_WhenDetectBendsFalse_ReturnsEmptyBends()
{
var result = CadImporter.Import(TestDxf, new CadImportOptions { DetectBends = false });
Assert.Empty(result.Bends);
}
[Fact]
public void Import_WhenNameOverrideProvided_UsesOverride()
{
var result = CadImporter.Import(TestDxf, new CadImportOptions { Name = "custom" });
Assert.Equal("custom", result.Name);
}
[Fact]
public void Import_WhenNamedDetectorDoesNotExist_ReturnsEmptyBends()
{
// Exercises the named-detector branch: when BendDetectorName doesn't
// match any registered detector, bends should be an empty list
// (not a crash, and no fall-through to auto-detect).
var result = CadImporter.Import(TestDxf,
new CadImportOptions { BendDetectorName = "__nonexistent__" });
Assert.Empty(result.Bends);
}
[Fact]
public void BuildDrawing_ProducesDrawingWithProgramAndMetadata()
{
var result = CadImporter.Import(TestDxf);
var drawing = CadImporter.BuildDrawing(
result,
result.Entities,
result.Bends,
quantity: 5,
customer: "ACME",
editedProgram: null);
Assert.NotNull(drawing);
Assert.Equal("4526 A14 PT11", drawing.Name);
Assert.Equal("ACME", drawing.Customer);
Assert.Equal(5, drawing.Quantity.Required);
Assert.Equal(TestDxf, drawing.Source.Path);
Assert.NotNull(drawing.Program);
Assert.NotEmpty(drawing.Program.Codes);
Assert.NotNull(drawing.SourceEntities);
Assert.NotEmpty(drawing.SourceEntities);
}
[Fact]
public void BuildDrawing_ExtractsFirstRapidAsSourceOffset()
{
var result = CadImporter.Import(TestDxf);
var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends,
quantity: 1, customer: null, editedProgram: null);
Assert.NotNull(drawing.Source.Offset);
// After offset extraction, the program's first rapid must start at origin.
var firstRapid = (OpenNest.CNC.RapidMove)drawing.Program.Codes[0];
Assert.Equal(0, firstRapid.EndPoint.X, 6);
Assert.Equal(0, firstRapid.EndPoint.Y, 6);
}
[Fact]
public void BuildDrawing_WhenEntityHidden_TracksSuppressedId()
{
var result = CadImporter.Import(TestDxf);
// Suppress the first non-bend-source entity
var bendSources = result.Bends
.Where(b => b.SourceEntity != null)
.Select(b => b.SourceEntity)
.ToHashSet();
var hidden = result.Entities.First(e => !bendSources.Contains(e));
hidden.IsVisible = false;
var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends,
quantity: 1, customer: null, editedProgram: null);
Assert.Contains(hidden.Id, drawing.SuppressedEntityIds);
}
[Fact]
public void BuildDrawing_WhenEditedProgramProvided_UsesEditedProgram()
{
var result = CadImporter.Import(TestDxf);
var edited = new OpenNest.CNC.Program();
edited.MoveTo(new OpenNest.Geometry.Vector(0, 0));
var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends,
quantity: 1, customer: null, editedProgram: edited);
Assert.Same(edited, drawing.Program);
}
[Fact]
public void ImportDrawing_ComposesImportAndBuild()
{
var drawing = CadImporter.ImportDrawing(TestDxf,
new CadImportOptions { Quantity = 3, Customer = "ACME" });
Assert.NotNull(drawing);
Assert.Equal("4526 A14 PT11", drawing.Name);
Assert.Equal(3, drawing.Quantity.Required);
Assert.Equal("ACME", drawing.Customer);
Assert.NotNull(drawing.Program);
Assert.NotNull(drawing.SourceEntities);
}
}
}
+3 -6
View File
@@ -11,17 +11,14 @@ public class DxfRoundtripTests
private static List<Entity> ExportAndReimport(List<Entity> geometry)
{
var program = ConvertGeometry.ToProgram(geometry);
var exporter = new DxfExporter();
var importer = new DxfImporter();
using var exportStream = new MemoryStream();
exporter.ExportProgram(program, exportStream);
Dxf.ExportProgram(program, exportStream);
var bytes = exportStream.ToArray();
var importStream = new MemoryStream(bytes);
var success = importer.GetGeometry(importStream, out var reimported);
var reimported = Dxf.GetGeometry(importStream);
Assert.True(success, "Failed to re-import exported DXF");
Assert.NotEmpty(reimported);
return reimported;
}
@@ -0,0 +1,75 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.IO;
public class SubProgramSerializationTests
{
[Fact]
public void NestWriter_WritesSubProgramCall_WithOffset()
{
var nest = CreateNestWithHoleSubProgram();
using var stream = new MemoryStream();
var writer = new NestWriter(nest);
writer.Write(stream);
stream.Position = 0;
var reader = new NestReader(stream);
var loaded = reader.Read();
var drawing = loaded.Drawings.First();
var calls = drawing.Program.Codes.OfType<SubProgramCall>().ToList();
Assert.Single(calls);
Assert.Equal(5, calls[0].Offset.X, 1);
Assert.Equal(5, calls[0].Offset.Y, 1);
}
[Fact]
public void NestWriter_WritesSubPrograms_AndRestoresOnLoad()
{
var nest = CreateNestWithHoleSubProgram();
using var stream = new MemoryStream();
var writer = new NestWriter(nest);
writer.Write(stream);
stream.Position = 0;
var reader = new NestReader(stream);
var loaded = reader.Read();
var drawing = loaded.Drawings.First();
Assert.True(drawing.Program.SubPrograms.Count > 0);
var call = drawing.Program.Codes.OfType<SubProgramCall>().First();
Assert.True(drawing.Program.SubPrograms.ContainsKey(call.Id));
}
private static Nest CreateNestWithHoleSubProgram()
{
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new LinearMove(0.1, 0) { Layer = LayerType.Leadin });
sub.Codes.Add(new ArcMove(new Vector(0, 0), new Vector(-0.5, 0), RotationType.CW));
var pgm = new Program(Mode.Absolute);
pgm.SubPrograms[42] = sub;
pgm.Codes.Add(new SubProgramCall { Id = 42, Program = sub, Offset = new Vector(5, 5) });
// Add perimeter so the drawing has non-zero geometry
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(10, 0));
pgm.Codes.Add(new LinearMove(10, 10));
pgm.Codes.Add(new LinearMove(0, 10));
pgm.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("TestPart") { Program = pgm };
var nest = new Nest();
nest.Drawings.Add(drawing);
var plate = new Plate { Size = new Size(48, 96) };
plate.Parts.Add(new Part(drawing));
nest.Plates.Add(plate);
return nest;
}
}
@@ -369,8 +369,7 @@ public class DrawingSplitterTests
var writer = new OpenNest.IO.SplitDxfWriter();
writer.Write(tempPath, results[0]);
var reimporter = new OpenNest.IO.DxfImporter();
var reimportResult = reimporter.Import(tempPath);
var reimportResult = OpenNest.IO.Dxf.Import(tempPath);
var afterArcs = reimportResult.Entities.OfType<Arc>().Count();
var afterCircles = reimportResult.Entities.OfType<Circle>().Count();
@@ -185,8 +185,7 @@ public class SplitDxfWriterEtchLayerTests
writer.Write(tempPath, splitDrawing);
// Re-import via DxfImporter (same path as CadConverterForm)
var importer = new DxfImporter();
var result = importer.Import(tempPath);
var result = Dxf.Import(tempPath);
// ETCH entities should be filtered during import (like BEND)
var etchEntities = result.Entities
@@ -23,8 +23,7 @@ public class StrategyOverlapTests
if (!System.IO.File.Exists(DxfPath))
return null;
var importer = new DxfImporter();
importer.GetGeometry(DxfPath, out var geometry);
var geometry = Dxf.GetGeometry(DxfPath);
var pgm = ConvertGeometry.ToProgram(geometry);
return new Drawing("PT15", pgm);
}
+15 -6
View File
@@ -1,8 +1,8 @@
using OpenNest;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.ML;
using OpenNest.Geometry;
using OpenNest.Gpu;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Training;
using System;
@@ -104,7 +104,6 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
if (backfilled > 0)
Console.WriteLine($"Backfilled PerimeterToAreaRatio for {backfilled} existing parts");
var importer = new DxfImporter();
var colorIndex = 0;
var processed = 0;
var skippedGeometry = 0;
@@ -129,16 +128,26 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
continue;
}
if (!importer.GetGeometry(file, out var entities))
Drawing drawing;
try
{
drawing = CadImporter.ImportDrawing(file,
new CadImportOptions { DetectBends = false, Name = Path.GetFileName(file) });
}
catch (System.Exception ex)
{
Console.WriteLine($" - SKIP ({ex.Message})");
skippedGeometry++;
continue;
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
{
Console.WriteLine(" - SKIP (no geometry)");
skippedGeometry++;
continue;
}
var drawing = new Drawing(Path.GetFileName(file));
var normalized = ShapeProfile.NormalizeEntities(entities);
drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(normalized);
drawing.UpdateArea();
drawing.Color = PartColors[colorIndex % PartColors.Length];
colorIndex++;
+84
View File
@@ -0,0 +1,84 @@
using OpenNest.IO.Bom;
using System;
using System.Drawing;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Forms;
namespace OpenNest
{
public static class ArchUnits
{
private static readonly Regex UnitRegex =
new Regex("^(?<Feet>\\d+\\.?\\d*\\s*')?\\s*(?<Inches>\\d+\\.?\\d*\\s*\")?$");
public static double ParseToInches(string input)
{
if (string.IsNullOrWhiteSpace(input))
return 0;
var sb = new StringBuilder(input.Trim().ToLower());
sb.Replace("ft", "'");
sb.Replace("feet", "'");
sb.Replace("foot", "'");
sb.Replace("inches", "\"");
sb.Replace("inch", "\"");
sb.Replace("in", "\"");
input = Fraction.ReplaceFractionsWithDecimals(sb.ToString());
var match = UnitRegex.Match(input);
if (!match.Success)
{
if (!input.Contains("'") && !input.Contains("\""))
{
if (double.TryParse(input.Trim(), out var plainInches))
return System.Math.Round(plainInches, 8);
}
throw new FormatException("Input is not in a valid format.");
}
var feet = match.Groups["Feet"];
var inches = match.Groups["Inches"];
var totalInches = 0.0;
if (feet.Success)
{
var x = double.Parse(feet.Value.Remove(feet.Length - 1));
totalInches += x * 12;
}
if (inches.Success)
{
var x = double.Parse(inches.Value.Remove(inches.Length - 1));
totalInches += x;
}
return System.Math.Round(totalInches, 8);
}
public static double GetLengthInches(TextBox tb)
{
try
{
if (double.TryParse(tb.Text, out var d))
{
tb.ForeColor = SystemColors.WindowText;
return d;
}
var x = ParseToInches(tb.Text);
tb.ForeColor = SystemColors.WindowText;
return x;
}
catch
{
tb.ForeColor = Color.Red;
return double.NaN;
}
}
}
}
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.ComponentModel;
using Action = OpenNest.Actions.Action;
namespace OpenNest.Controls
{
internal class ActionManager
{
private readonly PlateView view;
private Action currentAction;
private Action previousAction;
public ActionManager(PlateView view)
{
this.view = view;
}
public Action CurrentAction => currentAction;
public void SetAction(Type type)
{
var action = Activator.CreateInstance(type, view) as Action;
if (action == null)
return;
if (currentAction != null)
{
if (type == typeof(Actions.ActionSelect) && !(currentAction is Actions.ActionSelect))
previousAction = currentAction;
else
previousAction = null;
currentAction.CancelAction();
currentAction.DisconnectEvents();
currentAction = null;
}
currentAction = action;
view.Status = GetDisplayName(type);
}
public void SetAction(Type type, params object[] args)
{
if (currentAction != null)
{
previousAction = null;
currentAction.CancelAction();
currentAction.DisconnectEvents();
currentAction = null;
}
Array.Resize(ref args, args.Length + 1);
for (var i = args.Length - 2; i >= 0; i--)
args[i + 1] = args[i];
args[0] = view;
var action = Activator.CreateInstance(type, args) as Action;
if (action == null)
return;
currentAction = action;
view.Status = GetDisplayName(type);
}
public void ProcessEscapeKey()
{
if (currentAction.IsBusy())
currentAction.CancelAction();
else if (currentAction is Actions.ActionSelect && previousAction != null)
RestorePreviousAction();
else
view.SetAction(typeof(Actions.ActionSelect));
}
public void RestorePreviousAction()
{
var action = previousAction;
previousAction = null;
currentAction.CancelAction();
currentAction.DisconnectEvents();
action.ConnectEvents();
currentAction = action;
view.Status = GetDisplayName(action.GetType());
}
public void OnPlateChanged()
{
if (currentAction == null || !currentAction.SurvivesPlateChange)
view.SetAction(typeof(Actions.ActionSelect));
else
currentAction.OnPlateChanged();
}
public void Cleanup()
{
if (currentAction != null)
{
currentAction.CancelAction();
currentAction.DisconnectEvents();
currentAction = null;
}
}
private string GetDisplayName(Type type)
{
var attributes = type.GetCustomAttributes(true);
foreach (var attr in attributes)
{
if (attr is DisplayNameAttribute displayNameAttr)
return displayNameAttr.DisplayName;
}
return type.Name;
}
}
}
+13 -3
View File
@@ -9,6 +9,12 @@ namespace OpenNest.Controls
{
public static void DrawProgram(Graphics g, DrawControl view, Program pgm, ref Vector pos,
Pen pen, double spacing, float arrowSize)
{
DrawProgram(g, view, pgm, pos, ref pos, pen, spacing, arrowSize);
}
private static void DrawProgram(Graphics g, DrawControl view, Program pgm, Vector basePos, ref Vector pos,
Pen pen, double spacing, float arrowSize)
{
for (var i = 0; i < pgm.Length; ++i)
{
@@ -18,7 +24,11 @@ namespace OpenNest.Controls
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program != null)
DrawProgram(g, view, subpgm.Program, ref pos, pen, spacing, arrowSize);
{
var holeBase = basePos + subpgm.Offset;
pos = holeBase;
DrawProgram(g, view, subpgm.Program, holeBase, ref pos, pen, spacing, arrowSize);
}
continue;
}
@@ -26,7 +36,7 @@ namespace OpenNest.Controls
var endpt = pgm.Mode == Mode.Incremental
? motion.EndPoint + pos
: motion.EndPoint;
: motion.EndPoint + basePos;
if (code.Type == CodeType.LinearMove)
{
@@ -41,7 +51,7 @@ namespace OpenNest.Controls
{
var center = pgm.Mode == Mode.Incremental
? arc.CenterPoint + pos
: arc.CenterPoint;
: arc.CenterPoint + basePos;
DrawArcArrows(g, view, pos, endpt, center, arc.Rotation, pen, spacing, arrowSize);
}
}
+81
View File
@@ -0,0 +1,81 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Controls
{
internal class CutOffHandler
{
private readonly PlateView view;
private Dictionary<Part, Geometry.Entity> dragPerimeterCache;
public CutOffHandler(PlateView view)
{
this.view = view;
}
public bool IsDragging { get; private set; }
public CutOff TryStartDrag(Vector point, double tolerance)
{
var hitCutOff = GetCutOffAtPoint(point, tolerance);
if (hitCutOff == null)
return null;
IsDragging = true;
dragPerimeterCache = Plate.BuildPerimeterCache(view.Plate);
return hitCutOff;
}
public void UpdateDrag(Vector currentPoint, CutOff cutOff)
{
if (!IsDragging || cutOff == null)
return;
if (cutOff.Axis == CutOffAxis.Vertical)
cutOff.Position = new Vector(currentPoint.X, cutOff.Position.Y);
else
cutOff.Position = new Vector(cutOff.Position.X, currentPoint.Y);
cutOff.Regenerate(view.Plate, view.CutOffSettings, dragPerimeterCache);
view.Invalidate();
}
public void EndDrag()
{
if (!IsDragging)
return;
IsDragging = false;
dragPerimeterCache = null;
view.Plate.RegenerateCutOffs(view.CutOffSettings);
view.Invalidate();
}
public CutOff GetCutOffAtPoint(Vector point, double tolerance)
{
if (view.Plate?.CutOffs == null)
return null;
foreach (var cutoff in view.Plate.CutOffs)
{
var program = cutoff.Drawing?.Program;
if (program == null)
continue;
for (var i = 0; i < program.Codes.Count - 1; i += 2)
{
if (program.Codes[i] is RapidMove rapid &&
program.Codes[i + 1] is LinearMove linear)
{
var line = new Line(rapid.EndPoint, linear.EndPoint);
if (line.ClosestPointTo(point).DistanceTo(point) <= tolerance)
return cutoff;
}
}
}
return null;
}
}
}

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