92 Commits

Author SHA1 Message Date
aj 2bae5340f0 test: add nest invariance tests for fill count across import orientations
Verify that filling an L-shaped part produces consistent counts
regardless of the orientation it was imported at, and that all
placed parts stay within the plate work area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:56 -04:00
aj 0b322817d7 fix(core): use chain tolerance for entity gap check to prevent spurious rapids
Ellipse-to-arc conversion creates tiny floating-point gaps (~0.00002")
between consecutive arc segments. ShapeBuilder chains these with
ChainTolerance (0.0001"), but ConvertGeometry checked gaps with Epsilon
(0.00001"). Gaps between these thresholds generated spurious rapid moves
that broke GraphicsPath figures, causing diagonal fill artifacts from
GDI+'s implicit figure closing.

Root cause fix: align ConvertGeometry's gap check with ShapeBuilder's
ChainTolerance so precision gaps are absorbed instead of generating rapids.

Defense-in-depth: GraphicsHelper no longer breaks figures at near-zero
rapids, protecting against any programs with residual tiny rapids.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:08 -04:00
aj e41f335c63 feat: remove duplicate arcs matching circles on same layer during DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:44:54 -04:00
aj 0ab33af5d3 feat: add WeldEndpoints to ShapeBuilder for gap repair on import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 10:40:43 -04:00
aj e04c9381f3 feat: add IComparable<Box> and comparison operators to Box
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:36:23 -04:00
aj ceb9cc0b44 refactor: move Fraction from OpenNest.IO.Bom to OpenNest.Math
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:33:57 -04:00
aj 4cecaba83a fix(core): emit line instead of arc for near-zero sweep to avoid full-circle misinterpretation
Near-zero-sweep arcs with large radius (e.g. from ellipse converter) have
nearly-coincident start/end points. Downstream code (ConvertProgram, Program
BoundingBox) treats coincident start/end as a full 360° circle, inflating the
bounding box and rendering wrong geometry. Emit a LinearMove when sweep is
negligible — geometrically equivalent and avoids the ambiguity. Also fix the
ellipse converter to produce lines instead of degenerate arcs at the source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:50:38 -04:00
aj 4053f1f989 fix(core): arc bounding box inflated for near-zero sweep arcs
Arcs with sweep angles smaller than Tolerance.Epsilon were treated as
full circles by IsBetweenRad's shortcut check, causing UpdateBounds to
expand the bounding box to Center ± Radius. This made zoom-to-fit zoom
out far beyond the actual part extents.

Skip cardinal angle expansion when sweep is near-zero so the bounding
box uses only the arc's start/end points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:22:20 -04:00
aj ca67b1bd29 fix(io): handle flipped OCS normal on DXF ellipse import
Ellipses with extrusion direction Z=-1 had their parametric direction
reversed, causing the curve to appear mirrored. Negate start/end
parameters when Normal.Z < 0 to correct the minor-axis traversal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:22:20 -04:00
aj 199095ee43 fix(engine): canonicalize PlaceBestFitPairs builds to match BestFitCache frame 2026-04-23 08:22:20 -04:00
aj eb493d501a feat(engine): wrap single-item Fill with canonicalize/un-rotate bookends 2026-04-23 08:22:20 -04:00
aj 6c98732117 feat(engine): BestFitCache operates in canonical frame; TryPlaceBestFitPair builds from canonical drawing 2026-04-23 08:22:20 -04:00
aj a2e9fd4d14 feat(engine): extract ML features from canonical drawing frame 2026-04-23 08:22:20 -04:00
aj d228b6b812 refactor(engine): share MBR between PartClassifier and CanonicalAngle 2026-04-23 08:22:20 -04:00
aj c634aecd4b docs(core): refresh SourceInfo.Angle doc now that setter wiring lands 2026-04-23 08:22:19 -04:00
aj 14b7c1cf32 feat(core): store Source.Angle; recompute when Program changes 2026-04-23 08:22:19 -04:00
aj 402af91af5 feat(engine): add CanonicalFrame helper for drawing-to-canonical rotation 2026-04-23 08:22:19 -04:00
aj 9a6b656e3c feat(core): add CanonicalAngle helper for MBR-aligning angle 2026-04-23 08:22:19 -04:00
aj d2f9597b0c refactor(fill): use native entity geometry for linear copy distance
Replaces PartBoundary polygon edges with PartGeometry.GetOffsetPerimeterEntities
(inflated Line/Arc entities) so arcs are handled exactly without the polygon
sampling error that previously required a bboxDim + PartSpacing clamp. Adds
bbox DirectionalGap / PerpendicularOverlap early-outs to skip pair checks
that can't produce a valid slide, and removes the now-unused PartBoundary
cache, GetPatternLines/GetOffsetPatternLines helpers, and ComputeCopyDistance
clamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:26:21 -04:00
aj c40dcf0e25 chore: remove unused debug logging to desktop
NfpSlideStrategy wrote to nfp-slide-debug.log on the Desktop on every
call. The console's SetUpLog created test-harness-logs/ next to input
files but nothing in the codebase wrote to Trace, so those files were
always empty. Drop both along with the --no-log flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:24:40 -04:00
aj 28653e3a9f feat(shapes): generate unique drawing names from parameters and add toolbar button
Shape library drawings now get descriptive names based on their
parameters (e.g. "Rectangle 12x6", "Circle 8 Dia") instead of generic
type names, preventing silent duplicates in the DrawingCollection
HashSet. Added a Shape Library button to the Drawings tab toolbar
and removed separators between toolbar buttons for a cleaner look.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:48:45 -04:00
aj 7c3246c6e7 fix(cutting): restrict tabs to external perimeter and clarify tab UI
Tabs were being applied to internal cutouts and circle holes, which is
incorrect — only the external perimeter should be tabbed. Restructured
the Tabs panel to use radio buttons ("Tab all parts" vs "Auto-tab by
smallest dimension") so the two modes are clearly mutually exclusive
instead of the confusing implicit override behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:55:30 -04:00
aj bd48f57ce0 feat(ui): distinct Dark palette and recolor drawings on scheme switch
- Replace Dark part colors with high-contrast neon/electric palette
- Recolor existing drawings in open nests when scheme changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:06:14 -04:00
aj a6ec21accc fix(ui): address code review issues in color scheme feature
- Sync PlateView.BackColor on repaint so live scheme switch updates background
- Guard FromHex against truncated hex strings (< 6 chars)
- Cache disk schemes to avoid re-reading Schemes/ folder on every access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:56:15 -04:00
aj 320cf40f41 feat(ui): ship Schemes folder for user-defined color scheme JSON
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:52:45 -04:00
aj 3beca10429 feat(ui): add color scheme picker to Options dialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:52:00 -04:00
aj 8bea5dac6c feat(ui): apply active color scheme at startup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:50:03 -04:00
aj 12f8bbf8f5 feat(ui): add ActiveColorScheme user setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:49:31 -04:00
aj d15790b948 feat(ui): add ColorSchemeRegistry with Classic/Pastel/Dark built-ins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:48:46 -04:00
aj d80f76e386 feat(ui): add ColorScheme.Name/PartColors instance props and JSON serializer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:46:51 -04:00
aj 07bce8699a refactor(core): make Drawing.PartColors mutable for scheme overrides
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:45:18 -04:00
aj 9b84508ff4 refactor(shapes): generalize OctagonShape to NgonShape
Parameterize side count so users can generate any regular n-gon
(n>=3). Width remains the inscribed-circle diameter, preserving n=8
behavior; circumradius derives as Width / (2*cos(pi/n)).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:42:02 -04:00
aj 6fdf0ad3c5 refactor(cnc): extract rapid enumeration into RapidEnumerator
Pulls the rapid-walk logic (sub-program unwrapping, first-pierce lookup,
incremental-vs-absolute handling, first-rapid skipping) out of
PlateRenderer.DrawRapids into a reusable RapidEnumerator in Core so it
can be unit-tested and reused outside the renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:49:04 -04:00
aj 4f7bfcc3ad Merge remote-tracking branch 'origin/master' 2026-04-15 12:46:40 -04:00
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 a3ae61d993 fix(cutting): emit open contours raw instead of applying lead-in/lead-out
Open (non-closed) shapes like scribe lines or partial cuts don't have
a meaningful pierce point or closing segment, so applying lead-in/out
would produce invalid toolpaths. Skip the lead-in/out logic and emit
them as raw contours in both Apply and ApplySingle paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:37:56 -04:00
aj 838a247ef9 fix(geometry): replace closest-point heuristic with analytical arc-to-line directional distance
ArcToLineClosestDistance used geometric closest-point as a proxy for
directional push distance, which are fundamentally different queries.
The heuristic could overestimate the safe push distance when an arc
faces an inclined line, causing the Compactor to over-push parts into
overlapping positions.

Replace with analytical computation: for each arc/line pair, solve
dt/dθ = 0 to find the two critical angles where the directional
distance is stationary, evaluate both (if within the arc's angular
span), and fire a ray to verify the hit is within the line segment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:33:48 -04:00
aj a5e5e78c4e refactor(geometry): deduplicate axis branches in SpatialQuery.OneWayDistance
Merge the near-identical Left/Right and Up/Down pruning loops into a
single loop that selects the perpendicular axis via IsHorizontalDirection().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:58:45 -04:00
aj c386e462b2 docs(readme): add CAD converter section with screenshots
Add a CAD Converter workflow section and inline thumbnail screenshots.
Rearrange existing screenshots as side-by-side thumbnails with
click-to-enlarge links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:39 -04:00
aj 2c0457d503 feat(ui): add bend line editing to CAD converter
Add Edit link and double-click handler to the bend lines list so
existing bends can be modified without removing and re-adding them.
BendLineDialog gains a LoadBend method to populate fields from an
existing Bend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:26 -04:00
aj b03b3eb4d9 fix(bending): detect bend lines on layer "0" in addition to "BEND"
SolidWorks drawings sometimes place centerline bend markers on the
default layer instead of a dedicated BEND layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:21 -04:00
aj 29c2872819 fix(geometry): add Entity.Clone() and stop NormalizeEntities from mutating originals
ShapeProfile.NormalizeEntities called Shape.Reverse() which flipped arc
directions on the original entity objects shared with the CAD view. Switching
to the Program tab and back would leave arcs reversed. Clone entities before
normalizing so the originals stay untouched.

Adds abstract Entity.Clone() with implementations on Line, Arc, Circle,
Polygon, and Shape (deep-clones children). Also adds CloneAll() extension
and replaces manual duplication in PartGeometry.CopyEntitiesAtLocation and
ProgramEditorControl.CloneEntity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:35:13 -04:00
aj 3e96c62f33 docs(readme): reformat features as tables and document cutout-aware splitter
Feature list becomes grouped tables (Import/Export, Nesting, Plate
Operations, CNC Output). Nest file format section expands to cover the
newer entities/programs/subs layout. Drawing Splitting section gains a
paragraph explaining cutout-aware clipping: Liang-Barsky line clipping,
arc-vs-region intersection, and connected-component detection that emits
one drawing per physically-disconnected strip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:55:11 -04:00
aj 6880dee489 fix(splitter): preserve disconnected strips and trim cuts around cutouts
Splits that cross an interior cutout previously merged physically
disconnected strips into one drawing and drew cut lines through the hole.
The region boundary now spans full feature-edge extents (trimmed against
cutout polygons) and line entities are Liang-Barsky clipped, so multi-split
edges work. Arcs are properly clipped at region boundaries via iterative
split-at-intersection so circles that straddle a split contribute to both
sides. AssemblePieces groups a region's entities into connected closed
loops and nests holes by bbox-pre-check + vertex-in-polygon containment,
so one region can emit multiple drawings when a cutout fully spans it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:46:47 -04:00
aj 0e45c13515 feat(shapes): add PlateSizes catalog and wire Ctrl+P to snap-to-standard
PlateSizes holds standard mill sheet sizes (48x96 through 96x240) and
exposes Recommend() which snaps small layouts to an increment and
rounds larger layouts up to the nearest fitting sheet. Plate.SnapToStandardSize
applies the result while preserving long-axis orientation, and the
existing Ctrl+P "Resize to Fit" menu in EditNestForm now calls it
instead of the simple round-up AutoSize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:16:29 -04:00
aj 54def611fa refactor(ui): switch CreateShapeFromInputs to control-type branching 2026-04-10 17:52:03 -04:00
aj b1d094104a feat(ui): add filtered pipe size dropdown to shape library
Renders PipeSize as a DropDownList ComboBox, filters entries to those fitting
the current hole geometry, disables the combo when Blind is checked, and
appends an invalid-pipe warning to the preview info when TryGetOD fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:50:01 -04:00
aj 9d66b78a11 feat(ui): add bool checkbox support to ShapeLibraryForm
BuildParameterControls now creates a CheckBox (wired to UpdatePreview) for bool properties instead of a TextBox; CreateShapeFromInputs reads the Checked value via a short-circuit before the TextBox cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:47:36 -04:00
aj eddbbca7ef test(shapes): verify PipeFlangeShape JSON loading and shipped config integrity 2026-04-10 17:45:46 -04:00
aj 4e7b5304a0 chore(shapes): migrate flange config to PipeFlangeShape schema
Replace NominalPipeSize (double) with PipeSize (string label) and add
PipeClearance: 0.0625 to all 136 entries in PipeFlangeShape.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:42:16 -04:00
aj 06485053fc test(shapes): cover empty-string PipeSize in addition to null 2026-04-10 17:39:50 -04:00
aj 92a57d33df feat(shapes): add pipe bore, clearance, and blind flag to PipeFlangeShape
Replaces NominalPipeSize (double) with PipeSize (string), PipeClearance (double), and Blind (bool). GetDrawing cuts a center bore at pipeOD + PipeClearance unless Blind is true or PipeSize is unknown/null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:36:10 -04:00
aj 6adc5b0967 refactor(shapes): rename FlangeShape to PipeFlangeShape 2026-04-10 17:33:28 -04:00
aj d215d02844 style(shapes): remove redundant usings and document PipeSizes bound 2026-04-10 17:31:22 -04:00
aj 57863e16e9 feat(shapes): add ANSI pipe OD lookup table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:27:25 -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
139 changed files with 9268 additions and 1381 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 -9
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;
@@ -30,15 +28,21 @@ public static class NestRunner
if (!File.Exists(part.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
var geometry = Dxf.GetGeometry(part.DxfPath);
if (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 -42
View File
@@ -1,5 +1,4 @@
using OpenNest;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using System;
@@ -42,7 +41,6 @@ static class NestConsole
}
}
using var log = SetUpLog(options);
var nest = LoadOrCreateNest(options);
if (nest == null)
@@ -69,10 +67,6 @@ static class NestConsole
var overlapCount = CheckOverlaps(plate, options);
// Flush and close the log before printing results.
Trace.Flush();
log?.Dispose();
PrintResults(success, plate, elapsed);
Save(nest, options);
PostProcess(nest, options);
@@ -113,9 +107,6 @@ static class NestConsole
case "--no-save":
o.NoSave = true;
break;
case "--no-log":
o.NoLog = true;
break;
case "--keep-parts":
o.KeepParts = true;
break;
@@ -154,21 +145,6 @@ static class NestConsole
return o;
}
static StreamWriter SetUpLog(Options options)
{
if (options.NoLog)
return null;
var baseDir = Path.GetDirectoryName(options.InputFiles[0]);
var logDir = Path.Combine(baseDir, "test-harness-logs");
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
var writer = new StreamWriter(logFile) { AutoFlush = true };
Trace.Listeners.Add(new TextWriterTraceListener(writer));
Console.WriteLine($"Debug log: {logFile}");
return writer;
}
static Nest LoadOrCreateNest(Options options)
{
var nestFile = options.InputFiles.FirstOrDefault(f =>
@@ -241,25 +217,15 @@ static class NestConsole
static Drawing ImportDxf(string path)
{
var geometry = Dxf.GetGeometry(path);
if (geometry.Count == 0)
try
{
Console.Error.WriteLine($"Error: failed to read DXF file or no geometry found: {path}");
return CadImporter.ImportDrawing(path);
}
catch (System.Exception ex)
{
Console.Error.WriteLine($"Error: failed to import DXF '{path}': {ex.Message}");
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)
@@ -514,7 +480,6 @@ static class NestConsole
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
Console.Error.WriteLine(" --no-save Skip saving output file");
Console.Error.WriteLine(" --no-log Skip writing debug log file");
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
@@ -533,7 +498,6 @@ static class NestConsole
public Size? PlateSize;
public bool CheckOverlaps;
public bool NoSave;
public bool NoLog;
public bool KeepParts;
public bool AutoNest;
public string TemplateFile;
@@ -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,26 +26,60 @@ 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);
EmitScribeContours(result, scribeEntities);
foreach (var entry in cutoutEntries)
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
{
if (!entry.Shape.IsClosed())
EmitRawContour(result, entry.Shape);
else
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);
if (!profile.Perimeter.IsClosed())
EmitRawContour(result, profile.Perimeter);
else
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
result.Mode = Mode.Incremental;
@@ -67,10 +107,14 @@ namespace OpenNest.CNC.CuttingStrategy
// Find the target shape that contains the clicked entity
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
// Emit cutouts — only the target gets lead-in/out
// Emit cutouts — only the target gets lead-in/out (skip open contours)
foreach (var cutout in profile.Cutouts)
{
if (cutout == targetShape)
if (!cutout.IsClosed())
{
EmitRawContour(result, cutout);
}
else if (cutout == targetShape)
{
var ct = DetectContourType(cutout);
EmitContour(result, cutout, point, matchedEntity, ct);
@@ -82,7 +126,11 @@ namespace OpenNest.CNC.CuttingStrategy
}
// Emit perimeter
if (profile.Perimeter == targetShape)
if (!profile.Perimeter.IsClosed())
{
EmitRawContour(result, profile.Perimeter);
}
else if (profile.Perimeter == targetShape)
{
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
}
@@ -187,6 +235,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 +279,59 @@ 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);
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);
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
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));
}
@@ -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; }
+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;
}
+80
View File
@@ -0,0 +1,80 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC
{
public static class RapidEnumerator
{
public readonly record struct Segment(Vector From, Vector To);
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
{
var results = new List<Segment>();
// Draw the rapid from the previous tool position to the program's first
// pierce point. This also primes pos so the interior walk interprets
// Incremental deltas from the correct absolute location (basePos), which
// matters for raw pre-lead-in programs that are emitted Incremental.
var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce));
var pos = firstPierce;
Walk(pgm, basePos, ref pos, skipFirst: true, results);
return results;
}
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
{
for (var i = 0; i < pgm.Length; i++)
{
if (pgm[i] is SubProgramCall call && call.Program != null)
return FirstPiercePoint(call.Program, basePos + call.Offset);
if (pgm[i] is Motion motion)
return motion.EndPoint + basePos;
}
return basePos;
}
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results)
{
var skipped = !skipFirst;
for (var i = 0; i < pgm.Length; ++i)
{
var code = pgm[i];
if (code is SubProgramCall { Program: { } program } call)
{
var holeBase = basePos + call.Offset;
var firstPierce = FirstPiercePoint(program, holeBase);
if (!skipped)
skipped = true;
else
results.Add(new Segment(pos, firstPierce));
var subPos = holeBase;
Walk(program, holeBase, ref subPos, skipFirst: true, results);
pos = subPos;
}
else if (code is Motion motion)
{
var endpt = pgm.Mode == Mode.Incremental
? motion.EndPoint + pos
: motion.EndPoint + basePos;
if (code.Type == CodeType.RapidMove)
{
if (!skipped)
skipped = true;
else
results.Add(new Segment(pos, endpt));
}
pos = endpt;
}
}
}
}
}
+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();
}
}
}
+78
View File
@@ -0,0 +1,78 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using System.Linq;
namespace OpenNest
{
/// <summary>
/// Computes the rotation that maps a drawing to its canonical (MBR-axis-aligned) frame.
/// Lives in OpenNest.Core so Drawing.Program setter can invoke it directly without
/// a circular dependency on OpenNest.Engine.
/// </summary>
public static class CanonicalAngle
{
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
public const double SnapToZero = 0.001;
/// <summary>
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
/// across both callers.
/// </summary>
public static double FromMbr(BoundingRectangleResult mbr)
{
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
return 0.0;
// The MBR edge angle can represent any of four equivalent orientations
// (edge-i, edge-i + π/2, edge-i + π, edge-i - π/2) depending on which hull
// edge the algorithm happened to pick. Normalize -mbr.Angle to the
// representative in [-π/4, π/4] so snap-to-zero works for inputs near
// ANY of the equivalent orientations.
var angle = -mbr.Angle;
const double halfPi = System.Math.PI / 2.0;
angle -= halfPi * System.Math.Round(angle / halfPi);
if (System.Math.Abs(angle) < SnapToZero)
return 0.0;
return angle;
}
public static double Compute(Drawing drawing)
{
if (drawing?.Program == null)
return 0.0;
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0)
return 0.0;
var perimeter = shapes[0];
var perimeterArea = perimeter.Area();
for (var i = 1; i < shapes.Count; i++)
{
var area = shapes[i].Area();
if (area > perimeterArea)
{
perimeter = shapes[i];
perimeterArea = area;
}
}
var polygon = perimeter.ToPolygonWithTolerance(0.1);
if (polygon == null || polygon.Vertices.Count < 3)
return 0.0;
var hull = ConvexHull.Compute(polygon.Vertices);
if (hull.Vertices.Count < 3)
return 0.0;
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
return FromMbr(mbr);
}
}
}
+14 -4
View File
@@ -1,5 +1,6 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Converters
@@ -81,12 +82,21 @@ namespace OpenNest.Converters
var startpt = arc.StartPoint();
var endpt = arc.EndPoint();
if (startpt != lastpt)
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt);
lastpt = endpt;
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
var sweep = System.Math.Abs(arc.SweepAngle());
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{
pgm.LineTo(endpt);
}
else
{
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
}
return lastpt;
}
@@ -94,7 +104,7 @@ namespace OpenNest.Converters
{
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
if (startpt != lastpt)
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
@@ -105,7 +115,7 @@ namespace OpenNest.Converters
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
{
if (line.StartPoint != lastpt)
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint);
+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;
}
}
}
+11 -5
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;
}
}
+32 -2
View File
@@ -16,7 +16,7 @@ namespace OpenNest
private static int nextColorIndex;
private Program program;
public static readonly Color[] PartColors = new Color[]
public static Color[] PartColors = new Color[]
{
Color.FromArgb(205, 92, 92), // Indian Red
Color.FromArgb(148, 103, 189), // Medium Purple
@@ -54,9 +54,9 @@ namespace OpenNest
Id = Interlocked.Increment(ref nextId);
Name = name;
Material = new Material();
Program = pgm;
Constraints = new NestConstraints();
Source = new SourceInfo();
Program = pgm;
}
public int Id { get; }
@@ -78,9 +78,29 @@ namespace OpenNest
{
program = value;
UpdateArea();
RecomputeCanonicalAngle();
}
}
/// <summary>
/// Recomputes and stores the canonical angle from the current Program.
/// Callers that mutate Program in place (rather than reassigning it) must invoke this explicitly.
/// Cut-off drawings are left with Angle=0.
/// </summary>
public void RecomputeCanonicalAngle()
{
if (Source == null)
Source = new SourceInfo();
if (program == null || IsCutOff)
{
Source.Angle = 0.0;
return;
}
Source.Angle = CanonicalAngle.Compute(this);
}
public Color Color { get; set; }
public bool IsCutOff { get; set; }
@@ -163,5 +183,15 @@ namespace OpenNest
/// Offset distances to the original location.
/// </summary>
public Vector Offset { get; set; }
/// <summary>
/// Rotation (radians) that maps the source program geometry to its canonical
/// (MBR-axis-aligned) frame. Populated automatically by the <see cref="Drawing.Program"/>
/// setter via <see cref="CanonicalAngle.Compute"/>. A value of 0 means the drawing is
/// already canonical or <see cref="Drawing.IsCutOff"/> is true. Callers that mutate
/// <see cref="Drawing.Program"/> in place must invoke
/// <see cref="Drawing.RecomputeCanonicalAngle"/> to refresh.
/// </summary>
public double Angle { get; set; }
}
}
+24 -14
View File
@@ -267,6 +267,13 @@ namespace OpenNest.Geometry
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; }
}
public override Entity Clone()
{
var copy = new Arc(center, radius, startAngle, endAngle, reversed);
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reverses the rotation direction.
/// </summary>
@@ -397,26 +404,29 @@ namespace OpenNest.Geometry
maxY = startpt.Y;
}
var angle1 = StartAngle;
var angle2 = EndAngle;
var sweep = SweepAngle();
if (sweep > Tolerance.Epsilon)
{
var angle1 = StartAngle;
var angle2 = EndAngle;
// switch the angle to counter clockwise.
if (IsReversed)
Generic.Swap(ref angle1, ref angle2);
if (IsReversed)
Generic.Swap(ref angle1, ref angle2);
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
maxY = Center.Y + Radius;
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
maxY = Center.Y + Radius;
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
minX = Center.X - Radius;
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
minX = Center.X - Radius;
const double oneHalfPI = System.Math.PI * 1.5;
const double oneHalfPI = System.Math.PI * 1.5;
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
minY = Center.Y - Radius;
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
minY = Center.Y - Radius;
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
maxX = Center.X + Radius;
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
maxX = Center.X + Radius;
}
boundingBox.X = minX;
boundingBox.Y = minY;
+17 -2
View File
@@ -1,8 +1,9 @@
using OpenNest.Math;
using System;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public class Box
public class Box : IComparable<Box>
{
public static readonly Box Empty = new Box();
@@ -214,5 +215,19 @@ namespace OpenNest.Geometry
{
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
}
public int CompareTo(Box other)
{
var cmp = Width.CompareTo(other.Width);
return cmp != 0 ? cmp : Length.CompareTo(other.Length);
}
public static bool operator >(Box a, Box b) => a.CompareTo(b) > 0;
public static bool operator <(Box a, Box b) => a.CompareTo(b) < 0;
public static bool operator >=(Box a, Box b) => a.CompareTo(b) >= 0;
public static bool operator <=(Box a, Box b) => a.CompareTo(b) <= 0;
}
}
+7
View File
@@ -165,6 +165,13 @@ namespace OpenNest.Geometry
get { return Circumference(); }
}
public override Entity Clone()
{
var copy = new Circle(center, radius) { Rotation = Rotation };
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reverses the rotation direction.
/// </summary>
+5 -1
View File
@@ -173,7 +173,11 @@ namespace OpenNest.Geometry
if (maxDev <= tolerance)
{
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1);
if (arc.SweepAngle() < Tolerance.Epsilon)
results.Add(new Line(p0, p1));
else
results.Add(arc);
}
else
{
+25
View File
@@ -251,6 +251,23 @@ namespace OpenNest.Geometry
/// <returns></returns>
public abstract bool Intersects(Shape shape, out List<Vector> pts);
/// <summary>
/// Creates a deep copy of the entity with a new Id.
/// </summary>
public abstract Entity Clone();
/// <summary>
/// Copies common Entity properties from this instance to the target.
/// </summary>
protected void CopyBaseTo(Entity target)
{
target.Color = Color;
target.Layer = Layer;
target.LineTypeName = LineTypeName;
target.IsVisible = IsVisible;
target.Tag = Tag;
}
/// <summary>
/// Type of entity.
/// </summary>
@@ -259,6 +276,14 @@ namespace OpenNest.Geometry
public static class EntityExtensions
{
public static List<Entity> CloneAll(this IEnumerable<Entity> entities)
{
var result = new List<Entity>();
foreach (var e in entities)
result.Add(e.Clone());
return result;
}
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
{
var points = new List<Vector>();
+7
View File
@@ -257,6 +257,13 @@ namespace OpenNest.Geometry
}
}
public override Entity Clone()
{
var copy = new Line(pt1, pt2);
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reversed the line.
/// </summary>
+7
View File
@@ -168,6 +168,13 @@ namespace OpenNest.Geometry
get { return Perimeter(); }
}
public override Entity Clone()
{
var copy = new Polygon { Vertices = new List<Vector>(Vertices) };
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reverses the rotation direction of the polygon.
/// </summary>
+9
View File
@@ -349,6 +349,15 @@ namespace OpenNest.Geometry
return polygon;
}
public override Entity Clone()
{
var copy = new Shape();
foreach (var e in Entities)
copy.Entities.Add(e.Clone());
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reverses the rotation direction of the shape.
/// </summary>
+92 -1
View File
@@ -1,12 +1,13 @@
using OpenNest.Math;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace OpenNest.Geometry
{
public static class ShapeBuilder
{
public static List<Shape> GetShapes(IEnumerable<Entity> entities)
public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null)
{
var lines = new List<Line>();
var arcs = new List<Arc>();
@@ -57,6 +58,9 @@ namespace OpenNest.Geometry
entityList.AddRange(lines);
entityList.AddRange(arcs);
if (weldTolerance.HasValue)
WeldEndpoints(entityList, weldTolerance.Value);
while (entityList.Count > 0)
{
var next = entityList[0];
@@ -107,6 +111,93 @@ namespace OpenNest.Geometry
return shapes;
}
public static void WeldEndpoints(List<Entity> entities, double tolerance)
{
var endpointGroups = new List<List<(Entity entity, bool isStart, Vector point)>>();
foreach (var entity in entities)
{
var (start, end) = GetEndpoints(entity);
if (!start.IsValid() || !end.IsValid())
continue;
AddToGroup(endpointGroups, entity, true, start, tolerance);
AddToGroup(endpointGroups, entity, false, end, tolerance);
}
foreach (var group in endpointGroups)
{
if (group.Count <= 1)
continue;
var avgX = group.Average(g => g.point.X);
var avgY = group.Average(g => g.point.Y);
var weldedPoint = new Vector(avgX, avgY);
foreach (var (entity, isStart, _) in group)
ApplyWeld(entity, isStart, weldedPoint);
}
}
private static void AddToGroup(
List<List<(Entity entity, bool isStart, Vector point)>> groups,
Entity entity, bool isStart, Vector point, double tolerance)
{
foreach (var group in groups)
{
if (group[0].point.DistanceTo(point) <= tolerance)
{
group.Add((entity, isStart, point));
return;
}
}
groups.Add(new List<(Entity, bool, Vector)> { (entity, isStart, point) });
}
private static (Vector start, Vector end) GetEndpoints(Entity entity)
{
switch (entity.Type)
{
case EntityType.Arc:
var arc = (Arc)entity;
return (arc.StartPoint(), arc.EndPoint());
case EntityType.Line:
var line = (Line)entity;
return (line.StartPoint, line.EndPoint);
default:
return (Vector.Invalid, Vector.Invalid);
}
}
private static void ApplyWeld(Entity entity, bool isStart, Vector weldedPoint)
{
switch (entity.Type)
{
case EntityType.Line:
var line = (Line)entity;
if (isStart)
line.StartPoint = weldedPoint;
else
line.EndPoint = weldedPoint;
break;
case EntityType.Arc:
var arc = (Arc)entity;
var deltaX = weldedPoint.X - arc.Center.X;
var deltaY = weldedPoint.Y - arc.Center.Y;
var angle = System.Math.Atan2(deltaY, deltaX);
if (isStart)
arc.StartAngle = angle;
else
arc.EndAngle = angle;
break;
}
}
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
{
var tol = Tolerance.ChainTolerance;
+2 -1
View File
@@ -75,7 +75,8 @@ namespace OpenNest.Geometry
/// </summary>
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
{
var profile = new ShapeProfile(entities.ToList());
var cloned = entities.CloneAll();
var profile = new ShapeProfile(cloned);
return profile.ToNormalizedEntities();
}
+64 -48
View File
@@ -306,49 +306,38 @@ namespace OpenNest.Geometry
var minDist = double.MaxValue;
var vx = vertex.X;
var vy = vertex.Y;
var horizontal = IsHorizontalDirection(direction);
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
if (direction == PushDirection.Left || direction == PushDirection.Right)
// Pruning: edges are sorted by their perpendicular min-coordinate.
// For horizontal push, prune by Y range; for vertical push, prune by X range.
for (var i = 0; i < edges.Length; i++)
{
for (var i = 0; i < edges.Length; i++)
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
double perpValue, edgeMin, edgeMax;
if (horizontal)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
if (vy < minY - Tolerance.Epsilon)
break;
if (vy > maxY + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
perpValue = vy;
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
}
}
else // Up/Down
{
for (var i = 0; i < edges.Length; i++)
else
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minX = e1.X < e2.X ? e1.X : e2.X;
var maxX = e1.X > e2.X ? e1.X : e2.X;
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
if (vx < minX - Tolerance.Epsilon)
break;
if (vx > maxX + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
perpValue = vx;
edgeMin = e1.X < e2.X ? e1.X : e2.X;
edgeMax = e1.X > e2.X ? e1.X : e2.X;
}
// Since edges are sorted by edgeMin, if perpValue < edgeMin, all subsequent edges are also past.
if (perpValue < edgeMin - Tolerance.Epsilon)
break;
if (perpValue > edgeMax + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
}
return minDist;
@@ -642,19 +631,46 @@ namespace OpenNest.Geometry
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (arcEntities[i] is Arc arc)
if (arcEntities[i] is not Arc arc)
continue;
var cx = arc.Center.X;
var cy = arc.Center.Y;
var r = arc.Radius;
for (var j = 0; j < lineEntities.Count; j++)
{
for (var j = 0; j < lineEntities.Count; j++)
if (lineEntities[j] is not Line line)
continue;
var p1x = line.pt1.X;
var p1y = line.pt1.Y;
var ex = line.pt2.X - p1x;
var ey = line.pt2.Y - p1y;
var det = ex * dirY - ey * dirX;
if (System.Math.Abs(det) < Tolerance.Epsilon)
continue;
// The directional distance from an arc point at angle θ to the
// line is t(θ) = [A + r·(ey·cosθ ex·sinθ)] / det.
// dt/dθ = 0 at θ = atan2(ex, ey) and θ + π.
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
for (var k = 0; k < 2; k++)
{
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; }
}
var theta = k == 0 ? theta1 : theta2;
if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
continue;
var qx = cx + r * System.Math.Cos(theta);
var qy = cy + r * System.Math.Sin(theta);
var d = RayEdgeDistance(qx, qy, p1x, p1y, line.pt2.X, line.pt2.Y,
dirX, dirY);
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
}
}
}
@@ -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);
}
}
@@ -3,7 +3,7 @@ using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace OpenNest.IO.Bom
namespace OpenNest.Math
{
public static class Fraction
{
+6 -1
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;
+3 -13
View File
@@ -126,20 +126,10 @@ namespace OpenNest
{
var result = new List<Entity>(source.Count);
for (var i = 0; i < source.Count; i++)
foreach (var entity in source)
{
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;
var copy = entity.Clone();
copy.Offset(location);
result.Add(copy);
}
+60
View File
@@ -1,6 +1,7 @@
using OpenNest.Collections;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -548,6 +549,65 @@ namespace OpenNest
Rounding.RoundUpToNearest(xExtent, roundingFactor));
}
/// <summary>
/// Sizes the plate using the <see cref="PlateSizes"/> catalog: small
/// layouts snap to an increment, larger ones round up to the next
/// standard mill sheet. The plate's long-axis orientation (X vs Y)
/// is preserved. Does nothing if the plate has no parts.
/// </summary>
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
{
if (Parts.Count == 0)
return default;
var bounds = Parts.GetBoundingBox();
// Quadrant-aware extents relative to the plate origin, matching AutoSize.
double xExtent;
double yExtent;
switch (Quadrant)
{
case 1:
xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
break;
case 2:
xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
break;
case 3:
xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
break;
case 4:
xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
break;
default:
return default;
}
// PlateSizes.Recommend takes (short, long); canonicalize then map
// the result back so the plate's long axis stays aligned with the
// parts' long axis.
var shortDim = System.Math.Min(xExtent, yExtent);
var longDim = System.Math.Max(xExtent, yExtent);
var result = PlateSizes.Recommend(shortDim, longDim, options);
// Plate convention: Length = X axis, Width = Y axis.
if (xExtent >= yExtent)
Size = new Size(result.Width, result.Length); // X is the long axis
else
Size = new Size(result.Length, result.Width); // Y is the long axis
return result;
}
/// <summary>
/// Gets the area of the top surface of the plate.
/// </summary>
+2
View File
@@ -7,6 +7,8 @@ namespace OpenNest.Shapes
{
public double Diameter { get; set; }
public override string GenerateName() => $"Circle {Dim(Diameter)} Dia";
public override void SetPreviewDefaults()
{
Diameter = 8;
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
public double Base { get; set; }
public double Height { get; set; }
public override string GenerateName() => $"Isosceles Triangle {Dim(Base)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Base = 8;
+2
View File
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
public double LegWidth { get; set; }
public double LegHeight { get; set; }
public override string GenerateName() => $"L {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 8;
@@ -3,33 +3,40 @@ using System.Collections.Generic;
namespace OpenNest.Shapes
{
public class OctagonShape : ShapeDefinition
public class NgonShape : ShapeDefinition
{
public int Sides { get; set; }
public double Width { get; set; }
public override string GenerateName() => $"{Sides}-Sided Polygon {Dim(Width)}";
public override void SetPreviewDefaults()
{
Sides = 8;
Width = 8;
}
public override Drawing GetDrawing()
{
var n = Sides < 3 ? 3 : Sides;
var center = Width / 2.0;
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / 8.0));
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / n));
var step = 2.0 * System.Math.PI / n;
var start = System.Math.PI / n;
var vertices = new Vector[8];
for (var i = 0; i < 8; i++)
var vertices = new Vector[n];
for (var i = 0; i < n; i++)
{
var angle = System.Math.PI / 8.0 + i * System.Math.PI / 4.0;
var angle = start + i * step;
vertices[i] = new Vector(
center + circumRadius * System.Math.Cos(angle),
center + circumRadius * System.Math.Sin(angle));
}
var entities = new List<Entity>();
for (var i = 0; i < 8; i++)
for (var i = 0; i < n; i++)
{
var next = (i + 1) % 8;
var next = (i + 1) % n;
entities.Add(new Line(vertices[i], vertices[next]));
}
@@ -3,31 +3,41 @@ using System.Collections.Generic;
namespace OpenNest.Shapes
{
public class FlangeShape : ShapeDefinition
public class PipeFlangeShape : ShapeDefinition
{
public double NominalPipeSize { get; set; }
public double OD { get; set; }
public double HoleDiameter { get; set; }
public double HolePatternDiameter { get; set; }
public int HoleCount { get; set; }
public string PipeSize { get; set; }
public double PipeClearance { get; set; }
public bool Blind { get; set; }
public override string GenerateName()
{
var name = $"Pipe Flange {Dim(OD)} OD";
if (!string.IsNullOrEmpty(PipeSize))
name += $" {PipeSize} Pipe";
return name;
}
public override void SetPreviewDefaults()
{
NominalPipeSize = 2;
OD = 7.5;
HoleDiameter = 0.875;
HolePatternDiameter = 5.5;
HoleCount = 8;
PipeSize = "2";
PipeClearance = 0.0625;
Blind = false;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>();
// Outer circle
entities.Add(new Circle(0, 0, OD / 2.0));
// Bolt holes evenly spaced on the bolt circle
var boltCircleRadius = HolePatternDiameter / 2.0;
var holeRadius = HoleDiameter / 2.0;
var angleStep = 2.0 * System.Math.PI / HoleCount;
@@ -40,6 +50,12 @@ namespace OpenNest.Shapes
entities.Add(new Circle(cx, cy, holeRadius));
}
if (!Blind && !string.IsNullOrEmpty(PipeSize) && PipeSizes.TryGetOD(PipeSize, out var pipeOD))
{
var boreDiameter = pipeOD + PipeClearance;
entities.Add(new Circle(0, 0, boreDiameter / 2.0));
}
return CreateDrawing(entities);
}
}
+78
View File
@@ -0,0 +1,78 @@
using System.Collections.Generic;
namespace OpenNest.Shapes
{
public static class PipeSizes
{
public readonly record struct Entry(string Label, double OuterDiameter);
public static IReadOnlyList<Entry> All { get; } = new[]
{
new Entry("1/8", 0.405),
new Entry("1/4", 0.540),
new Entry("3/8", 0.675),
new Entry("1/2", 0.840),
new Entry("3/4", 1.050),
new Entry("1", 1.315),
new Entry("1 1/4", 1.660),
new Entry("1 1/2", 1.900),
new Entry("2", 2.375),
new Entry("2 1/2", 2.875),
new Entry("3", 3.500),
new Entry("3 1/2", 4.000),
new Entry("4", 4.500),
new Entry("4 1/2", 5.000),
new Entry("5", 5.563),
new Entry("6", 6.625),
new Entry("7", 7.625),
new Entry("8", 8.625),
new Entry("9", 9.625),
new Entry("10", 10.750),
new Entry("11", 11.750),
new Entry("12", 12.750),
new Entry("14", 14.000),
new Entry("16", 16.000),
new Entry("18", 18.000),
new Entry("20", 20.000),
new Entry("24", 24.000),
new Entry("26", 26.000),
new Entry("28", 28.000),
new Entry("30", 30.000),
new Entry("32", 32.000),
new Entry("34", 34.000),
new Entry("36", 36.000),
new Entry("42", 42.000),
new Entry("48", 48.000),
};
public static bool TryGetOD(string label, out double outerDiameter)
{
foreach (var entry in All)
{
if (entry.Label == label)
{
outerDiameter = entry.OuterDiameter;
return true;
}
}
outerDiameter = 0;
return false;
}
/// <summary>
/// Returns all pipe sizes whose outer diameter is less than or equal to <paramref name="maxOD"/>.
/// The bound is inclusive.
/// </summary>
public static IEnumerable<Entry> GetFittingSizes(double maxOD)
{
foreach (var entry in All)
{
if (entry.OuterDiameter <= maxOD)
{
yield return entry;
}
}
}
}
}
+255
View File
@@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
/// <summary>
/// Catalog of standard mill sheet sizes (inches) with helpers for matching
/// a bounding box to a recommended plate size. Uses the project-wide
/// (Width, Length) convention where Width is the short dimension and
/// Length is the long dimension.
/// </summary>
public static class PlateSizes
{
public readonly record struct Entry(string Label, double Width, double Length)
{
public double Area => Width * Length;
/// <summary>
/// Returns true if a part of the given dimensions fits within this entry
/// in either orientation.
/// </summary>
public bool Fits(double width, double length) =>
(width <= Width && length <= Length) || (width <= Length && length <= Width);
}
/// <summary>
/// Standard mill sheet sizes (inches), sorted by area ascending.
/// Canonical orientation: Width &lt;= Length.
/// </summary>
public static IReadOnlyList<Entry> All { get; } = new[]
{
new Entry("48x96", 48, 96), // 4608
new Entry("48x120", 48, 120), // 5760
new Entry("48x144", 48, 144), // 6912
new Entry("60x120", 60, 120), // 7200
new Entry("60x144", 60, 144), // 8640
new Entry("72x120", 72, 120), // 8640
new Entry("72x144", 72, 144), // 10368
new Entry("96x240", 96, 240), // 23040
};
/// <summary>
/// Looks up a standard size by label. Case-insensitive.
/// </summary>
public static bool TryGet(string label, out Entry entry)
{
if (!string.IsNullOrWhiteSpace(label))
{
foreach (var candidate in All)
{
if (string.Equals(candidate.Label, label, StringComparison.OrdinalIgnoreCase))
{
entry = candidate;
return true;
}
}
}
entry = default;
return false;
}
/// <summary>
/// Recommends a plate size for the given bounding box. The box's
/// spatial axes are normalized to (short, long) so neither the bbox
/// orientation nor Box's internal Length/Width naming matters.
/// </summary>
public static PlateSizeResult Recommend(Box bbox, PlateSizeOptions options = null)
{
var a = bbox.Width;
var b = bbox.Length;
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
}
/// <summary>
/// Recommends a plate size for the envelope of the given boxes.
/// </summary>
public static PlateSizeResult Recommend(IEnumerable<Box> boxes, PlateSizeOptions options = null)
{
if (boxes == null)
throw new ArgumentNullException(nameof(boxes));
var hasAny = false;
var minX = double.PositiveInfinity;
var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity;
var maxY = double.NegativeInfinity;
foreach (var box in boxes)
{
hasAny = true;
if (box.Left < minX) minX = box.Left;
if (box.Bottom < minY) minY = box.Bottom;
if (box.Right > maxX) maxX = box.Right;
if (box.Top > maxY) maxY = box.Top;
}
if (!hasAny)
throw new ArgumentException("At least one box is required.", nameof(boxes));
var b = maxX - minX;
var a = maxY - minY;
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
}
/// <summary>
/// Recommends a plate size for a (width, length) pair.
/// Inputs are treated as orientation-independent.
/// </summary>
public static PlateSizeResult Recommend(double width, double length, PlateSizeOptions options = null)
{
options ??= new PlateSizeOptions();
var w = width + 2 * options.Margin;
var l = length + 2 * options.Margin;
// Canonicalize (short, long) — Fits handles rotation anyway, but
// normalizing lets the below-min comparison use the narrower
// MinSheet dimensions consistently.
if (w > l)
(w, l) = (l, w);
// Below full-sheet threshold: snap each dimension up to the nearest increment.
if (w <= options.MinSheetWidth && l <= options.MinSheetLength)
return SnapResult(w, l, options.SnapIncrement);
var catalog = BuildCatalog(options.AllowedSizes);
var best = PickBest(catalog, w, l, options.Selection);
if (best.HasValue)
return new PlateSizeResult(best.Value.Width, best.Value.Length, best.Value.Label);
// Nothing in the catalog fits - fall back to snap-up (ad-hoc oversize sheet).
return SnapResult(w, l, options.SnapIncrement);
}
private static PlateSizeResult SnapResult(double width, double length, double increment)
{
if (increment <= 0)
return new PlateSizeResult(width, length, null);
return new PlateSizeResult(SnapUp(width, increment), SnapUp(length, increment), null);
}
private static double SnapUp(double value, double increment)
{
var steps = System.Math.Ceiling(value / increment);
return steps * increment;
}
private static IReadOnlyList<Entry> BuildCatalog(IReadOnlyList<string> allowedSizes)
{
if (allowedSizes == null || allowedSizes.Count == 0)
return All;
var result = new List<Entry>(allowedSizes.Count);
foreach (var label in allowedSizes)
{
if (TryParseEntry(label, out var entry))
result.Add(entry);
}
return result;
}
private static bool TryParseEntry(string label, out Entry entry)
{
if (TryGet(label, out entry))
return true;
// Accept ad-hoc "WxL" strings (e.g. "50x100", "50 x 100").
if (!string.IsNullOrWhiteSpace(label))
{
var parts = label.Split(new[] { 'x', 'X' }, 2);
if (parts.Length == 2
&& double.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var a)
&& double.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var b)
&& a > 0 && b > 0)
{
var width = System.Math.Min(a, b);
var length = System.Math.Max(a, b);
entry = new Entry(label.Trim(), width, length);
return true;
}
}
entry = default;
return false;
}
private static Entry? PickBest(IReadOnlyList<Entry> catalog, double width, double length, PlateSizeSelection selection)
{
var fitting = catalog.Where(e => e.Fits(width, length));
fitting = selection switch
{
PlateSizeSelection.NarrowestFirst => fitting.OrderBy(e => e.Width).ThenBy(e => e.Area),
_ => fitting.OrderBy(e => e.Area).ThenBy(e => e.Width),
};
foreach (var candidate in fitting)
return candidate;
return null;
}
}
public readonly record struct PlateSizeResult(double Width, double Length, string MatchedLabel)
{
public bool IsStandard => MatchedLabel != null;
}
public sealed class PlateSizeOptions
{
/// <summary>
/// If the margin-adjusted bounding box fits within MinSheetWidth x MinSheetLength
/// the result is snapped to <see cref="SnapIncrement"/> instead of routed to a
/// standard sheet. Default 48" x 48".
/// </summary>
public double MinSheetWidth { get; set; } = 48;
public double MinSheetLength { get; set; } = 48;
/// <summary>
/// Increment used for below-threshold rounding and oversize fallback. Default 1".
/// </summary>
public double SnapIncrement { get; set; } = 1.0;
/// <summary>
/// Extra clearance added to each side of the bounding box before matching.
/// </summary>
public double Margin { get; set; } = 0;
/// <summary>
/// Optional whitelist. When non-empty, only these sizes are considered.
/// Entries may be standard catalog labels (e.g. "48x96") or arbitrary
/// "WxL" strings (e.g. "50x100").
/// </summary>
public IReadOnlyList<string> AllowedSizes { get; set; }
/// <summary>
/// Tiebreaker when multiple sheets can contain the bounding box.
/// </summary>
public PlateSizeSelection Selection { get; set; } = PlateSizeSelection.SmallestArea;
}
public enum PlateSizeSelection
{
/// <summary>Pick the cheapest sheet that contains the bbox (smallest area).</summary>
SmallestArea,
/// <summary>Prefer narrower-width sheets (e.g. 48-wide before 60-wide).</summary>
NarrowestFirst,
}
}
+2
View File
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
public double Length { get; set; }
public double Width { get; set; }
public override string GenerateName() => $"Rectangle {Dim(Length)}x{Dim(Width)}";
public override void SetPreviewDefaults()
{
Length = 12;
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
public double Width { get; set; }
public double Height { get; set; }
public override string GenerateName() => $"Right Triangle {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 8;
+2
View File
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
public double OuterDiameter { get; set; }
public double InnerDiameter { get; set; }
public override string GenerateName() => $"Ring {Dim(OuterDiameter)}x{Dim(InnerDiameter)}";
public override void SetPreviewDefaults()
{
OuterDiameter = 10;
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
public double Width { get; set; }
public double Radius { get; set; }
public override string GenerateName() => $"Rounded Rectangle {Dim(Length)}x{Dim(Width)} R{Dim(Radius)}";
public override void SetPreviewDefaults()
{
Length = 12;
+10
View File
@@ -26,6 +26,14 @@ namespace OpenNest.Shapes
public abstract Drawing GetDrawing();
public virtual string GenerateName()
{
var typeName = GetType().Name;
return typeName.EndsWith("Shape")
? typeName.Substring(0, typeName.Length - 5)
: typeName;
}
public virtual void SetPreviewDefaults() { }
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
@@ -34,6 +42,8 @@ namespace OpenNest.Shapes
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
}
protected static string Dim(double value) => value.ToString("0.###");
protected Drawing CreateDrawing(List<Entity> entities)
{
var pgm = ConvertGeometry.ToProgram(entities);
+2
View File
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
public double StemWidth { get; set; }
public double BarHeight { get; set; }
public override string GenerateName() => $"T {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 10;
+2
View File
@@ -9,6 +9,8 @@ namespace OpenNest.Shapes
public double BottomWidth { get; set; }
public double Height { get; set; }
public override string GenerateName() => $"Trapezoid {Dim(TopWidth)}x{Dim(BottomWidth)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
TopWidth = 6;
+342 -188
View File
@@ -32,12 +32,20 @@ public static class DrawingSplitter
var regions = BuildClipRegions(sortedLines, bounds);
var feature = GetFeature(parameters.Type);
// Polygonize cutouts once. Used for trimming feature edges (so cut lines
// don't travel through a cutout interior) and for hole/containment tests
// in the final component-assembly pass.
var cutoutPolygons = profile.Cutouts
.Select(c => c.ToPolygon())
.Where(p => p != null)
.ToList();
var results = new List<Drawing>();
var pieceIndex = 1;
foreach (var region in regions)
{
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons);
if (pieceEntities.Count == 0)
continue;
@@ -47,9 +55,16 @@ public static class DrawingSplitter
allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities);
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
results.Add(piece);
pieceIndex++;
// A single region may yield multiple physically-disjoint pieces when an
// interior cutout spans across it. Group the region's entities into
// connected closed loops, nest holes by containment, and emit one
// Drawing per outer loop (with its contained holes).
foreach (var pieceOfRegion in AssemblePieces(allEntities))
{
var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region);
results.Add(piece);
pieceIndex++;
}
}
return results;
@@ -218,100 +233,108 @@ public static class DrawingSplitter
/// and stitching in feature edges. No polygon clipping library needed.
/// </summary>
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
List<Polygon> cutoutPolygons)
{
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>();
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
foreach (var entity in perimeter.Entities)
{
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
}
ProcessEntity(entity, region, entities);
if (entities.Count == 0)
return new List<Entity>();
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
EnsurePerimeterWinding(entities);
InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
// Winding is handled later in AssemblePieces, once connected components
// are known. At this stage the piece may still be multiple disjoint loops.
return entities;
}
private static void ProcessEntity(Entity entity, Box region,
List<SplitLine> boundarySplitLines, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
{
// Find the first boundary split line this entity crosses
SplitLine crossedLine = null;
Vector? intersectionPt = null;
foreach (var sl in boundarySplitLines)
{
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
{
var pt = SplitLineIntersect.FindIntersection(entity, sl);
if (pt != null)
{
crossedLine = sl;
intersectionPt = pt;
break;
}
}
}
if (crossedLine != null)
{
// Entity crosses a split line — split it and keep the half inside the region
var regionSide = RegionSideOf(region, crossedLine);
var startPt = GetStartPoint(entity);
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
var startInRegion = startSide == regionSide || startSide == 0;
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
}
else
{
// Entity doesn't cross any boundary split line — check if it's inside the region
var mid = MidPoint(entity);
if (region.Contains(mid))
entities.Add(entity);
}
}
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
SplitLine crossedLine, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
{
if (entity is Line line)
{
var (first, second) = line.SplitAt(point);
if (startInRegion)
{
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
if (clipped == null) return;
if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
return;
}
else if (entity is Arc arc)
if (entity is Arc arc)
{
var (first, second) = arc.SplitAt(point);
if (startInRegion)
{
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
foreach (var sub in ClipArcToRegion(arc, region))
entities.Add(sub);
return;
}
}
/// <summary>
/// Clips an arc against the four edges of a region box. Returns the sub-arcs
/// whose midpoints lie inside the region. Uses line-arc intersection to find
/// split points, then iteratively bisects the arc at each crossing.
/// </summary>
private static List<Arc> ClipArcToRegion(Arc arc, Box region)
{
var edges = new[]
{
new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)),
new Line(new Vector(region.Right, region.Bottom), new Vector(region.Right, region.Top)),
new Line(new Vector(region.Right, region.Top), new Vector(region.Left, region.Top)),
new Line(new Vector(region.Left, region.Top), new Vector(region.Left, region.Bottom))
};
var arcs = new List<Arc> { arc };
foreach (var edge in edges)
{
var next = new List<Arc>();
foreach (var a in arcs)
{
if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0)
{
next.Add(a);
continue;
}
// Split the arc at each intersection that actually lies on one of
// the working sub-arcs. Prior splits may make some original hits
// moot for the sub-arc that now holds them.
var working = new List<Arc> { a };
foreach (var pt in pts)
{
var replaced = new List<Arc>();
foreach (var w in working)
{
var onArc = OpenNest.Math.Angle.IsBetweenRad(
w.Center.AngleTo(pt), w.StartAngle, w.EndAngle, w.IsReversed);
if (!onArc)
{
replaced.Add(w);
continue;
}
var (first, second) = w.SplitAt(pt);
if (first != null && first.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(first);
if (second != null && second.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(second);
}
working = replaced;
}
next.AddRange(working);
}
arcs = next;
}
var result = new List<Arc>();
foreach (var a in arcs)
{
if (region.Contains(a.MidPoint()))
result.Add(a);
}
return result;
}
/// <summary>
/// Returns split lines whose position matches a boundary edge of the region.
/// </summary>
@@ -365,104 +388,157 @@ public static class DrawingSplitter
}
/// <summary>
/// Groups split points by split line, pairs exits with entries, and generates feature edges.
/// For each boundary split line of the region, generates a feature edge that
/// spans the full region boundary along that split line and trims it against
/// interior cutouts. This produces one (or zero) feature edge per contiguous
/// material interval on the boundary, handling corner regions (one perimeter
/// crossing), spanning cutouts (two holes puncturing the line), and
/// normal mid-part splits uniformly.
/// </summary>
private static void InsertFeatureEdges(List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
Box region, List<SplitLine> boundarySplitLines,
ISplitFeature feature, SplitParameters parameters)
ISplitFeature feature, SplitParameters parameters,
List<Polygon> cutoutPolygons)
{
// Group split points by their split line
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
foreach (var sp in splitPoints)
foreach (var sl in boundarySplitLines)
{
if (!groups.ContainsKey(sp.Line))
groups[sp.Line] = new List<(Vector, bool)>();
groups[sp.Line].Add((sp.Point, sp.IsExit));
}
var isVertical = sl.Axis == CutOffAxis.Vertical;
var extentStart = isVertical ? region.Bottom : region.Left;
var extentEnd = isVertical ? region.Top : region.Right;
foreach (var kvp in groups)
{
var sl = kvp.Key;
var points = kvp.Value;
// Pair each exit with the next entry
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
if (exits.Count == 0 || entries.Count == 0)
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
continue;
// For each exit, find the matching entry to form the feature edge span
// Sort exits and entries by their position along the split line
var isVertical = sl.Axis == CutOffAxis.Vertical;
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
// Pair them up: each exit with the next entry (or vice versa)
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
// Trim any line segments that cross a cutout — cut lines must never
// travel through a hole.
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
entities.AddRange(featureEdge);
}
}
/// <summary>
/// Subtracts any portions of line entities in <paramref name="featureEdge"/> that
/// lie inside any of the supplied cutout polygons. Non-line entities (arcs) are
/// passed through unchanged; a tighter fix for arcs in feature edges (weld-gap
/// tabs, spike-groove) can be added later if a test demands it.
/// </summary>
private static List<Entity> TrimFeatureEdgeAgainstCutouts(List<Entity> featureEdge, List<Polygon> cutoutPolygons)
{
if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
return featureEdge;
var result = new List<Entity>();
foreach (var entity in featureEdge)
{
if (entity is Line line)
result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons));
else
result.Add(entity);
}
return result;
}
/// <summary>
/// Returns the sub-segments of <paramref name="line"/> that lie outside every
/// cutout polygon. Handles the common axis-aligned feature-edge case exactly.
/// </summary>
private static List<Line> SubtractCutoutsFromLine(Line line, List<Polygon> cutoutPolygons)
{
// Collect parameter values t in [0,1] where the line crosses any cutout edge.
var ts = new List<double> { 0.0, 1.0 };
foreach (var poly in cutoutPolygons)
{
var polyLines = poly.ToLines();
foreach (var edge in polyLines)
{
var exitPt = exits[i];
var entryPt = entries[i];
var extentStart = isVertical
? System.Math.Min(exitPt.Y, entryPt.Y)
: System.Math.Min(exitPt.X, entryPt.X);
var extentEnd = isVertical
? System.Math.Max(exitPt.Y, entryPt.Y)
: System.Math.Max(exitPt.X, entryPt.X);
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
if (featureEdge.Count > 0)
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
entities.AddRange(featureEdge);
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
{
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
ts.Add(t);
}
}
}
}
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
{
var featureStart = GetStartPoint(featureEdge[0]);
var featureEnd = GetEndPoint(featureEdge[^1]);
var isVertical = axis == CutOffAxis.Vertical;
ts.Sort();
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
if (edgeGoesForward != featureGoesForward)
var segments = new List<Line>();
for (var i = 0; i < ts.Count - 1; i++)
{
featureEdge = new List<Entity>(featureEdge);
featureEdge.Reverse();
foreach (var e in featureEdge)
e.Reverse();
var t0 = ts[i];
var t1 = ts[i + 1];
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
var tMid = (t0 + t1) * 0.5;
var mid = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid);
var insideCutout = false;
foreach (var poly in cutoutPolygons)
{
if (poly.ContainsPoint(mid))
{
insideCutout = true;
break;
}
}
if (insideCutout) continue;
var p0 = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0);
var p1 = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1);
segments.Add(new Line(p0, p1));
}
return featureEdge;
return segments;
}
private static void EnsurePerimeterWinding(List<Entity> entities)
/// <summary>
/// Segment-segment intersection. On hit, returns the parameter t along segment AB
/// (0 = a0, 1 = a1) via <paramref name="tOnA"/>.
/// </summary>
private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA)
{
var shape = new Shape();
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CW)
shape.Reverse();
tOnA = 0;
var rx = a1.X - a0.X;
var ry = a1.Y - a0.Y;
var sx = b1.X - b0.X;
var sy = b1.Y - b0.Y;
entities.Clear();
entities.AddRange(shape.Entities);
var denom = rx * sy - ry * sx;
if (System.Math.Abs(denom) < Math.Tolerance.Epsilon)
return false;
var dx = b0.X - a0.X;
var dy = b0.Y - a0.Y;
var t = (dx * sy - dy * sx) / denom;
var u = (dx * ry - dy * rx) / denom;
if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false;
if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false;
tOnA = t;
return true;
}
private static bool IsCutoutInRegion(Shape cutout, Box region)
{
if (cutout.Entities.Count == 0) return false;
var pt = GetStartPoint(cutout.Entities[0]);
return region.Contains(pt);
var bb = cutout.BoundingBox;
// Fully contained iff the cutout's bounding box fits inside the region.
return bb.Left >= region.Left - Math.Tolerance.Epsilon
&& bb.Right <= region.Right + Math.Tolerance.Epsilon
&& bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon
&& bb.Top <= region.Top + Math.Tolerance.Epsilon;
}
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
@@ -479,57 +555,135 @@ public static class DrawingSplitter
}
/// <summary>
/// Clip a cutout shape to a region by walking entities, splitting at split line
/// intersections, keeping portions inside the region, and closing gaps with
/// straight lines. No polygon clipping library needed.
/// Clip a cutout shape to a region by walking entities and splitting at split-line
/// crossings. Only returns the cutout-edge fragments that lie inside the region —
/// it deliberately does NOT emit synthetic closing lines at the region boundary.
///
/// Rationale: a closing line on the region boundary would overlap the split-line
/// feature edge and reintroduce a cut through the cutout interior. The feature
/// edge (trimmed against cutouts in <see cref="InsertFeatureEdges"/>) and these
/// cutout fragments are stitched together later by <see cref="AssemblePieces"/>
/// using endpoint connectivity, which produces the correct closed loops — one
/// loop per physically-connected strip of material.
/// </summary>
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
{
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>();
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
foreach (var entity in cutout.Entities)
ProcessEntity(entity, region, entities);
return entities;
}
/// <summary>
/// Groups a region's entities into closed components and nests holes inside
/// outer loops by point-in-polygon containment. Returns one entity list per
/// output <see cref="Drawing"/> — outer loop first, then its contained holes.
/// Each outer loop is normalized to CW winding and each hole to CCW.
/// </summary>
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
{
var pieces = new List<List<Entity>>();
if (entities.Count == 0) return pieces;
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0) return pieces;
// Polygonize every shape once so we can run containment tests.
var polygons = new List<Polygon>(shapes.Count);
foreach (var s in shapes)
polygons.Add(s.ToPolygon());
// Classify each shape as outer or hole using nesting by containment.
// Shape A is contained in shape B iff A's bounding box is strictly inside
// B's bounding box AND a representative vertex of A lies inside B's polygon.
// The bbox pre-check avoids the ambiguity of bbox-center tests when two
// shapes share a center (e.g., an outer half and a centered cutout).
var isHole = new bool[shapes.Count];
for (var i = 0; i < shapes.Count; i++)
{
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
var bbA = shapes[i].BoundingBox;
var repA = FirstVertexOf(shapes[i]);
for (var j = 0; j < shapes.Count; j++)
{
if (i == j) continue;
if (polygons[j] == null) continue;
if (polygons[j].Vertices.Count < 3) continue;
var bbB = shapes[j].BoundingBox;
if (!BoxContainsBox(bbB, bbA)) continue;
if (!polygons[j].ContainsPoint(repA)) continue;
isHole[i] = true;
break;
}
}
if (entities.Count == 0)
return new List<Entity>();
// Close gaps with straight lines (connect exit→entry pairs)
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
foreach (var sp in splitPoints)
// For each outer, attach the holes that fall inside it.
for (var i = 0; i < shapes.Count; i++)
{
if (!groups.ContainsKey(sp.Line))
groups[sp.Line] = new List<(Vector, bool)>();
groups[sp.Line].Add((sp.Point, sp.IsExit));
if (isHole[i]) continue;
var outer = shapes[i];
var outerPoly = polygons[i];
// Enforce perimeter winding = CW.
if (outerPoly != null && outerPoly.Vertices.Count >= 3
&& outerPoly.RotationDirection() != RotationType.CW)
outer.Reverse();
var piece = new List<Entity>();
piece.AddRange(outer.Entities);
for (var j = 0; j < shapes.Count; j++)
{
if (!isHole[j]) continue;
if (polygons[i] == null || polygons[i].Vertices.Count < 3) continue;
var bbJ = shapes[j].BoundingBox;
if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) continue;
var rep = FirstVertexOf(shapes[j]);
if (!polygons[i].ContainsPoint(rep)) continue;
var hole = shapes[j];
var holePoly = polygons[j];
if (holePoly != null && holePoly.Vertices.Count >= 3
&& holePoly.RotationDirection() != RotationType.CCW)
hole.Reverse();
piece.AddRange(hole.Entities);
}
pieces.Add(piece);
}
foreach (var kvp in groups)
{
var sl = kvp.Key;
var points = kvp.Value;
var isVertical = sl.Axis == CutOffAxis.Vertical;
return pieces;
}
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
/// <summary>
/// Returns the first vertex of a shape (start point of its first entity). Used as
/// a representative for containment testing: if bbox pre-check says the whole
/// shape is inside another, testing one vertex is sufficient to confirm.
/// </summary>
private static Vector FirstVertexOf(Shape shape)
{
if (shape.Entities.Count == 0)
return new Vector(0, 0);
return GetStartPoint(shape.Entities[0]);
}
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
entities.Add(new Line(exits[i], entries[i]));
}
// Ensure CCW winding for cutouts
var shape = new Shape();
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CCW)
shape.Reverse();
return shape.Entities;
/// <summary>
/// True iff box <paramref name="inner"/> is entirely inside box
/// <paramref name="outer"/> (tolerant comparison).
/// </summary>
private static bool BoxContainsBox(Box outer, Box inner)
{
var eps = Math.Tolerance.Epsilon;
return inner.Left >= outer.Left - eps
&& inner.Right <= outer.Right + eps
&& inner.Bottom >= outer.Bottom - eps
&& inner.Top <= outer.Top + eps;
}
private static Vector GetStartPoint(Entity entity)
+10 -4
View File
@@ -24,6 +24,9 @@ namespace OpenNest.Engine.BestFit
if (_cache.TryGetValue(key, out var cached))
return cached;
// Operate on the canonical frame so cached pair positions are orientation-invariant.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
IPairEvaluator evaluator = null;
ISlideComputer slideComputer = null;
@@ -31,7 +34,7 @@ namespace OpenNest.Engine.BestFit
{
if (CreateEvaluator != null)
{
try { evaluator = CreateEvaluator(drawing, spacing); }
try { evaluator = CreateEvaluator(canonical, spacing); }
catch { /* fall back to default evaluator */ }
}
@@ -42,7 +45,7 @@ namespace OpenNest.Engine.BestFit
}
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
var results = finder.FindBestFits(drawing, spacing, StepSize);
var results = finder.FindBestFits(canonical, spacing, StepSize);
_cache.TryAdd(key, results);
return results;
@@ -86,9 +89,12 @@ namespace OpenNest.Engine.BestFit
try
{
// Operate on the canonical frame so cached pair positions are orientation-invariant.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
if (CreateEvaluator != null)
{
try { evaluator = CreateEvaluator(drawing, spacing); }
try { evaluator = CreateEvaluator(canonical, spacing); }
catch { /* fall back to default evaluator */ }
}
@@ -100,7 +106,7 @@ namespace OpenNest.Engine.BestFit
// Compute candidates and evaluate once with the largest plate.
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
var baseResults = finder.FindBestFits(drawing, spacing, StepSize);
var baseResults = finder.FindBestFits(canonical, spacing, StepSize);
// Cache a filtered copy for each plate size.
foreach (var size in needed)
@@ -1,18 +1,10 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.IO;
namespace OpenNest.Engine.BestFit
{
public class NfpSlideStrategy : IBestFitStrategy
{
private static readonly string LogPath = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
"nfp-slide-debug.log");
private static readonly object LogLock = new object();
private readonly double _part2Rotation;
private readonly Polygon _stationaryPerimeter;
private readonly Polygon _stationaryHull;
@@ -46,12 +38,6 @@ namespace OpenNest.Engine.BestFit
var hull = ConvexHull.Compute(result.Polygon.Vertices);
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
return new NfpSlideStrategy(part2Rotation, type, description,
result.Polygon, hull, result.Correction);
}
@@ -63,40 +49,17 @@ namespace OpenNest.Engine.BestFit
if (stepSize <= 0)
return candidates;
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
// Orbiting polygon: same shape rotated to Part2's angle.
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
if (nfp == null || nfp.Vertices.Count < 3)
{
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
return candidates;
}
var verts = nfp.Vertices;
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
// Log NFP vertices
for (var v = 0; v < vertCount; v++)
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
// Compare with what RotationSlideStrategy would produce
var part1 = Part.CreateAtOrigin(drawing);
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
var testNumber = 0;
for (var i = 0; i < vertCount; i++)
@@ -125,20 +88,6 @@ namespace OpenNest.Engine.BestFit
}
}
// Log overlap check for vertex candidates (first few)
var checkCount = System.Math.Min(vertCount, 8);
for (var c = 0; c < checkCount; c++)
{
var cand = candidates[c];
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
p2.Location = cand.Part2Offset;
var overlaps = part1.Intersects(p2, out _);
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
}
Log($" Total candidates: {candidates.Count}");
Log("");
return candidates;
}
@@ -160,20 +109,5 @@ namespace OpenNest.Engine.BestFit
Spacing = spacing
};
}
private static string FormatBounds(Polygon polygon)
{
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
}
private static void Log(string message)
{
lock (LogLock)
{
File.AppendAllText(LogPath, message + "\n");
}
}
}
}
+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;
}
}
return false;
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);
}
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;
+76
View File
@@ -0,0 +1,76 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Engine
{
/// <summary>
/// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
/// and un-rotates placed parts back to the drawing's original frame.
/// </summary>
public static class CanonicalFrame
{
/// <summary>
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
/// The source drawing is not mutated.
/// </summary>
public static Drawing AsCanonicalCopy(Drawing drawing)
{
if (drawing == null)
return null;
var angle = drawing.Source?.Angle ?? 0.0;
// Clone program (never mutate the source).
var pgm = (drawing.Program.Clone() as OpenNest.CNC.Program)
?? new OpenNest.CNC.Program();
if (!Tolerance.IsEqualTo(angle, 0))
pgm.Rotate(angle, pgm.BoundingBox().Center);
var copy = new Drawing(drawing.Name ?? string.Empty, pgm)
{
Color = drawing.Color,
Constraints = drawing.Constraints,
Material = drawing.Material,
Priority = drawing.Priority,
Customer = drawing.Customer,
IsCutOff = drawing.IsCutOff,
Source = new SourceInfo
{
Path = drawing.Source?.Path,
Offset = drawing.Source?.Offset ?? new Vector(0, 0),
Angle = 0.0,
},
};
return copy;
}
/// <summary>
/// Composes the source drawing's canonical angle onto each placed part so the
/// returned list is in the drawing's original (visible) frame.
///
/// Derivation: let sourceAngle = S (rotation mapping source -> canonical).
/// Canonical part at rotation R shows visible orientation R.
/// Source part at rotation R' shows visible orientation R' + (-S), because the
/// source geometry is already rotated by -S relative to canonical.
/// Setting equal gives R' = R + S, so we ADD sourceAngle to each placed part.
///
/// Rotation is performed around the part's Location so its placement position is preserved;
/// only the orientation composes.
/// </summary>
public static List<Part> FromCanonical(List<Part> placed, double sourceAngle)
{
if (placed == null || placed.Count == 0)
return placed;
if (Tolerance.IsEqualTo(sourceAngle, 0))
return placed;
foreach (var p in placed)
p.Rotate(sourceAngle, p.Location);
return placed;
}
}
}
+63 -19
View File
@@ -47,14 +47,29 @@ namespace OpenNest
PhaseResults.Clear();
AngleResults.Clear();
// Fast path: for very small quantities, skip the full strategy pipeline.
if (item.Quantity > 0 && item.Quantity <= 2)
// Replace the item's Drawing with a canonical copy for the duration of this fill.
// All internal methods see canonical geometry; this wrapper un-canonicalizes the final result.
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
var originalDrawing = item.Drawing;
var canonicalItem = new NestItem
{
var fast = TryFillSmallQuantity(item, workArea);
if (fast != null && fast.Count >= item.Quantity)
Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing),
Quantity = item.Quantity,
Priority = item.Priority,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
StepAngle = item.StepAngle,
};
// Fast path for qty 1-2.
if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2)
{
var fast = TryFillSmallQuantity(canonicalItem, workArea);
if (fast != null && fast.Count >= canonicalItem.Quantity)
{
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}");
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}");
WinnerPhase = NestPhase.Pairs;
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport
{
Phase = WinnerPhase,
@@ -68,32 +83,30 @@ namespace OpenNest
}
}
// For low quantities, shrink the work area in both dimensions to avoid
// running expensive strategies against the full plate.
var effectiveWorkArea = workArea;
if (item.Quantity > 0)
if (canonicalItem.Quantity > 0)
{
effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing);
effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing);
if (effectiveWorkArea != workArea)
Debug.WriteLine($"[Fill] Low-qty shrink: {item.Quantity} requested, " +
Debug.WriteLine($"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " +
$"from {workArea.Width:F1}x{workArea.Length:F1} " +
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
}
var best = RunFillPipeline(item, effectiveWorkArea, progress, token);
var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token);
// Fallback: if the reduced area didn't yield enough, retry with full area.
if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea)
if (canonicalItem.Quantity > 0 && best.Count < canonicalItem.Quantity && effectiveWorkArea != workArea)
{
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area");
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area");
PhaseResults.Clear();
AngleResults.Clear();
best = RunFillPipeline(item, workArea, progress, token);
best = RunFillPipeline(canonicalItem, workArea, progress, token);
}
if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport
{
@@ -108,6 +121,31 @@ namespace OpenNest
return best;
}
/// <summary>
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
/// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy)
/// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical.
/// </summary>
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original, double sourceAngle)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
// Rebind to `original` while preserving world pose. CreateAtOrigin rotates
// at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox.
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
/// <summary>
/// Fast path for qty 1-2: place a single part or a best-fit pair
/// without running the full strategy pipeline.
@@ -139,6 +177,10 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
// Build pair candidates with a canonical drawing so their geometry matches
// the coordinate frame of the cached fit results.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing);
List<Part> bestPlacement = null;
foreach (var fit in bestFits)
@@ -152,7 +194,7 @@ namespace OpenNest
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue;
var landscape = fit.BuildParts(drawing);
var landscape = fit.BuildParts(canonicalDrawing);
var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea);
@@ -174,6 +216,8 @@ namespace OpenNest
bestPlacement = candidate;
}
// Parts are returned in canonical frame, bound to the canonical drawing.
// The outer Fill wrapper (Task 7) rebinds to `drawing` and composes sourceAngle onto rotation.
return bestPlacement;
}
+55 -130
View File
@@ -61,92 +61,91 @@ namespace OpenNest.Engine.Fill
: NestDirection.Horizontal;
}
/// <summary>
/// Computes the slide distance for the push algorithm, returning the
/// geometry-aware copy distance along the given axis.
/// </summary>
private double ComputeCopyDistance(double bboxDim, double slideDistance)
{
if (slideDistance >= double.MaxValue || slideDistance < 0)
return bboxDim + PartSpacing;
// The geometry-aware slide can produce a copy distance smaller than
// the part itself when inflated corner/arc vertices interact spuriously.
// Clamp to bboxDim + PartSpacing to prevent bounding box overlap.
return System.Math.Max(bboxDim - slideDistance, bboxDim + PartSpacing);
}
/// <summary>
/// Finds the geometry-aware copy distance between two identical parts along an axis.
/// Both parts are inflated by half-spacing for symmetric spacing.
/// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled
/// exactly without polygon sampling error.
/// </summary>
private double FindCopyDistance(Part partA, NestDirection direction, PartBoundary boundary)
private double FindCopyDistance(Part partA, NestDirection direction)
{
var bboxDim = GetDimension(partA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset);
var locationBOffset = MakeOffset(direction, bboxDim);
var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing);
var movingEntities = PartGeometry.GetOffsetPerimeterEntities(
partA.CloneAtOffset(offset), HalfSpacing);
// Use the most efficient array-based overload to avoid all allocations.
var slideDistance = SpatialQuery.DirectionalDistance(
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
pushDir);
movingEntities, stationaryEntities, pushDir);
return ComputeCopyDistance(bboxDim, slideDistance);
if (slideDistance >= double.MaxValue || slideDistance < 0)
return bboxDim + PartSpacing;
return startOffset - slideDistance;
}
/// <summary>
/// Finds the geometry-aware copy distance between two identical patterns along an axis.
/// Checks every pair of parts across adjacent patterns so that multi-part
/// patterns (e.g. interlocking pairs) maintain spacing between ALL parts.
/// Both sides are inflated by half-spacing for symmetric spacing.
/// Checks every pair of parts across adjacent pattern copies so multi-part patterns
/// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity
/// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs
/// are exact and no bbox clamp is needed.
/// </summary>
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary[] boundaries)
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction)
{
if (patternA.Parts.Count <= 1)
return FindSinglePartPatternCopyDistance(patternA, direction, boundaries[0]);
if (patternA.Parts.Count == 1)
return FindCopyDistance(patternA.Parts[0], direction);
var bboxDim = GetDimension(patternA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var opposite = SpatialQuery.OppositeDirection(pushDir);
var dirVec = SpatialQuery.DirectionToOffset(pushDir, 1.0);
// bboxDim already spans max(upper) - min(lower) across all parts,
// so the start offset just needs to push beyond that plus spacing.
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset);
var maxCopyDistance = FindMaxPairDistance(
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
var parts = patternA.Parts;
var stationaryBoxes = new Box[parts.Count];
var movingBoxes = new Box[parts.Count];
var stationaryEntities = new List<Entity>[parts.Count];
var movingEntities = new List<Entity>[parts.Count];
// 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);
}
for (var i = 0; i < parts.Count; i++)
{
stationaryBoxes[i] = parts[i].BoundingBox;
movingBoxes[i] = stationaryBoxes[i].Translate(offset);
}
/// <summary>
/// Tests every pair of parts across adjacent pattern copies and returns the
/// maximum copy distance found. Returns 0 if no valid slide was found.
/// </summary>
private static double FindMaxPairDistance(
List<Part> parts, PartBoundary[] boundaries, Vector offset,
PushDirection pushDir, PushDirection opposite, double startOffset)
{
var maxCopyDistance = 0.0;
for (var j = 0; j < parts.Count; j++)
{
var movingEdges = boundaries[j].GetEdges(pushDir);
var locationB = parts[j].Location + offset;
var movingBox = movingBoxes[j];
for (var i = 0; i < parts.Count; i++)
{
var stationaryBox = stationaryBoxes[i];
// Skip if stationary is already ahead of moving in the push direction
// (sliding forward would take them further apart).
if (SpatialQuery.DirectionalGap(movingBox, stationaryBox, opposite) > 0)
continue;
// Skip if bboxes can't overlap along the axis perpendicular to the push.
if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec))
continue;
stationaryEntities[i] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[i], HalfSpacing);
movingEntities[j] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[j].CloneAtOffset(offset), HalfSpacing);
var slideDistance = SpatialQuery.DirectionalDistance(
movingEdges, locationB,
boundaries[i].GetEdges(opposite), parts[i].Location,
pushDir);
movingEntities[j], stationaryEntities[i], pushDir);
if (slideDistance >= double.MaxValue || slideDistance < 0)
continue;
@@ -161,86 +160,15 @@ namespace OpenNest.Engine.Fill
return maxCopyDistance;
}
/// <summary>
/// Fast path for single-part patterns — no cross-part conflicts possible.
/// </summary>
private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary)
{
var template = patternA.Parts[0];
return FindCopyDistance(template, direction, boundary);
}
/// <summary>
/// Gets offset boundary lines for all parts in a pattern using a shared boundary.
/// </summary>
private static List<Line> GetPatternLines(Pattern pattern, PartBoundary boundary, PushDirection direction)
{
var lines = new List<Line>();
foreach (var part in pattern.Parts)
lines.AddRange(boundary.GetLines(part.Location, direction));
return lines;
}
/// <summary>
/// Gets boundary lines for all parts in a pattern, with an additional
/// location offset applied. Avoids cloning the pattern.
/// </summary>
private static List<Line> GetOffsetPatternLines(Pattern pattern, Vector offset, PartBoundary boundary, PushDirection direction)
{
var lines = new List<Line>();
foreach (var part in pattern.Parts)
lines.AddRange(boundary.GetLines(part.Location + offset, direction));
return lines;
}
/// <summary>
/// Creates boundaries for all parts in a pattern. Parts that share the same
/// program geometry (same drawing and rotation) reuse the same boundary instance.
/// </summary>
private PartBoundary[] CreateBoundaries(Pattern pattern)
{
var boundaries = new PartBoundary[pattern.Parts.Count];
var cache = new List<(Drawing drawing, double rotation, PartBoundary boundary)>();
for (var i = 0; i < pattern.Parts.Count; i++)
{
var part = pattern.Parts[i];
PartBoundary found = null;
foreach (var entry in cache)
{
if (entry.drawing == part.BaseDrawing && entry.rotation.IsEqualTo(part.Rotation))
{
found = entry.boundary;
break;
}
}
if (found == null)
{
found = new PartBoundary(part, HalfSpacing);
cache.Add((part.BaseDrawing, part.Rotation, found));
}
boundaries[i] = found;
}
return boundaries;
}
/// <summary>
/// Tiles a pattern along the given axis, returning the cloned parts
/// (does not include the original pattern's parts). For multi-part
/// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area.
/// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
private List<Part> TilePattern(Pattern basePattern, NestDirection direction)
{
var copyDistance = FindPatternCopyDistance(basePattern, direction, boundaries);
var copyDistance = FindPatternCopyDistance(basePattern, direction);
if (copyDistance <= 0)
return new List<Part>();
@@ -394,11 +322,10 @@ namespace OpenNest.Engine.Fill
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
{
var perpAxis = PerpendicularAxis(direction);
var boundaries = CreateBoundaries(pattern);
// Step 1: Tile along primary axis
var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction, boundaries));
row.AddRange(TilePattern(pattern, direction));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
{
@@ -410,7 +337,7 @@ namespace OpenNest.Engine.Fill
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
{
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
row.AddRange(TilePattern(pattern, perpAxis));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
{
@@ -427,9 +354,8 @@ namespace OpenNest.Engine.Fill
rowPattern.Parts.AddRange(row);
rowPattern.UpdateBounds();
var rowBoundaries = CreateBoundaries(rowPattern);
var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
gridResult.AddRange(TilePattern(rowPattern, perpAxis));
if (HasOverlappingParts(gridResult, out var a3, out var b3))
{
@@ -481,9 +407,8 @@ namespace OpenNest.Engine.Fill
return seed;
var template = seed.Parts[0];
var boundary = new PartBoundary(template, HalfSpacing);
var copyDistance = FindCopyDistance(template, direction, boundary);
var copyDistance = FindCopyDistance(template, direction);
if (copyDistance <= 0)
return seed;
+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 piercePoints = new Vector[sequenced.Count];
var currentPoint = exitPoint;
for (var i = 0; i < sequenced.Count; i++)
{
var part = sp.Part;
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;
}
}
}
+9 -6
View File
@@ -27,7 +27,10 @@ namespace OpenNest.Engine.ML
{
public static PartFeatures Extract(Drawing drawing)
{
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program)
// Normalize to canonical frame so features are invariant to import orientation.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(canonical.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
@@ -45,18 +48,18 @@ namespace OpenNest.Engine.ML
var features = new PartFeatures
{
Area = drawing.Area,
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
Area = canonical.Area,
Convexity = canonical.Area / (hullArea > 0 ? hullArea : 1.0),
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
BoundingBoxFill = canonical.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
VertexCount = polygon.Vertices.Count,
Bitmask = GenerateBitmask(polygon, 32)
};
// Circularity = 4 * PI * Area / Perimeter^2
var perimeterLen = polygon.Perimeter();
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen);
features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0;
features.Circularity = (4 * System.Math.PI * canonical.Area) / (perimeterLen * perimeterLen);
features.PerimeterToAreaRatio = canonical.Area > 0 ? perimeterLen / canonical.Area : 0;
return features;
}
+35 -1
View File
@@ -334,6 +334,12 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
// BestFitCache stores pair coordinates in canonical frame. Build candidates
// from a canonical drawing copy so geometry and coords share a frame; rebind
// + un-rotate winning pair to the original drawing's frame before returning.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
List<Part> bestPlacement = null;
Box bestTarget = null;
@@ -342,7 +348,7 @@ namespace OpenNest
if (!fit.Keep)
continue;
var parts = fit.BuildParts(item.Drawing);
var parts = fit.BuildParts(canonicalDrawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width;
var pairL = pairBbox.Length;
@@ -374,6 +380,10 @@ namespace OpenNest
if (bestPlacement == null) continue;
// Rebind to the original drawing and compose sourceAngle onto rotation so the
// final placed parts sit in the user's visible frame.
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
result.AddRange(bestPlacement);
item.Quantity = 0;
@@ -388,6 +398,30 @@ namespace OpenNest
return result;
}
/// <summary>
/// Rebinds each canonical-frame Part in the pair to the original Drawing at its current
/// world pose, then composes sourceAngle onto each via CanonicalFrame.FromCanonical so
/// the returned list is in the original drawing's visible frame. Mirrors
/// DefaultNestEngine.RebindAndUnCanonicalize.
/// </summary>
private static List<Part> RebindPairToOriginal(List<Part> parts, Drawing original, double sourceAngle)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
/// <summary>
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
/// Low-quantity items whose total area is a small fraction of the plate are
+2 -2
View File
@@ -64,8 +64,8 @@ namespace OpenNest.Engine
var mbrArea = mbr.Area;
var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
// Store primary angle (negated to align MBR with axes, same as RotationAnalysis).
result.PrimaryAngle = -mbr.Angle;
// Share the single angle formula with CanonicalAngle (no duplicate MBR compute).
result.PrimaryAngle = CanonicalAngle.FromMbr(mbr);
// Drawing perimeter for circularity and perimeter ratio.
var drawingPerimeter = polygon.Perimeter();
+40 -10
View File
@@ -17,15 +17,38 @@ namespace OpenNest.Engine
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,12 +89,10 @@ 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);
}
+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; }
@@ -133,7 +133,7 @@ namespace OpenNest.IO.Bending
{
return document.Entities
.OfType<ACadSharp.Entities.Line>()
.Where(l => l.Layer?.Name == "BEND"
.Where(l => (l.Layer?.Name == "BEND" || l.Layer?.Name == "0")
&& (l.LineType?.Name?.Contains("CENTER") == true
|| l.LineType?.Name == "CENTERX2"))
.ToList();
+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; }
}
}
+171
View File
@@ -0,0 +1,171 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Bending;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
using OpenNest.Math;
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);
RemoveDuplicateArcs(dxf.Entities);
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;
}
internal static void RemoveDuplicateArcs(List<Entity> entities)
{
var circles = entities.OfType<Circle>().ToList();
var arcs = entities.OfType<Arc>().ToList();
var arcsToRemove = new List<Arc>();
foreach (var arc in arcs)
{
foreach (var circle in circles)
{
if (arc.Layer?.Name != circle.Layer?.Name)
continue;
if (!arc.Center.DistanceTo(circle.Center).IsEqualTo(0))
continue;
if (!arc.Radius.IsEqualTo(circle.Radius))
continue;
arcsToRemove.Add(arc);
break;
}
}
foreach (var arc in arcsToRemove)
entities.Remove(arc);
}
}
}
+12 -3
View File
@@ -181,13 +181,22 @@ namespace OpenNest.IO
{
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var startParam = ellipse.StartParameter;
var endParam = ellipse.EndParameter;
if (ellipse.Normal.Z < 0)
{
var newStart = OpenNest.Math.Angle.TwoPI - endParam;
var newEnd = OpenNest.Math.Angle.TwoPI - startParam;
startParam = newStart;
endParam = newEnd;
}
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var layer = ellipse.Layer.ToOpenNest();
var color = ellipse.ResolveColor();
var lineTypeName = ellipse.ResolveLineTypeName();
+58
View File
@@ -71,10 +71,68 @@ 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 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>)>();
+29 -3
View File
@@ -308,8 +308,32 @@ namespace OpenNest.IO
WriteDrawing(stream, kvp.Value);
var entry = zipArchive.CreateEntry(name);
using var entryStream = entry.Open();
stream.CopyTo(entryStream);
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");
}
}
@@ -448,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}";
}
}
+3
View File
@@ -4,6 +4,9 @@
<RootNamespace>OpenNest.IO</RootNamespace>
<AssemblyName>OpenNest.IO</AssemblyName>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
+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()
+11 -19
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,24 +94,18 @@ namespace OpenNest.Mcp.Tools
if (!File.Exists(path))
return $"Error: file not found: {path}";
var geometry = Dxf.GetGeometry(path);
try
{
var drawing = CadImporter.ImportDrawing(path, new CadImportOptions { Name = name });
_session.Drawings.Add(drawing);
if (geometry.Count == 0)
return "Error: failed to read DXF file or no geometry found";
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();
_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" }
]
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.CNC
{
public class RapidEnumeratorTests
{
[Fact]
public void Enumerate_AbsoluteProgram_OffsetsMotionsByBasePos()
{
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(1, 0));
pgm.Codes.Add(new LinearMove(2, 0));
pgm.Codes.Add(new RapidMove(3, 3));
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
// Origin → first pierce, then interior rapid from contour end to next rapid target.
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(101, 200), segments[0].To);
Assert.Equal(new Vector(102, 200), segments[1].From);
Assert.Equal(new Vector(103, 203), segments[1].To);
}
[Fact]
public void Enumerate_IncrementalProgram_InterpretsDeltasFromBasePos()
{
// Pre-lead-in raw program: first rapid normalized to (0,0), Mode=Incremental
// (matches ConvertGeometry.ToProgram output).
var pgm = new Program(Mode.Incremental);
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(5, 0));
pgm.Codes.Add(new LinearMove(0, 5));
pgm.Codes.Add(new RapidMove(1, 1));
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
Assert.Equal(2, segments.Count);
// First rapid: plate origin → part pierce at basePos.
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(100, 200), segments[0].To);
// Interior rapid: after deltas (5,0) and (0,5) from basePos, rapid delta (1,1).
Assert.Equal(new Vector(105, 205), segments[1].From);
Assert.Equal(new Vector(106, 206), segments[1].To);
}
[Fact]
public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce()
{
// Main program: lead-in rapid, a line, then a SubProgramCall for a hole.
// Sub-program (incremental) starts with RapidMove(radius, 0) to the hole pierce.
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new RapidMove(0.5, 0));
sub.Codes.Add(new LinearMove(0, 0.1));
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(0.2, 0.3)); // first pierce (perimeter lead-in)
pgm.Codes.Add(new LinearMove(1.0, 1.0)); // contour move
pgm.Codes.Add(new SubProgramCall
{
Id = 1,
Program = sub,
Offset = new Vector(2, 2), // hole center (drawing-local)
});
var basePos = new Vector(100, 200); // part.Location
var segments = RapidEnumerator.Enumerate(pgm, basePos, startPos: new Vector(0, 0));
// Expected rapids:
// 1. origin → first pierce (0.2+100, 0.3+200) = (100.2, 200.3)
// 2. end of contour (1+100, 1+200) = (101, 201) → hole pierce (2+100+0.5, 2+200) = (102.5, 202)
// The sub's internal first rapid is skipped (already drawn in #2).
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(100.2, 200.3), segments[0].To);
Assert.Equal(new Vector(101, 201), segments[1].From);
Assert.Equal(new Vector(102.5, 202), segments[1].To);
}
}
}
@@ -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);
}
}
@@ -0,0 +1,156 @@
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.Engine;
public class CanonicalAngleTests
{
private const double AngleTol = 0.002; // ~0.11°
private static Drawing MakeRect(double w, double h)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("rect", pgm);
}
private static Drawing RotateCopy(Drawing src, double angle)
{
var pgm = src.Program.Clone() as OpenNest.CNC.Program;
pgm.Rotate(angle, pgm.BoundingBox().Center);
return new Drawing("rotated", pgm);
}
[Fact]
public void AxisAlignedRectangle_ReturnsZero()
{
var d = MakeRect(100, 50);
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
// Program.BoundingBox() has a pre-existing bug where minX/minY initialize to 0 and can
// only decrease, so programs whose extents stay in the positive half-plane report a
// too-large AABB. To validate MBR-axis-alignment without tripping that bug, extract the
// outer perimeter polygon and compute its true AABB from vertices.
private static (double length, double width) TrueAabb(OpenNest.CNC.Program pgm)
{
var entities = ConvertProgram.ToGeometry(pgm).Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = ShapeBuilder.GetShapes(entities);
var outer = shapes.OrderByDescending(s => s.Area()).First();
var poly = outer.ToPolygonWithTolerance(0.1);
var minX = poly.Vertices.Min(v => v.X);
var maxX = poly.Vertices.Max(v => v.X);
var minY = poly.Vertices.Min(v => v.Y);
var maxY = poly.Vertices.Max(v => v.Y);
return (maxX - minX, maxY - minY);
}
[Theory]
[InlineData(0.3)]
[InlineData(0.7)]
[InlineData(1.2)]
public void Rectangle_ReturnsNegatedRotation_Modulo90(double theta)
{
var rotated = RotateCopy(MakeRect(100, 50), theta);
var angle = CanonicalAngle.Compute(rotated);
// Applying the returned angle should leave MBR axis-aligned.
var canonical = rotated.Program.Clone() as OpenNest.CNC.Program;
canonical.Rotate(angle, canonical.BoundingBox().Center);
var (length, width) = TrueAabb(canonical);
var longer = System.Math.Max(length, width);
var shorter = System.Math.Min(length, width);
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
}
[Fact]
public void NearZeroInput_SnapsToZero()
{
var rotated = RotateCopy(MakeRect(100, 50), 0.0005);
Assert.Equal(0.0, CanonicalAngle.Compute(rotated), precision: 6);
}
[Fact]
public void DegeneratePolygon_ReturnsZero()
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
var d = new Drawing("line", pgm);
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
[Fact]
public void EmptyProgram_ReturnsZero()
{
var d = new Drawing("empty", new OpenNest.CNC.Program());
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
}
public class DrawingCanonicalAngleWiringTests
{
private static OpenNest.CNC.Program RotatedRectProgram(double w, double h, double theta)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
if (!OpenNest.Math.Tolerance.IsEqualTo(theta, 0))
pgm.Rotate(theta, pgm.BoundingBox().Center);
return pgm;
}
[Fact]
public void Constructor_ComputesAngleOnProgramAssignment()
{
var pgm = RotatedRectProgram(100, 50, 0.5);
var d = new Drawing("r", pgm);
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
[Fact]
public void SetProgram_RecomputesAngle()
{
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
Assert.Equal(0.0, d.Source.Angle, precision: 6);
d.Program = RotatedRectProgram(100, 50, 0.5);
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
[Fact]
public void IsCutOff_SkipsAngleComputation()
{
var d = new Drawing("cut", RotatedRectProgram(100, 50, 0.5)) { IsCutOff = true };
// Re-assign after flag is set so the setter observes IsCutOff.
d.Program = RotatedRectProgram(100, 50, 0.5);
Assert.Equal(0.0, d.Source.Angle, precision: 6);
}
[Fact]
public void RecomputeCanonicalAngle_UpdatesAfterMutation()
{
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
Assert.Equal(0.0, d.Source.Angle, precision: 6);
// Mutate in-place (doesn't trigger setter).
d.Program.Rotate(0.5, d.Program.BoundingBox().Center);
Assert.Equal(0.0, d.Source.Angle, precision: 6); // still stale
d.RecomputeCanonicalAngle();
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.Engine;
public class CanonicalFrameTests
{
private static Drawing MakeRect(double w, double h, double rotation)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
if (!Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("rect", pgm) { Source = new SourceInfo { Angle = -rotation } };
}
[Fact]
public void AsCanonicalCopy_AxisAlignsMbr()
{
var d = MakeRect(100, 50, 0.6);
var canonical = CanonicalFrame.AsCanonicalCopy(d);
var bb = canonical.Program.BoundingBox();
var longer = System.Math.Max(bb.Length, bb.Width);
var shorter = System.Math.Min(bb.Length, bb.Width);
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
Assert.Equal(0.0, canonical.Source.Angle, precision: 6);
}
[Fact]
public void AsCanonicalCopy_DoesNotMutateSource()
{
var d = MakeRect(100, 50, 0.6);
var originalBbox = d.Program.BoundingBox();
var originalAngle = d.Source.Angle;
CanonicalFrame.AsCanonicalCopy(d);
var afterBbox = d.Program.BoundingBox();
Assert.Equal(originalBbox.Width, afterBbox.Width, precision: 6);
Assert.Equal(originalBbox.Length, afterBbox.Length, precision: 6);
Assert.Equal(originalAngle, d.Source.Angle, precision: 6);
}
[Fact]
public void FromCanonical_ComposesSourceAngleOntoRotation()
{
var d = MakeRect(100, 50, 0.0);
var part = new Part(d);
part.Rotate(0.2); // engine returned a canonical-frame part at R = 0.2
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, sourceAngle: -0.5);
// R' = R + sourceAngle = 0.2 + (-0.5) = -0.3
// Part.Rotation comes from Program.Rotation which is normalized to [0, 2PI),
// so compare after normalizing the expected value as well.
Assert.Single(placed);
Assert.Equal(Angle.NormalizeRad(-0.3), placed[0].Rotation, precision: 4);
}
[Fact]
public void RoundTrip_RestoresGeometry()
{
var d = MakeRect(100, 50, 0.4);
var canonical = CanonicalFrame.AsCanonicalCopy(d);
// Place a part at origin in the canonical frame.
var part = Part.CreateAtOrigin(canonical);
var canonicalBbox = part.BoundingBox;
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, d.Source.Angle);
var originalBbox = d.Program.BoundingBox();
Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, precision: 2);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Threading;
namespace OpenNest.Tests.Engine;
public class NestInvarianceTests
{
private static OpenNest.CNC.Program MakeLShapedProgram()
{
// L-shape: 100x50 outer rect with a 50x30 notch removed from top-right.
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return pgm;
}
private static Drawing MakeImportedAt(double rotation)
{
var pgm = MakeLShapedProgram();
if (!Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("L", pgm);
}
private static Plate MakePlate() => new Plate(new Size(500, 500))
{
Quadrant = 1,
PartSpacing = 2,
};
private static int RunFillCount(Drawing drawing, Plate plate)
{
BestFitCache.Clear();
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = drawing };
var parts = engine.Fill(item, plate.WorkArea(), progress: null, token: CancellationToken.None);
return parts?.Count ?? 0;
}
[Theory]
[InlineData(0.0)]
[InlineData(0.3)]
[InlineData(0.8)]
[InlineData(1.2)]
public void Fill_SameCount_AcrossImportOrientations(double theta)
{
var baseline = RunFillCount(MakeImportedAt(0.0), MakePlate());
var rotated = RunFillCount(MakeImportedAt(theta), MakePlate());
// Allow +/-1 tolerance for sweep quantization edge effects near plate boundaries.
Assert.InRange(rotated, baseline - 1, baseline + 1);
}
[Fact]
public void Fill_PlacedPartsStayWithinWorkArea_AcrossImportOrientations()
{
var plate = MakePlate();
var workArea = plate.WorkArea();
foreach (var theta in new[] { 0.0, 0.3, 0.8, 1.2 })
{
BestFitCache.Clear();
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = MakeImportedAt(theta) };
var parts = engine.Fill(item, workArea, progress: null, token: CancellationToken.None);
Assert.NotNull(parts);
foreach (var p in parts)
{
Assert.InRange(p.BoundingBox.Left, workArea.Left - 0.5, workArea.Right + 0.5);
Assert.InRange(p.BoundingBox.Bottom, workArea.Bottom - 0.5, workArea.Top + 0.5);
}
}
}
}
+70
View File
@@ -8,6 +8,76 @@ namespace OpenNest.Tests.Fill
{
public class CompactorTests
{
[Fact]
public void DirectionalDistance_ArcVsInclinedLine_DoesNotOverPush()
{
// Arc (top semicircle) pushed upward toward a 45° inclined line.
// The critical angle on the arc gives a shorter distance than any
// sampled vertex (endpoints + cardinal extremes).
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
var line = new Line(new Vector(3, 4), new Vector(7, 6));
var moving = new List<Entity> { arc };
var stationary = new List<Entity> { line };
var direction = new Vector(0, 1); // push up
var dist = SpatialQuery.DirectionalDistance(moving, stationary, direction);
// Move the arc up by the computed distance, then verify no overlap.
// The topmost reachable point on the arc at the critical angle θ ≈ 2.034
// (between π/2 and π) should just touch the line.
Assert.True(dist < double.MaxValue, "Should find a finite distance");
Assert.True(dist > 0, "Should be a positive distance");
// Verify: after moving, the closest point on the arc should be within
// tolerance of the line, not past it.
var theta = System.Math.Atan2(
line.pt2.X - line.pt1.X, -(line.pt2.Y - line.pt1.Y));
theta = OpenNest.Math.Angle.NormalizeRad(theta + System.Math.PI);
var qx = arc.Center.X + arc.Radius * System.Math.Cos(theta);
var qy = arc.Center.Y + arc.Radius * System.Math.Sin(theta) + dist;
// The moved point should be on or just touching the line, not past it.
// Line equation: (y - 4) / (x - 3) = (6 - 4) / (7 - 3) = 0.5
// y = 0.5x + 2.5
var lineYAtQx = 0.5 * qx + 2.5;
Assert.True(qy <= lineYAtQx + 0.001,
$"Arc point ({qx:F4}, {qy:F4}) should not be past line (line Y={lineYAtQx:F4} at X={qx:F4}). " +
$"dist={dist:F6}, overshot by {qy - lineYAtQx:F6}");
}
[Fact]
public void DirectionalDistance_ArcVsInclinedLine_BetterThanVertexSampling()
{
// Same geometry — verify the analytical Phase 3 finds a shorter
// distance than the Phase 1/2 vertex sampling alone would.
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
var line = new Line(new Vector(3, 4), new Vector(7, 6));
// Phase 1/2 vertex-only distance: sample arc endpoints + cardinal extreme.
var vertices = new[]
{
new Vector(7, 0), // arc endpoint θ=0
new Vector(3, 0), // arc endpoint θ=π
new Vector(5, 2), // cardinal extreme θ=π/2
};
var vertexMin = double.MaxValue;
foreach (var v in vertices)
{
var d = SpatialQuery.RayEdgeDistance(v.X, v.Y,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, 0, 1);
if (d < vertexMin) vertexMin = d;
}
// Full directional distance (includes Phase 3 arc-to-line).
var moving = new List<Entity> { arc };
var stationary = new List<Entity> { line };
var fullDist = SpatialQuery.DirectionalDistance(moving, stationary, new Vector(0, 1));
Assert.True(fullDist < vertexMin,
$"Full distance ({fullDist:F6}) should be less than vertex-only ({vertexMin:F6})");
}
private static Drawing MakeRectDrawing(double w, double h)
{
var pgm = new OpenNest.CNC.Program();
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Geometry;
public class BoxComparisonTests
{
[Fact]
public void GreaterThan_TallerBox_ReturnsTrue()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(tall > short_);
Assert.False(short_ > tall);
}
[Fact]
public void GreaterThan_SameWidthLongerBox_ReturnsTrue()
{
var longer = new Box(0, 0, 20, 10);
var shorter = new Box(0, 0, 10, 10);
Assert.True(longer > shorter);
Assert.False(shorter > longer);
}
[Fact]
public void LessThan_ShorterBox_ReturnsTrue()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(short_ < tall);
Assert.False(tall < short_);
}
[Fact]
public void GreaterThanOrEqual_EqualBoxes_ReturnsTrue()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.True(a >= b);
Assert.True(b >= a);
}
[Fact]
public void LessThanOrEqual_EqualBoxes_ReturnsTrue()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.True(a <= b);
Assert.True(b <= a);
}
[Fact]
public void CompareTo_TallerBox_ReturnsPositive()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(tall.CompareTo(short_) > 0);
Assert.True(short_.CompareTo(tall) < 0);
}
[Fact]
public void CompareTo_EqualBoxes_ReturnsZero()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.Equal(0, a.CompareTo(b));
}
[Fact]
public void Sort_OrdersByWidthThenLength()
{
var boxes = new List<Box>
{
new Box(0, 0, 20, 10),
new Box(0, 0, 5, 30),
new Box(0, 0, 10, 10),
};
boxes.Sort();
Assert.Equal(10, boxes[0].Width);
Assert.Equal(10, boxes[0].Length);
Assert.Equal(10, boxes[1].Width);
Assert.Equal(20, boxes[1].Length);
Assert.Equal(30, boxes[2].Width);
}
}
@@ -1,14 +1,19 @@
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using Xunit;
using Xunit.Abstractions;
using System.Linq;
namespace OpenNest.Tests.Geometry;
public class EllipseConverterTests
{
private readonly ITestOutputHelper _output;
private const double Tol = 1e-10;
public EllipseConverterTests(ITestOutputHelper output) => _output = output;
[Fact]
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
{
@@ -244,6 +249,101 @@ public class EllipseConverterTests
}
}
[Fact]
public void DxfImport_ArcBoundingBoxes_Diagnostic()
{
var path = @"C:\Users\aisaacs\Desktop\11ga tab.dxf";
if (!System.IO.File.Exists(path)) return;
var result = Dxf.Import(path);
var all = (System.Collections.Generic.IEnumerable<IBoundable>)result.Entities;
var bbox = all.GetBoundingBox();
_output.WriteLine($"Overall: X={bbox.X:F4} Y={bbox.Y:F4} W={bbox.Length:F4} H={bbox.Width:F4}");
for (var i = 0; i < result.Entities.Count; i++)
{
var e = result.Entities[i];
var b = e.BoundingBox;
var flag = (b.Length > 1 || b.Width > 1) ? " ***" : "";
_output.WriteLine($"{i + 1,3}. {e.GetType().Name,-8} X={b.X:F4} Y={b.Y:F4} W={b.Length:F4} H={b.Width:F4}{flag}");
}
}
[Fact]
public void ToOpenNest_FlippedNormalZ_ProducesCorrectArcs()
{
var normal = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(-0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, 1)
};
var flipped = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(-0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, -1)
};
var normalArcs = normal.ToOpenNest();
var flippedArcs = flipped.ToOpenNest();
Assert.True(normalArcs.Count > 0);
Assert.True(flippedArcs.Count > 0);
Assert.True(normalArcs.All(e => e is Arc));
Assert.True(flippedArcs.All(e => e is Arc));
var normalFirst = (Arc)normalArcs.First();
var flippedFirst = (Arc)flippedArcs.First();
var normalStart = GetArcStart(normalFirst);
var flippedStart = GetArcStart(flippedFirst);
Assert.True(normalStart.X < 0, $"Normal ellipse start X should be negative, got {normalStart.X}");
Assert.True(flippedStart.X > 0, $"Flipped ellipse should bulge right, got {flippedStart.X}");
var normalBbox = GetBoundingBox(normalArcs.Cast<Arc>());
var flippedBbox = GetBoundingBox(flippedArcs.Cast<Arc>());
Assert.True(flippedBbox.minX > 0, $"Flipped ellipse should stay on positive X side, minX={flippedBbox.minX}");
Assert.True(normalBbox.maxX < 0, $"Normal ellipse should stay on negative X side, maxX={normalBbox.maxX}");
}
private static (double minX, double maxX) GetBoundingBox(IEnumerable<Arc> arcs)
{
var minX = double.MaxValue;
var maxX = double.MinValue;
foreach (var arc in arcs)
{
var s = GetArcStart(arc);
var e = GetArcEnd(arc);
minX = System.Math.Min(minX, System.Math.Min(s.X, e.X));
maxX = System.Math.Max(maxX, System.Math.Max(s.X, e.X));
}
return (minX, maxX);
}
private static Vector GetArcStart(Arc arc)
{
var angle = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static Vector GetArcEnd(Arc arc)
{
var angle = arc.IsReversed ? arc.StartAngle : arc.EndAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
double semiMajor, double semiMinor, double rotation, int samples)
{
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.Geometry;
public class WeldEndpointsTests
{
[Fact]
public void WeldEndpoints_SnapsNearbyLineEndpoints()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.0000005, 0, 20, 0);
var entities = new List<Entity> { line1, line2 };
ShapeBuilder.WeldEndpoints(entities, 0.000001);
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) <= Tolerance.Epsilon);
}
[Fact]
public void WeldEndpoints_SnapsArcEndpointByAdjustingAngle()
{
var line = new Line(0, 0, 10, 0);
var arc = new Arc(15, 0, 5, Angle.ToRadians(180.001), Angle.ToRadians(90));
var entities = new List<Entity> { line, arc };
ShapeBuilder.WeldEndpoints(entities, 0.01);
var arcStart = arc.StartPoint();
Assert.True(line.EndPoint.DistanceTo(arcStart) <= 0.01);
}
[Fact]
public void WeldEndpoints_DoesNotWeldDistantEndpoints()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.1, 0, 20, 0);
var entities = new List<Entity> { line1, line2 };
ShapeBuilder.WeldEndpoints(entities, 0.000001);
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) > 0.01);
}
[Fact]
public void GetShapes_WithWeldTolerance_WeldsBeforeChaining()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.0000005, 0, 10.0000005, 10);
var entities = new List<Entity> { line1, line2 };
var shapes = ShapeBuilder.GetShapes(entities, weldTolerance: 0.000001);
Assert.Single(shapes);
Assert.Equal(2, shapes[0].Entities.Count);
}
[Fact]
public void GetShapes_WithoutWeldTolerance_DefaultBehavior()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10, 0, 10, 10);
var entities = new List<Entity> { line1, line2 };
var shapes = ShapeBuilder.GetShapes(entities);
Assert.Single(shapes);
Assert.Equal(2, shapes[0].Entities.Count);
}
}
+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);
}
}
}
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.IO;
public class RemoveDuplicateArcsTests
{
[Fact]
public void RemoveDuplicateArcs_RemovesArcMatchingCircle_SameLayer()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var line = new Line(0, 0, 10, 0) { Layer = layer };
var entities = new List<Entity> { circle, arc, line };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
Assert.Contains(circle, entities);
Assert.Contains(line, entities);
Assert.DoesNotContain(arc, entities);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcOnDifferentLayer()
{
var layer1 = new Layer("cut");
var layer2 = new Layer("etch");
var circle = new Circle(10, 10, 5) { Layer = layer1 };
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer2 };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
Assert.Contains(arc, entities);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcWithDifferentRadius()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(10, 10, 3, 0, Angle.ToRadians(90)) { Layer = layer };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcWithDifferentCenter()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(20, 20, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_NoCircles_NoChange()
{
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90));
var line = new Line(0, 0, 10, 0);
var entities = new List<Entity> { arc, line };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_MultipleArcsMatchOneCircle_RemovesAll()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc1 = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var arc2 = new Arc(10, 10, 5, Angle.ToRadians(90), Angle.ToRadians(180)) { Layer = layer };
var entities = new List<Entity> { circle, arc1, arc2 };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Single(entities);
Assert.Contains(circle, entities);
}
}
@@ -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;
}
}
+46
View File
@@ -0,0 +1,46 @@
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.Math;
public class FractionTests
{
[Theory]
[InlineData("3/8", 0.375)]
[InlineData("1 3/4", 1.75)]
[InlineData("1-3/4", 1.75)]
[InlineData("1/2", 0.5)]
public void Parse_ValidFraction_ReturnsDouble(string input, double expected)
{
var result = Fraction.Parse(input);
Assert.Equal(expected, result, 8);
}
[Theory]
[InlineData("3/8", true)]
[InlineData("abc", false)]
[InlineData("1 3/4", true)]
public void IsValid_ReturnsExpected(string input, bool expected)
{
Assert.Equal(expected, Fraction.IsValid(input));
}
[Fact]
public void TryParse_InvalidInput_ReturnsFalse()
{
var result = Fraction.TryParse("abc", out var value);
Assert.False(result);
Assert.Equal(0, value);
}
[Fact]
public void ReplaceFractionsWithDecimals_ReplacesFractionInString()
{
var result = Fraction.ReplaceFractionsWithDecimals("length is 1 3/4 inches");
Assert.Contains("1.75", result);
Assert.DoesNotContain("3/4", result);
}
}
+3
View File
@@ -34,6 +34,9 @@
<Content Include="Bending\TestData\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Splitting\TestData\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,118 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests;
public class PlateSnapToStandardSizeTests
{
private static Part MakeRectPart(double x, double y, double length, double width)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(length, 0)));
pgm.Codes.Add(new LinearMove(new Vector(length, width)));
pgm.Codes.Add(new LinearMove(new Vector(0, width)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("test", pgm);
var part = new Part(drawing);
part.Offset(x, y);
return part;
}
[Fact]
public void SnapToStandardSize_SmallParts_SnapsToIncrement()
{
var plate = new Plate(200, 200); // oversized starting size
plate.Parts.Add(MakeRectPart(0, 0, 10, 20));
var result = plate.SnapToStandardSize();
// 10x20 is well below 48x48 MinSheet -> snap to integer increment.
Assert.Null(result.MatchedLabel);
Assert.Equal(10, plate.Size.Length); // X axis
Assert.Equal(20, plate.Size.Width); // Y axis
}
[Fact]
public void SnapToStandardSize_SmallPartsWithFractionalIncrement_UsesIncrement()
{
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 10.3, 20.7));
var result = plate.SnapToStandardSize(new PlateSizeOptions { SnapIncrement = 0.25 });
Assert.Null(result.MatchedLabel);
Assert.Equal(10.5, plate.Size.Length, 4);
Assert.Equal(20.75, plate.Size.Width, 4);
}
[Fact]
public void SnapToStandardSize_40x90Part_SnapsToStandard48x96_XLong()
{
// Part is 90 long (X) x 40 wide (Y) -> X is the long axis.
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 90, 40));
var result = plate.SnapToStandardSize();
Assert.Equal("48x96", result.MatchedLabel);
Assert.Equal(96, plate.Size.Length); // X axis = long
Assert.Equal(48, plate.Size.Width); // Y axis = short
}
[Fact]
public void SnapToStandardSize_90TallPart_SnapsToStandard48x96_YLong()
{
// Part is 40 long (X) x 90 wide (Y) -> Y is the long axis.
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 40, 90));
var result = plate.SnapToStandardSize();
Assert.Equal("48x96", result.MatchedLabel);
Assert.Equal(48, plate.Size.Length); // X axis = short
Assert.Equal(96, plate.Size.Width); // Y axis = long
}
[Fact]
public void SnapToStandardSize_JustOver48_PicksNextStandardSize()
{
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 100, 50));
var result = plate.SnapToStandardSize();
Assert.Equal("60x120", result.MatchedLabel);
Assert.Equal(120, plate.Size.Length); // X long
Assert.Equal(60, plate.Size.Width);
}
[Fact]
public void SnapToStandardSize_EmptyPlate_DoesNotModifySize()
{
var plate = new Plate(60, 120);
var result = plate.SnapToStandardSize();
Assert.Null(result.MatchedLabel);
Assert.Equal(60, plate.Size.Width);
Assert.Equal(120, plate.Size.Length);
}
[Fact]
public void SnapToStandardSize_MultipleParts_UsesCombinedEnvelope()
{
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 30, 40));
plate.Parts.Add(MakeRectPart(30, 0, 30, 40)); // combined X-extent = 60
plate.Parts.Add(MakeRectPart(0, 40, 60, 60)); // combined extent = 60 x 100
var result = plate.SnapToStandardSize();
// 60 x 100 fits 60x120 standard sheet, Y is the long axis.
Assert.Equal("60x120", result.MatchedLabel);
Assert.Equal(60, plate.Size.Length); // X
Assert.Equal(120, plate.Size.Width); // Y long
}
}
-104
View File
@@ -1,104 +0,0 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class FlangeShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesOD()
{
var shape = new FlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(10, bbox.Width, 0.01);
Assert.Equal(10, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_AreaExcludesBoltHoles()
{
var shape = new FlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
// Area = pi * 5^2 - 4 * pi * 0.5^2 = pi * (25 - 1) = pi * 24
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_DefaultName_IsFlange()
{
var shape = new FlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
Assert.Equal("Flange", drawing.Name);
}
[Fact]
public void LoadFromJson_ProducesCorrectDrawing()
{
var json = """
[
{
"Name": "2in-150#",
"NominalPipeSize": 2.0,
"OD": 6.0,
"HoleDiameter": 0.75,
"HolePatternDiameter": 4.75,
"HoleCount": 4
},
{
"Name": "2in-300#",
"NominalPipeSize": 2.0,
"OD": 6.5,
"HoleDiameter": 0.75,
"HolePatternDiameter": 5.0,
"HoleCount": 8
}
]
""";
var tempFile = Path.GetTempFileName();
try
{
File.WriteAllText(tempFile, json);
var flanges = ShapeDefinition.LoadFromJson<FlangeShape>(tempFile);
Assert.Equal(2, flanges.Count);
var first = flanges[0];
Assert.Equal("2in-150#", first.Name);
var drawing = first.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(6, bbox.Width, 0.01);
var second = flanges[1];
Assert.Equal("2in-300#", second.Name);
Assert.Equal(8, second.HoleCount);
}
finally
{
File.Delete(tempFile);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class NgonShapeTests
{
[Fact]
public void GetDrawing_Octagon_BoundingBoxFitsWithinExpectedSize()
{
var shape = new NgonShape { Sides = 8, Width = 20 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
// Corner-to-corner is larger than flat-to-flat
Assert.True(bbox.Width >= 20 - 0.01);
Assert.True(bbox.Length >= 20 - 0.01);
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
Assert.True(bbox.Width < 22);
Assert.True(bbox.Length < 22);
}
[Theory]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(8)]
[InlineData(12)]
public void GetDrawing_HasOneLinearMovePerSide(int sides)
{
var shape = new NgonShape { Sides = sides, Width = 20 };
var drawing = shape.GetDrawing();
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(sides, moves);
}
[Fact]
public void GetDrawing_ClampsSidesBelowThreeToTriangle()
{
var shape = new NgonShape { Sides = 2, Width = 20 };
var drawing = shape.GetDrawing();
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(3, moves);
}
}
@@ -1,34 +0,0 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class OctagonShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxFitsWithinExpectedSize()
{
var shape = new OctagonShape { Width = 20 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
// Corner-to-corner is larger than flat-to-flat
Assert.True(bbox.Width >= 20 - 0.01);
Assert.True(bbox.Length >= 20 - 0.01);
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
Assert.True(bbox.Width < 22);
Assert.True(bbox.Length < 22);
}
[Fact]
public void GetDrawing_HasEightEdges()
{
var shape = new OctagonShape { Width = 20 };
var drawing = shape.GetDrawing();
// An octagon program should have 8 linear moves (one per edge)
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(8, moves);
}
}
@@ -0,0 +1,216 @@
using System;
using System.IO;
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class PipeFlangeShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesOD()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(10, bbox.Width, 0.01);
Assert.Equal(10, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_AreaExcludesBoltHoles()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
Blind = true
};
var drawing = shape.GetDrawing();
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_DefaultName_IsPipeFlange()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
Assert.Equal("PipeFlange", drawing.Name);
}
[Fact]
public void GetDrawing_WithPipeSize_CutsCenterBoreAtPipeODPlusClearance()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
PipeSize = "2", // OD = 2.375
PipeClearance = 0.125,
Blind = false
};
var drawing = shape.GetDrawing();
// Expected bore diameter = 2.375 + 0.125 = 2.5
// Area = pi * (5^2 - 0.5^2 * 4 - 1.25^2) = pi * (25 - 1 - 1.5625) = pi * 22.4375
var expectedArea = System.Math.PI * 22.4375;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_Blind_OmitsCenterBore()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
PipeSize = "2",
PipeClearance = 0.125,
Blind = true
};
var drawing = shape.GetDrawing();
// With Blind=true, area = outer - 4 bolt holes = pi * (25 - 1) = pi * 24
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_UnknownPipeSize_OmitsCenterBore()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
PipeSize = "not-a-real-pipe",
PipeClearance = 0.125,
Blind = false
};
var drawing = shape.GetDrawing();
// Unknown pipe size → no bore, area matches blind case
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void GetDrawing_NullOrEmptyPipeSize_OmitsCenterBore(string pipeSize)
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
PipeSize = pipeSize,
PipeClearance = 0.125
};
var drawing = shape.GetDrawing();
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void LoadFromJson_ProducesCorrectDrawing()
{
var json = """
[
{
"Name": "2in-150#",
"PipeSize": "2",
"PipeClearance": 0.0625,
"OD": 6.0,
"HoleDiameter": 0.75,
"HolePatternDiameter": 4.75,
"HoleCount": 4
},
{
"Name": "2in-300#",
"PipeSize": "2",
"PipeClearance": 0.0625,
"OD": 6.5,
"HoleDiameter": 0.75,
"HolePatternDiameter": 5.0,
"HoleCount": 8
}
]
""";
var tempFile = Path.GetTempFileName();
try
{
File.WriteAllText(tempFile, json);
var flanges = ShapeDefinition.LoadFromJson<PipeFlangeShape>(tempFile);
Assert.Equal(2, flanges.Count);
var first = flanges[0];
Assert.Equal("2in-150#", first.Name);
Assert.Equal("2", first.PipeSize);
Assert.Equal(0.0625, first.PipeClearance, 0.0001);
var drawing = first.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(6, bbox.Width, 0.01);
var second = flanges[1];
Assert.Equal("2in-300#", second.Name);
Assert.Equal(8, second.HoleCount);
}
finally
{
File.Delete(tempFile);
}
}
[Fact]
public void LoadFromJson_RealShippedConfig_LoadsAllEntries()
{
// Resolve the repo-relative config path from the test binary location.
var dir = AppDomain.CurrentDomain.BaseDirectory;
while (dir != null && !File.Exists(Path.Combine(dir, "OpenNest.sln")))
dir = Path.GetDirectoryName(dir);
Assert.NotNull(dir);
var configPath = Path.Combine(dir, "OpenNest", "Configurations", "PipeFlangeShape.json");
Assert.True(File.Exists(configPath), $"Config missing at {configPath}");
var flanges = ShapeDefinition.LoadFromJson<PipeFlangeShape>(configPath);
Assert.NotEmpty(flanges);
foreach (var f in flanges)
{
Assert.False(string.IsNullOrWhiteSpace(f.PipeSize));
Assert.True(PipeSizes.TryGetOD(f.PipeSize, out _),
$"Unknown PipeSize '{f.PipeSize}' in entry '{f.Name}'");
Assert.Equal(0.0625, f.PipeClearance, 0.0001);
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class PipeSizesTests
{
[Fact]
public void All_ContainsExpectedCount()
{
Assert.Equal(35, PipeSizes.All.Count);
}
[Fact]
public void All_IsSortedByOuterDiameterAscending()
{
for (var i = 1; i < PipeSizes.All.Count; i++)
Assert.True(PipeSizes.All[i].OuterDiameter > PipeSizes.All[i - 1].OuterDiameter);
}
[Theory]
[InlineData("1/8", 0.405)]
[InlineData("1/2", 0.840)]
[InlineData("2", 2.375)]
[InlineData("2 1/2", 2.875)]
[InlineData("12", 12.750)]
[InlineData("48", 48.000)]
public void TryGetOD_KnownLabel_ReturnsExpectedOD(string label, double expected)
{
Assert.True(PipeSizes.TryGetOD(label, out var od));
Assert.Equal(expected, od, 0.001);
}
[Fact]
public void TryGetOD_UnknownLabel_ReturnsFalse()
{
Assert.False(PipeSizes.TryGetOD("bogus", out _));
}
[Fact]
public void GetFittingSizes_FiltersByMaxOD()
{
var results = PipeSizes.GetFittingSizes(3.0).ToList();
Assert.Contains(results, e => e.Label == "2 1/2");
Assert.DoesNotContain(results, e => e.Label == "3");
Assert.DoesNotContain(results, e => e.Label == "4");
}
[Fact]
public void GetFittingSizes_ExactBoundary_IsInclusive()
{
// NPS 3 has OD 3.500; passing maxOD = 3.500 should include it.
var results = PipeSizes.GetFittingSizes(3.500).ToList();
Assert.Contains(results, e => e.Label == "3");
Assert.DoesNotContain(results, e => e.Label == "3 1/2");
}
[Fact]
public void GetFittingSizes_MaxSmallerThanSmallest_ReturnsEmpty()
{
Assert.Empty(PipeSizes.GetFittingSizes(0.1));
}
}
+311
View File
@@ -0,0 +1,311 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class PlateSizesTests
{
[Fact]
public void All_IsNotEmpty()
{
Assert.NotEmpty(PlateSizes.All);
}
[Fact]
public void All_DoesNotContain48x48()
{
// 48x48 is not a standard sheet - it's the default MinSheet threshold only.
Assert.DoesNotContain(PlateSizes.All, e => e.Width == 48 && e.Length == 48);
}
[Fact]
public void All_Smallest_Is48x96()
{
var smallest = PlateSizes.All.OrderBy(e => e.Area).First();
Assert.Equal(48, smallest.Width);
Assert.Equal(96, smallest.Length);
}
[Fact]
public void All_SortedByAreaAscending()
{
for (var i = 1; i < PlateSizes.All.Count; i++)
Assert.True(PlateSizes.All[i].Area >= PlateSizes.All[i - 1].Area);
}
[Fact]
public void All_Entries_AreCanonical_WidthLessOrEqualLength()
{
foreach (var entry in PlateSizes.All)
Assert.True(entry.Width <= entry.Length, $"{entry.Label} not in canonical orientation");
}
[Theory]
[InlineData(40, 40, true)] // small - fits trivially
[InlineData(48, 96, true)] // exact
[InlineData(96, 48, true)] // rotated exact
[InlineData(90, 40, true)] // rotated
[InlineData(49, 97, false)] // just over in both dims
[InlineData(50, 50, false)] // too wide in both orientations
public void Entry_Fits_RespectsRotation(double w, double h, bool expected)
{
var entry = new PlateSizes.Entry("48x96", 48, 96);
Assert.Equal(expected, entry.Fits(w, h));
}
[Fact]
public void TryGet_KnownLabel_ReturnsEntry()
{
Assert.True(PlateSizes.TryGet("48x96", out var entry));
Assert.Equal(48, entry.Width);
Assert.Equal(96, entry.Length);
}
[Fact]
public void TryGet_IsCaseInsensitive()
{
Assert.True(PlateSizes.TryGet("48X96", out var entry));
Assert.Equal(48, entry.Width);
Assert.Equal(96, entry.Length);
}
[Fact]
public void TryGet_UnknownLabel_ReturnsFalse()
{
Assert.False(PlateSizes.TryGet("bogus", out _));
}
[Fact]
public void Recommend_BelowMin_SnapsToDefaultIncrementOfOne()
{
var bbox = new Box(0, 0, 10.3, 20.7);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(11, result.Width);
Assert.Equal(21, result.Length);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_BelowMin_UsesCustomIncrement()
{
var bbox = new Box(0, 0, 10.3, 20.7);
var options = new PlateSizeOptions { SnapIncrement = 0.25 };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(10.5, result.Width, 4);
Assert.Equal(20.75, result.Length, 4);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_ExactlyAtMin_Snaps()
{
var bbox = new Box(0, 0, 48, 48);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(48, result.Width);
Assert.Equal(48, result.Length);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_AboveMin_PicksSmallestContainingStandardSheet()
{
var bbox = new Box(0, 0, 40, 90);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(48, result.Width);
Assert.Equal(96, result.Length);
Assert.Equal("48x96", result.MatchedLabel);
}
[Fact]
public void Recommend_AboveMin_WithRotation_PicksSmallestSheet()
{
var bbox = new Box(0, 0, 90, 40);
var result = PlateSizes.Recommend(bbox);
Assert.Equal("48x96", result.MatchedLabel);
}
[Fact]
public void Recommend_JustOver48_PicksNextStandardSize()
{
var bbox = new Box(0, 0, 50, 100);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(60, result.Width);
Assert.Equal(120, result.Length);
Assert.Equal("60x120", result.MatchedLabel);
}
[Fact]
public void Recommend_MarginIsAppliedPerSide()
{
// 46 + 2*1 = 48 (fits exactly), 94 + 2*1 = 96 (fits exactly)
var bbox = new Box(0, 0, 46, 94);
var options = new PlateSizeOptions { Margin = 1 };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal("48x96", result.MatchedLabel);
}
[Fact]
public void Recommend_MarginPushesToNextSheet()
{
// 47 + 2 = 49 > 48, so 48x96 no longer fits -> next standard
var bbox = new Box(0, 0, 47, 95);
var options = new PlateSizeOptions { Margin = 1 };
var result = PlateSizes.Recommend(bbox, options);
Assert.NotEqual("48x96", result.MatchedLabel);
Assert.True(result.Width >= 49);
Assert.True(result.Length >= 97);
}
[Fact]
public void Recommend_AllowedSizes_StandardLabelWhitelist()
{
// 60x120 is the only option; 50x50 is above min so it routes to standard
var bbox = new Box(0, 0, 50, 50);
var options = new PlateSizeOptions { AllowedSizes = new[] { "60x120" } };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal("60x120", result.MatchedLabel);
}
[Fact]
public void Recommend_AllowedSizes_ArbitraryWxHString()
{
// 50x100 isn't in the standard catalog but is valid as an ad-hoc entry.
// bbox 49x99 doesn't fit 48x96 or 48x120, does fit 50x100 and 60x120,
// but only 50x100 is allowed.
var bbox = new Box(0, 0, 49, 99);
var options = new PlateSizeOptions { AllowedSizes = new[] { "50x100" } };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(50, result.Width);
Assert.Equal(100, result.Length);
Assert.Equal("50x100", result.MatchedLabel);
}
[Fact]
public void Recommend_NothingFits_FallsBackToSnapUp()
{
// Larger than any catalog sheet
var bbox = new Box(0, 0, 100, 300);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(100, result.Width);
Assert.Equal(300, result.Length);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_NothingFitsInAllowedList_FallsBackToSnapUp()
{
// Only 48x96 allowed, but bbox is too big for it
var bbox = new Box(0, 0, 50, 100);
var options = new PlateSizeOptions { AllowedSizes = new[] { "48x96" } };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(50, result.Width);
Assert.Equal(100, result.Length);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_BoxEnumerable_CombinesIntoEnvelope()
{
// Two boxes that together span 0..40 x 0..90 -> fits 48x96
var boxes = new[]
{
new Box(0, 0, 40, 50),
new Box(0, 40, 30, 50),
};
var result = PlateSizes.Recommend(boxes);
Assert.Equal("48x96", result.MatchedLabel);
}
[Fact]
public void Recommend_BoxEnumerable_Empty_Throws()
{
Assert.Throws<System.ArgumentException>(
() => PlateSizes.Recommend(System.Array.Empty<Box>()));
}
[Fact]
public void PlateSizeOptions_Defaults()
{
var options = new PlateSizeOptions();
Assert.Equal(48, options.MinSheetWidth);
Assert.Equal(48, options.MinSheetLength);
Assert.Equal(1.0, options.SnapIncrement);
Assert.Equal(0, options.Margin);
Assert.Null(options.AllowedSizes);
Assert.Equal(PlateSizeSelection.SmallestArea, options.Selection);
}
[Fact]
public void Recommend_NarrowestFirst_PicksNarrowerSheetOverSmallerArea()
{
// Hypothetical: bbox (47, 47) fits both 48x96 (area 4608) and some narrower option.
// With SmallestArea: picks 48x96 (it's already the smallest 48-wide).
// With NarrowestFirst: also picks 48x96 since that's the narrowest.
// Better test: AllowedSizes = ["60x120", "48x120"] with bbox that fits both.
// 48x120 (area 5760) is narrower; 60x120 (area 7200) has more area.
// SmallestArea picks 48x120; NarrowestFirst also picks 48x120. Both pick the same.
//
// Real divergence: AllowedSizes = ["60x120", "72x120"] with bbox 55x100.
// 60x120 has narrower width (60) AND smaller area (7200 vs 8640), so both agree.
//
// To force divergence: AllowedSizes = ["60x96", "48x144"] with bbox 47x95.
// 60x96 area = 5760, 48x144 area = 6912. SmallestArea -> 60x96.
// NarrowestFirst width 48 < 60 -> 48x144.
var bbox = new Box(0, 0, 47, 95);
var options = new PlateSizeOptions
{
AllowedSizes = new[] { "60x96", "48x144" },
Selection = PlateSizeSelection.NarrowestFirst,
};
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(48, result.Width);
Assert.Equal(144, result.Length);
}
[Fact]
public void Recommend_SmallestArea_PicksSmallerAreaOverNarrowerWidth()
{
var bbox = new Box(0, 0, 47, 95);
var options = new PlateSizeOptions
{
AllowedSizes = new[] { "60x96", "48x144" },
Selection = PlateSizeSelection.SmallestArea,
};
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(60, result.Width);
Assert.Equal(96, result.Length);
}
}
@@ -384,6 +384,161 @@ public class DrawingSplitterTests
}
}
[Fact]
public void Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips()
{
// 255x55 outer rectangle with a 235x35 interior slot centered at (10,10)-(245,45).
// 4 vertical splits at x = 55, 110, 165, 220.
//
// Expected: regions R2/R3/R4 are entirely "over" the slot horizontally, so the
// surviving material in each is two physically disjoint strips (upper + lower).
// R1 and R5 each have a solid edge that connects the top and bottom strips, so
// they remain single (notched) pieces.
//
// Total output drawings: 1 (R1) + 2 (R2) + 2 (R3) + 2 (R4) + 1 (R5) = 8.
var outerEntities = new List<Entity>
{
new Line(new Vector(0, 0), new Vector(255, 0)),
new Line(new Vector(255, 0), new Vector(255, 55)),
new Line(new Vector(255, 55), new Vector(0, 55)),
new Line(new Vector(0, 55), new Vector(0, 0))
};
var slotEntities = new List<Entity>
{
new Line(new Vector(10, 10), new Vector(245, 10)),
new Line(new Vector(245, 10), new Vector(245, 45)),
new Line(new Vector(245, 45), new Vector(10, 45)),
new Line(new Vector(10, 45), new Vector(10, 10))
};
var allEntities = new List<Entity>();
allEntities.AddRange(outerEntities);
allEntities.AddRange(slotEntities);
var drawing = new Drawing("SLOT", ConvertGeometry.ToProgram(allEntities));
var originalArea = drawing.Area;
var splitLines = new List<SplitLine>
{
new SplitLine(55.0, CutOffAxis.Vertical),
new SplitLine(110.0, CutOffAxis.Vertical),
new SplitLine(165.0, CutOffAxis.Vertical),
new SplitLine(220.0, CutOffAxis.Vertical)
};
var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight });
// R1 (0..55) → 1 notched piece, height 55
// R2 (55..110) → upper strip + lower strip, each height 10
// R3 (110..165)→ upper strip + lower strip, each height 10
// R4 (165..220)→ upper strip + lower strip, each height 10
// R5 (220..255)→ 1 notched piece, height 55
Assert.Equal(8, results.Count);
// Area preservation: sum of all output areas equals (outer slot).
var totalArea = results.Sum(d => d.Area);
Assert.Equal(originalArea, totalArea, 1);
// Box.Length = X-extent, Box.Width = Y-extent.
// Exactly 6 strips (Y-extent ~10mm) from the three middle regions, and
// exactly 2 notched pieces (Y-extent 55mm) from R1 and R5.
var strips = results
.Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 10.0) < 0.5)
.ToList();
var notched = results
.Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 55.0) < 0.5)
.ToList();
Assert.Equal(6, strips.Count);
Assert.Equal(2, notched.Count);
// Each piece should form a closed perimeter (no dangling edges, no gaps).
foreach (var piece in results)
{
var entities = ConvertProgram.ToGeometry(piece.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
Assert.True(entities.Count >= 3, $"{piece.Name} must have at least 3 edges");
for (var i = 0; i < entities.Count; i++)
{
var end = GetEndPoint(entities[i]);
var nextStart = GetStartPoint(entities[(i + 1) % entities.Count]);
var gap = end.DistanceTo(nextStart);
Assert.True(gap < 0.01,
$"{piece.Name} gap of {gap:F4} between edge {i} end and edge {(i + 1) % entities.Count} start");
}
}
}
[Fact]
public void Split_DxfFile_WithSpanningSlot_HasNoCutLinesThroughCutout()
{
// Real DXF regression: 255x55 plate with a centered slot cutout, split into
// five columns. Exercises the same path as the synthetic
// Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips test but through
// the full DXF import pipeline.
var path = Path.Combine(AppContext.BaseDirectory, "Splitting", "TestData", "split_test.dxf");
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
var imported = OpenNest.IO.Dxf.Import(path);
var profile = new OpenNest.Geometry.ShapeProfile(imported.Entities);
// Normalize to origin so the split line positions are predictable.
var bb = profile.Perimeter.BoundingBox;
var offsetX = -bb.X;
var offsetY = -bb.Y;
foreach (var e in profile.Perimeter.Entities) e.Offset(offsetX, offsetY);
foreach (var cutout in profile.Cutouts)
foreach (var e in cutout.Entities) e.Offset(offsetX, offsetY);
var allEntities = new List<Entity>();
allEntities.AddRange(profile.Perimeter.Entities);
foreach (var cutout in profile.Cutouts) allEntities.AddRange(cutout.Entities);
var drawing = new Drawing("SPLITTEST", ConvertGeometry.ToProgram(allEntities));
var originalArea = drawing.Area;
// Part is ~255x55 with an interior slot. Split into 5 columns (55mm each).
var splitLines = new List<SplitLine>
{
new SplitLine(55.0, CutOffAxis.Vertical),
new SplitLine(110.0, CutOffAxis.Vertical),
new SplitLine(165.0, CutOffAxis.Vertical),
new SplitLine(220.0, CutOffAxis.Vertical)
};
var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight });
// Area must be preserved within tolerance (floating-point coords in the DXF).
var totalArea = results.Sum(d => d.Area);
Assert.Equal(originalArea, totalArea, 0);
// At least one region must yield more than one physical strip — that's the
// whole point of the fix: a cutout that spans a region disconnects it.
Assert.True(results.Count > splitLines.Count + 1,
$"Expected more than {splitLines.Count + 1} pieces (some regions split into strips), got {results.Count}");
// Every output drawing must resolve into fully-closed shapes (outer loop
// and any hole loops), with no dangling geometry. A piece that contains
// a cutout will have its entities span more than one connected loop.
foreach (var piece in results)
{
var entities = ConvertProgram.ToGeometry(piece.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
Assert.True(entities.Count >= 3, $"{piece.Name} has only {entities.Count} entities");
var shapes = OpenNest.Geometry.ShapeBuilder.GetShapes(entities);
Assert.NotEmpty(shapes);
foreach (var shape in shapes)
{
Assert.True(shape.IsClosed(),
$"{piece.Name} contains an open chain of {shape.Entities.Count} entities");
}
}
}
private static Vector GetStartPoint(Entity entity)
{
return entity switch

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