Compare commits

..

475 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
4f7bfcc3ad Merge remote-tracking branch 'origin/master' 2026-04-15 12:46:40 -04:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
54def611fa refactor(ui): switch CreateShapeFromInputs to control-type branching 2026-04-10 17:52:03 -04:00
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
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
eddbbca7ef test(shapes): verify PipeFlangeShape JSON loading and shipped config integrity 2026-04-10 17:45:46 -04:00
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
06485053fc test(shapes): cover empty-string PipeSize in addition to null 2026-04-10 17:39:50 -04:00
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
6adc5b0967 refactor(shapes): rename FlangeShape to PipeFlangeShape 2026-04-10 17:33:28 -04:00
d215d02844 style(shapes): remove redundant usings and document PipeSizes bound 2026-04-10 17:31:22 -04:00
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
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
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
08f60690a7 docs: document CadImporter service in CLAUDE.md 2026-04-10 13:27:46 -04:00
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
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
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
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
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
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
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
d921558b9c feat: add CadImporter.BuildDrawing stage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:51:58 -04:00
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
e120ece014 feat: add CadImportResult data object for CadImporter 2026-04-10 12:28:17 -04:00
264e8264be feat: add CadImportOptions for CadImporter service 2026-04-10 12:25:04 -04:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
786b6e2e88 fix: show cutting parameters dialog before assigning lead-ins
Auto-assign lead-ins silently reused existing plate parameters with no
way to change them after the first assignment. Now a dialog with the
full CuttingPanel is shown every time, pre-populated with the current
settings, so the user can review and modify before confirming.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 07:53:53 -04:00
d65f3460a9 feat: move add/remove plate buttons to plate tab, sync remove state
Removed add and remove plate buttons from the plate header panel.
The plate tab toolbar now has add/remove buttons with the remove
button state driven by PlateManager.CanRemoveCurrent. MainForm's
Plate > Remove menu item also syncs on plate change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:12:59 -04:00
ede06b1bf6 fix: enforce sentinel reactively in OnPlateAdded/OnPlateRemoved
Without this, RemoveEmptyPlates would destroy the sentinel with no
recovery, and tail-plate subscriptions would go stale after plate list
mutations. Added tests for both scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:06:35 -04:00
51eea6d1e6 refactor: wire EditNestForm to use Document for save state
EditNestForm now holds a Document instead of a bare Nest field,
eliminating duplicated LastSavePath, LastSaveDate, and SaveAs logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:05:16 -04:00
3d23ad8073 refactor: update MainForm callsites to use PlateManager directly
Replace all backward-compat wrapper calls on EditNestForm (LoadFirstPlate,
LoadLastPlate, LoadNextPlate, LoadPreviousPlate, IsFirstPlate, IsLastPlate,
EnsureSentinelPlate, CurrentPlateIndex, PlateCount) with direct access to
activeForm.PlateManager. Remove the now-unused wrapper methods and properties
from EditNestForm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:58:57 -04:00
107fd86066 refactor: wire PlateManager into EditNestForm, replacing inline plate management
Replace direct plate collection event handlers, navigation methods, and
sentinel logic in EditNestForm with PlateManager delegation. Navigation
buttons, list selection, export, and plate removal now route through
PlateManager. Backward-compatible delegating wrappers kept for MainForm
until Task 7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:56:23 -04:00
d12f0cee3e fix: restore auto-navigation on plate add in PlateManager
OnPlateAdded now navigates to the new plate when suppressNavigation is
false, matching the original EditNestForm behavior. Fixed CanRemoveCurrent
test to account for this auto-navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:52:32 -04:00
d93b69c524 feat: implement PlateManager sentinel, reactive subscriptions, and batch ops (Tasks 3-5)
- EnsureSentinel() maintains exactly one trailing empty plate, suppressing navigation events during mutation
- Reactive tail subscriptions (PartAdded/PartRemoved on last two plates) call EnsureSentinel automatically; re-subscribed after each plate list change
- BeginBatch()/EndBatch() defers sentinel enforcement during bulk operations
- GetOrCreateEmpty() returns or creates an empty plate; RemoveCurrent() removes the current plate with index clamping; CanRemoveCurrent guards deletion
- 13 new tests (30 total PlateManager tests), all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:51:25 -04:00
a65598615e fix: assign part colors to drawings created by BOM importer and MCP
Drawings created by BomImportForm and MCP InputTools were missing color
assignments, causing them to render with default empty color instead of
the standard part color palette. Moved PartColors and GetNextColor() to
Drawing in Core so all consumers share one definition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:49:48 -04:00
ed082a6799 feat: add PlateManager with navigation state and disposal
Introduces PlateChangedEventArgs and PlateManager in OpenNest.Core to centralize plate navigation logic (CurrentIndex, LoadFirst/Last/Next/Previous/At, IsFirst/IsLast). Includes full xUnit test coverage (17 tests) verifying navigation, event firing, and disposal unsubscription.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:47:18 -04:00
c9b17619ef fix: intercept arrow keys in CadConverterForm for file list navigation
FileListControl loses focus when interacting with other controls on the
form, making arrow key navigation stop working. Intercept Up/Down at
the form level via ProcessCmdKey and forward to the file list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:41:03 -04:00
f78cc78a65 fix: improve fill progress reporting and engine pipeline
- Strategies now promote results to IsOverallBest when they beat the
  pipeline best, so the UI updates immediately on improvement rather
  than waiting for each phase to complete
- PlateView only updates the main view on overall-best results, fixing
  intermediate angle-sweep layouts leaking to the plate display
- Skip Row/Column strategies for rectangle parts (redundant with Linear)
- Intercept Escape key at MainForm level via ProcessCmdKey so it always
  reaches the active PlateView regardless of focus state
- Restore keyboard focus to PlateView after fill progress form closes
- Remnant engines use SelectBestFitPair for orientation-aware pair
  selection; DefaultNestEngine tries both landscape and portrait pairs
- RemnantFiller preserves more parts during topmost-part removal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:52:13 -04:00
37130e8a28 feat: add sentinel plate and plate list enhancements
Always keep a trailing empty plate so users can immediately place parts
without manually adding a plate. Auto-appends a new sentinel when parts
land on the last plate; trims excess trailing empties on removal.

Plate list now shows Parts count and Utilization % columns. Empty plates
are filtered from save and export. Sentinel updates are deferred via
BeginInvoke to avoid collection-modified exceptions and debounced to
prevent per-part overhead on bulk operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:56:54 -04:00
6f19fe1822 feat: add context menu to delete drawings from the drawing list
Adds a right-click "Delete" option on the drawings tab that removes the
selected drawing and all its placed parts from every plate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:34:18 -04:00
81c167320d feat: redesign AutoNest dialog with grouped layout and engine selector
Rebuild the dialog from a flat layout into grouped sections: engine
selector at top, Parts group with rotation columns and summary label,
Options group, collapsible Plate Optimizer with single-field size
parsing, and a clean button bar. Adds engine sync between dialog and
toolbar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:16:09 -04:00
981188f65e feat: persist plate optimizer settings across autonest runs
Add LoadPlateOptions() method to AutoNestForm that restores saved plate
options and salvage rate from the Nest. Call this method in
RunAutoNest_Click when opening the dialog if saved options exist, and save
settings back to Nest after dialog completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:38:59 -04:00
ffd060bf61 feat: serialize plate optimizer settings in nest files
Add PlateOptions and SalvageRate properties to the Nest class and
round-trip them through NestWriter/NestReader via a new PlateOptionDto.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:38:02 -04:00
a360452da3 feat: integrate PlateOptimizer into autonest flow
When "Optimize plate size" is enabled in AutoNestForm, NestSinglePlateAsync
calls PlateOptimizer.Optimize instead of engine.Nest, trying multiple plate
sizes and resizing the plate to the winning option.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:36:29 -04:00
b3e9e5e28b feat: add plate optimizer UI controls to AutoNestForm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:34:34 -04:00
7380a43349 feat: add PlateOptimizer with cost-aware plate size selection
Tries each candidate plate size via the nesting engine, compares results
by part count then net cost (accounting for salvage credit on remnant
material), and returns the best option.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:31:36 -04:00
59e00cd707 feat: add PlateOption and PlateOptimizerResult data classes 2026-04-05 00:27:40 -04:00
44cb6e4a2b feat: add quantity status bars and hide-nested toggle to DrawingListBox
Add colored left-edge bars (green=met, orange=short) to indicate nesting
quantity status. Replace blue selection highlight with a border outline.
Add toolbar toggle to hide fully nested drawings, auto-updating as parts
are placed or removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:26:20 -04:00
5949c3ca1f feat: add Delete key to remove source parts during ActionClone
Enables a "move" workflow: clone parts to a new position, then
press Delete to remove the originals. Previously Delete just
cancelled the clone action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:21:23 -04:00
ef15421915 refactor: standardize fill strategy progress reporting via FillContext
Strategies and fillers previously called NestEngineBase.ReportProgress
directly, each constructing ProgressReport structs with phase, plate
number, and work area manually. Some strategies (RectBestFit) reported
nothing at all. This made progress updates inconsistent and flakey.

Add FillContext.ReportProgress(parts, description) as the single
standard method for intermediate progress. RunPipeline sets ActivePhase
before each strategy, and the context handles common fields. Lower-level
fillers (PairFiller, FillExtents, StripeFiller) now accept an
Action<List<Part>, string> callback instead of raw IProgress, removing
their coupling to NestEngineBase and ProgressReport.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:21:48 -04:00
943c262ad2 fix: clear part selection highlight when leaving lead-in action
ActionLeadIn.DisconnectEvents() nulled selectedLayoutPart without first
setting IsSelected = false, leaving the part permanently rendered in the
selection color (transparent blue) after switching actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:38:56 -04:00
301831e096 fix: correct Width/Length axis swap in best-fit slide offsets
BuildOffsets had Width and Length swapped after the Box axis correction
in c5943e2. Horizontal pushes used Length (X) for perpendicular sweep
and Width (Y) for push start — backwards. This caused part2 to start
inside part1's footprint, producing overlapping best-fit pairs.

Added regression test that verifies no kept best-fit pairs overlap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:26:09 -04:00
fce287e649 chore: regenerate NestProgressForm designer layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:34:43 -04:00
7e86313d7c fix: prevent Delete key from corrupting quantity during ActionClone
ObservableList.Remove fired ItemRemoved even when the item wasn't in
the list, causing Plate to decrement Quantity.Nested for clone preview
parts that were never added — producing -1 counts. Delete in PlateView
now cancels ActionClone instead of trying to remove its preview parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:34:25 -04:00
c5943e22eb fix: correct Width/Length axis mapping and add spiral center-fill
Box constructor and derived properties (Right, Top, Center, Translate, Offset)
had Width and Length swapped — Length is X axis, Width is Y axis. Corrected
across Core geometry, plate bounding box, rectangle packing, fill algorithms,
tests, and UI renderers.

Added FillSpiral with center remnant detection and recursive FillBest on
the gap between the 4 spiral quadrants. RectFill.FillBest now compares
spiral+center vs full best-fit fairly. BestCombination returns a
CombinationResult record instead of out params.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:22:55 -04:00
e50a7c82cf test: skip overlap tests gracefully when DXF fixture missing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:34:56 -04:00
7a893ef50f refactor: replace floating tool window with docked side panel
- Add general-purpose ShowSidePanel/HideSidePanel to EditNestForm
- CuttingPanel uses Dock.Top layout so collapsible panels reflow
- Add loop selection step: click contour to lock before placing lead-in
- Stay on selected part after placing a lead-in
- Delete unused LeadInToolWindow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:34:20 -04:00
925a1c7751 test: add tests for ApplySingleLeadIn on Part
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:45:02 -04:00
036b48e273 refactor: replace CuttingParametersForm with settings-based parameter init
Remove CuttingParametersForm modal dialog. PlaceLeadIn_Click,
AssignLeadIns_Click, and AssignLeadInsAllPlates now initialize
cutting parameters from saved settings or defaults instead of
showing a dialog. The CuttingPanel tool window (in LeadInToolWindow)
replaces the form for interactive parameter editing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:43:16 -04:00
bd9b0369cf feat: ActionLeadIn uses tool window and single-contour placement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:41:00 -04:00
93391c4b8f feat: create LeadInToolWindow floating form
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:38:13 -04:00
ebab795f86 feat: create reusable CuttingPanel control
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:36:22 -04:00
9f9111975d feat: add ApplySingle for exact-click single-contour lead-in placement
Adds ApplySingle to ContourCuttingStrategy that applies lead-in/out to
only the contour containing the clicked entity, emitting other contours
as raw geometry. Also adds ApplySingleLeadIn wrapper to Part.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:32:56 -04:00
25ee193ae6 feat: add auto-tab size range fields to CuttingParameters
Add AutoTabMinSize and AutoTabMaxSize properties to enable automatic tab
assignment based on part size. Update CuttingParametersSerializer for
round-trip serialization and add tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:25:06 -04:00
5bcad9667b fix: DetermineWinding used absolute area, always returned CCW
Shape.Area() returns Math.Abs(signedArea), so DetermineWinding always
detected CCW regardless of actual winding. Use ToPolygon().RotationDirection()
which uses the signed area correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:16:15 -04:00
64945220b9 fix: account for contour winding direction in lead-in normal computation
ComputeNormal assumed CW winding for all contours. For CCW-wound cutouts,
line normals pointed to the material side instead of scrap, placing lead-ins
on the wrong side. Now accepts a winding parameter: lines flip the normal
for CCW winding, and arcs flip when arc direction differs from contour
winding (concave feature detection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:06:08 -04:00
ec0baad585 feat: use Plate.Quantity as M98 L count for duplicate sheets in Cincinnati post
Instead of emitting separate M98 calls per identical sheet, use the L
(loop count) parameter so the operator can adjust quantity at the control.
M50 pallet exchange moves inside the sheet subprogram so each L iteration
gets its own exchange cycle. GOTO targets now correspond to layout groups.
Also fixes sheet name comment outputting dimensions in wrong order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:52:34 -04:00
f26edb824d fix: remove dangerous G0 X0 Y0 return-to-home rapids from Cincinnati post
Rapid traversing back to origin over a sheet of freshly cut parts risks
collisions with tipped or warped pieces. Removed from both the sheet
footer and part subprogram endings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:11:29 -04:00
aae593a73e feat: cutoff coordinates use sheet width/length variables in Cincinnati post
Cutoff features now substitute plate-edge coordinates with #SheetWidthVariable
and #SheetLengthVariable references. Vertical cutoffs at Y=plate_width emit
Y#110, horizontal cutoffs at X=plate_length emit X#111. Segmented cutoffs
only substitute the edge coordinate, interior segment endpoints stay literal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:08:40 -04:00
36d8f7fb11 docs: document G-code user variable feature in CLAUDE.md and README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:17:50 -04:00
52ad5b4575 feat: Cincinnati post emits user variables as numbered #variables
When programs have user-defined variables, the Cincinnati post now:
- Assigns numbered machine variables (#200, #201, etc.) to non-inline variables
- Emits declarations like #200=48.0 (SHEET WIDTH) in the variable declaration subprogram
- Emits X#200 instead of X48.0 in coordinates that have VariableRefs
- Handles global variables (shared number across drawings) vs local (per-drawing number)
- Inline variables emit the literal value as before

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:16:15 -04:00
7416f8ae3f feat: serialize variable definitions and \$references in NestWriter
Emit variable definitions before G-code in program text entries and use
\$varName syntax for coordinate fields that have VariableRefs, so programs
round-trip through NestWriter → NestReader without losing variable information.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:09:12 -04:00
46e3104dfc feat: add two-pass variable parsing to ProgramReader
ProgramReader now supports G-code user variables with a two-pass
approach: first pass collects variable definitions (name = expression
[inline] [global]) and evaluates them via topological sort and
ExpressionEvaluator; second pass parses G-code lines with $name
substitution and VariableRef tracking on motion and feedrate objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:04:59 -04:00
27afa04e4a feat: add Variables dictionary to Program with deep-copy in Clone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:58:36 -04:00
95b9613e2d feat: add VariableRefs tracking on Motion and Feedrate
Adds Dictionary<string,string> VariableRefs to Motion (cleared on Rotate/Offset) and string VariableRef to Feedrate, with deep-copy Clone() support, so post processors can emit variable references instead of literal coordinate values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:56:28 -04:00
3bc9301e22 feat: add ExpressionEvaluator for G-code variable expressions
Also set ContinueOnError=true on Cincinnati's post-build copy to prevent
the running WinForms app from blocking test builds via a file lock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:52:37 -04:00
1040db414f feat: add VariableDefinition type for G-code user variables
Adds immutable VariableDefinition record to OpenNest.CNC with name,
expression, resolved value, inline, and global flags. Fixes namespace
collision in PatternTilerTests and PolygonHelperTests caused by the new
OpenNest.Tests.CNC namespace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:46:37 -04:00
287023d802 feat: add syntax highlighting to gcode editor
Switch gcodeEditor from TextBox to RichTextBox and colorize G-code
tokens: rapids (amber), linear cuts (green), arcs (blue), comments
(dim gray), and mode codes (purple).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:38:14 -04:00
3a24e76dbd refactor: make ProgramEditorControl gcode editor read-only with contour comments
Remove the Apply button and OnApplyClicked handler since the gcode
editor is now read-only. Add contour label comments (e.g. "; Hole 1
(CCW)") to the formatted gcode output so users can see which feature
each group of codes belongs to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:34:25 -04:00
a6e2845261 docs: update README with OpenNest.Data project, BOM import, and contour editing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:14:23 -04:00
97d897e885 fix: filter to cut-layer entities when building contour info in ActionLeadIn
Only include cut-layer entities when building the ShapeProfile for lead-in
placement, instead of removing just scribe entities. This prevents display,
lead-in, and lead-out geometry from interfering with contour detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:03:01 -04:00
9db7abcd37 refactor: move material and thickness from Plate to Nest
Material and thickness are properties of the nest (all plates share the
same material/gauge), not individual plates. This moves them to the Nest
class, removes them from Plate and PlateSettings, and updates the UI so
EditNestInfoForm has a material field while EditPlateForm no longer shows
thickness. The nest file format gains top-level thickness/material fields
with backward-compatible reading from PlateDefaults for old files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:00:59 -04:00
3e340e67e0 refactor: organize test project into subdirectories by feature area
Move 43 root-level test files into feature-specific subdirectories
mirroring the main codebase structure: Geometry, Fill, BestFit, CutOffs,
CuttingStrategy, Engine, IO. Update namespaces to match folder paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:46:43 -04:00
7a6c407edd feat: add owner-drawn color swatch to FilterPanel
Switch colorsList from CheckedListBox (which silently ignores owner
draw) to a plain ListBox with manual checkbox, color swatch, and hex
label rendering. Clone entities in ProgramEditorControl preview to
avoid mutating originals. Remove contour color application from
CadConverterForm. Fix struct null comparison warning in SplitDrawingForm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:24:28 -04:00
9f76659d5d refactor: two-pass lead-in placement in ContourCuttingStrategy
Resolve lead-in points by walking backward through cutting order (from
perimeter outward) so each lead-in faces the next cutout to be cut
rather than pointing back at the previous lead-out. Extract EmitContour
and EmitScribeContours to eliminate duplicated cutout/perimeter logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:26:47 -04:00
a8341e9e99 fix: preserve leading rapid in programs to prevent missing contour segment
The CAD converter and BOM import were stripping the leading RapidMove
after normalizing program coordinates to origin. This left programs
starting with a LinearMove, causing the post-processor to use that
endpoint as the pierce point — making the first contour edge zero-length
and losing the closing segment (e.g. the bottom line on curved parts).

Root cause: CadConverterForm.GetDrawings(), OnSplitClicked(), and
BomImportForm all called pgm.Codes.RemoveAt(0) after offsetting the
rapid to origin. The rapid at (0,0) is a harmless no-op that marks the
contour start point for downstream processing.

Also adds EnsureLeadingRapid() safety net in the Cincinnati post for
existing nest files that already have the rapid stripped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:33:59 -04:00
fb067187b4 fix: ensure absolute coordinates and .lib extension in post output
Convert programs to absolute mode before extracting features for
Cincinnati post output, fixing incorrect coordinates when programs
are stored in incremental mode. Also ensure G89 library names
always end with .lib extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:24:32 -04:00
5c66fb3b72 feat: add snap-to-endpoint/midpoint for lead-in placement
Priority-based snapping: when the cursor is within 10px of an entity
endpoint or midpoint, snaps to it instead of the nearest contour point.
Diamond marker (endpoint) or triangle marker (midpoint) replaces the
lime dot to indicate active snap. Also refactors OnPaint into focused
helper methods and adds Arc.MidPoint().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:42:13 -04:00
5bd4c89999 chore: add missing designer resource files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:27:14 -04:00
dd93c230dd test: add bending test data for 4526 A14 PT45
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:27:14 -04:00
d6ffd8efc9 refactor: move lead-in buttons from plates tab to menubar
Move Assign/Place/Remove Lead-ins from EditNestForm toolstrip to the
Plate menu in the main menubar. Add nest-wide Assign/Remove Lead-ins
to the Nest menu for applying to all plates at once.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:27:14 -04:00
68c3a904e8 refactor: move filter panel into CAD View tab, file list fills sidebar 2026-04-01 00:27:14 -04:00
d57e2ca54b feat: add contour reordering with auto-sequence and move up/down 2026-04-01 00:27:14 -04:00
904eeb38c2 fix: adjust arrow size and color, designer reformat 2026-04-01 00:27:14 -04:00
e1bb723169 feat: apply contour-type colors in CAD view on file load 2026-04-01 00:27:14 -04:00
aa156fff57 fix: draw direction arrows after origin transform so they track pan correctly 2026-04-01 00:27:14 -04:00
d3a439181c fix: use two-line V arrowheads with dark pen for cut direction 2026-04-01 00:27:14 -04:00
bb70ae26d3 refactor: extract CutDirectionArrows and reuse in program editor preview 2026-04-01 00:25:48 -04:00
35dc954017 feat: move G-code editor side by side with preview 2026-04-01 00:12:36 -04:00
0cae9e88e7 fix: improve program editor formatting, file switching, and entity colors
- Replace Program.ToString() with Cincinnati-style formatter (spaced
  coordinates, blank lines between contours, trailing zero suppression)
- Fix empty Program tab when switching files while on the tab by
  loading immediately instead of only marking stale
- Set contour-type colors on entities at load time and restore base
  colors before selection highlight to prevent color bleed to CAD view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:12:36 -04:00
5d824a1aff feat: integrate ProgramEditorControl into CadConverterForm with tab view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:12:35 -04:00
8a293bcc9d feat: implement G-code editor with Apply parsing
Wire up the Apply button to parse the G-code text back into a Program,
rebuild contours via ConvertProgram/ShapeBuilder/ContourInfo, and fire
ProgramChanged so callers receive the updated program.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 00:12:35 -04:00
24b89689c5 feat: add direction arrows and reverse direction to program editor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:12:35 -04:00
3da5d1c70c feat: implement contour list display and entity loading
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:12:35 -04:00
d3ec4eb3e2 feat: add ProgramEditorControl layout skeleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 00:12:35 -04:00
cb446e1057 feat: add ContourInfo model with shape classification logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 00:12:35 -04:00
f3ca021fad fix: mark layout parts dirty after bulk lead-in assignment
Parts were not redrawn after AssignLeadIns because their LayoutPart
graphics paths were stale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:39:18 -04:00
ffe32fc38c test: add lead-in rotation preservation tests
Cover assign, remove, re-assign, multiple rotations, and external
HasManualLeadIns scenarios to verify rotation is preserved throughout
the lead-in lifecycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:39:14 -04:00
27bbe99e7e fix: preserve part rotation through lead-in assign/remove cycle
Track preLeadInRotation when parts are rotated so lead-in removal
can restore the correct rotation. Remove stale HasManualLeadIns and
LeadInsLocked deserialization from NestReader since these flags are
transient state, not persisted data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:39:07 -04:00
5a9a06a6a0 feat: allow re-selecting parts with existing lead-ins and use magenta preview
Remove LeadInsLocked guard so parts can be re-selected for lead-in
re-placement. Change preview color from yellow to magenta for better
visibility against the cyan contour highlight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:37:31 -04:00
c1f1c829dc fix: flip ComputeNormal for CCW arcs on concave contour features
CCW arcs (e.g. the top of a U-slot) had the radial normal pointing
into the part material instead of into the scrap. This caused the
lead-in preview to flip sides on concave features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:37:26 -04:00
e8fe01aea2 feat: highlight hovered contour during lead-in placement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:30:40 -04:00
7b7d2cd8d1 feat: track hovered contour during lead-in mouse move
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:28:55 -04:00
6ca0e9da92 feat: gray overlay on all parts when ActionLeadIn is active
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:25:26 -04:00
bcaa4a03ee feat: show post processor config dialog before save
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:08:44 -04:00
54c6f1bc89 feat: add PostProcessorConfigForm with PropertyGrid
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:07:15 -04:00
429e4b63e1 feat: add PropertyGrid attributes to CincinnatiPostConfig
Decorate all properties with [Category], [DisplayName], and [Description]
attributes for use in the WinForms PropertyGrid config dialog. Reorder
properties to match category grouping (1. Output through B. Libraries)
and replace property-level XML doc comments with the attribute descriptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:05:16 -04:00
159b54a1ec feat: add IConfigurablePostProcessor interface and implement in Cincinnati post
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:02:07 -04:00
568539d5b1 fix: offset inline feature coordinates by part location for G90 absolute mode
Part.Program stores coordinates relative to the part's own origin, but
the Cincinnati post processor emits G90 (absolute positioning). Inline
features were writing part-relative coordinates directly without adding
Part.Location, producing incorrect output. Sub-program mode was
unaffected because it uses G92 to set up local coordinate systems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:50:43 -04:00
d7fa4bef43 feat: implement tab support in ContourCuttingStrategy
When TabsEnabled is set, trims the end of each contour using a circle
centered at the lead-in point with radius equal to the tab size. The
uncut gap between the trim point and the contour start keeps the part
connected to the sheet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:40:29 -04:00
7c58cfa749 fix: correct lead-in approach angle formula mirroring pierce point
The offset direction (start→pierce) is reversed from the approach
direction (pierce→start), so the old formula produced 180°−angle
instead of the requested angle. Invisible at the 90° default but
caused 45° to render as 135°.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:01:18 -04:00
525cbc6f12 fix: draw cut direction arrows as chevron lines instead of filled triangles
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:52:33 -04:00
134771aa23 feat: add Draw Cut Direction view option and extract PlateRenderer
Add a "Draw Cut Direction" toggle to the View menu that draws small
arrowheads along cutting paths to indicate the direction of travel.
Arrows are placed on both linear and arc moves, spaced ~60px apart,
and correctly follow CW/CCW arc tangents.

Extract all rendering methods (~660 lines) from PlateView into a new
PlateRenderer class, reducing PlateView from 1640 to 979 lines.
PlateView retains input handling, selection, zoom, and part management.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:22:05 -04:00
59a66173e1 fix: exempt scribe/etch contours from lead-ins and kerf
Scribe/etch lines were being treated as cut contours by
ContourCuttingStrategy, receiving lead-ins and kerf compensation.
Now they are separated before ShapeProfile construction and emitted
as plain moves with LayerType.Scribe preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:56:07 -04:00
a2b7be44f8 fix: draw approach rapid directly to first pierce point, not part origin
The approach rapid from sheet origin was drawing to part.Location (the
program coordinate origin) then a second rapid to the actual first
pierce point. This created a dog-leg through the part origin instead
of a single straight rapid to the lead-in. Also fixed PlateProcessor
using the original program's start point instead of the processed one
when the cutting strategy is applied on-the-fly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:38:53 -04:00
e94a556f23 feat: add Remove Lead-ins button to EditNestForm toolbar
Clears all manual lead-ins from every part on the active plate and
rebuilds the layout graphics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:36:01 -04:00
428dbdb03c feat: persist cutting parameters and add pierce clearance UI
Save/restore cutting parameters as JSON in user settings so values
survive between sessions. Add pierce clearance numeric input to the
CuttingParametersForm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:35:51 -04:00
e860ca3f4a feat: add pierce clearance clamping for circle contour lead-ins
Scales down lead-ins that would place the pierce point too close to the
opposite wall of small holes. Uses quadratic solve to find the maximum
safe distance inside a clearance-reduced radius. Adds Scale() method to
all LeadIn types and applies clamping in both the strategy and the
interactive preview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:35:41 -04:00
a399c89f58 fix: resolve rendering issues when applying lead-ins to parts
Three issues caused incorrect rendering after lead-in application:
- Rapid move entities from ToGeometry() were included in ShapeProfile
  contour detection, turning traversal paths into cutting moves
- Program created with Mode.Incremental default made the absolute-to-
  incremental conversion a no-op, leaving coordinates unconverted
- AddProgramSplit didn't call StartFigure() at rapid moves, causing
  GraphicsPath to draw implicit connecting lines between contours
- Part.Rotation returned 0 from the new program instead of the actual
  rotation, displacing the sequence label on rotated parts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:35:29 -04:00
d16ef36d34 feat: add lead-out parameters and tab toggle to CuttingParametersForm
Restructure the cutting parameters dialog with separate Lead-In and
Lead-Out GroupBoxes per tab, exposing editable length/angle/radius
fields for lead-outs (previously hardcoded). Add Tabs section with
enable checkbox and width control. Also fix lead-in/lead-out angle
calculations and convert cutting strategy output to incremental mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:08:18 -04:00
5307c5c85a feat: add ActionLeadIn for manual lead-in placement on part contours
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:47:22 -04:00
21321740d6 feat: add Assign Lead-ins button to EditNestForm toolbar
Adds a text-only toolbar button to the Plates tab that opens the
CuttingParametersForm, saves the chosen parameters on the plate, and
runs LeadInAssigner with LeftSideSequencer to auto-assign lead-ins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:39:40 -04:00
7f8c708d3f feat: add CuttingParametersForm dialog for lead-in/lead-out configuration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:36:41 -04:00
ab4f806820 feat: render lead-in/lead-out codes in yellow, skip suppressed codes
Add GetGraphicsPaths/AddProgramSplit to GraphicsHelper that builds separate
GraphicsPath objects for cut vs lead-in/lead-out codes, skipping suppressed
codes. Update LayoutPart to use split paths when HasManualLeadIns is set,
drawing lead-in geometry in yellow regardless of selection state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:32:43 -04:00
c9b5ee1918 feat: serialize HasManualLeadIns, LeadInsLocked, and :SUPPRESSED in nest files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:29:49 -04:00
f34dce95da feat: add LeadInAssigner for auto-assigning lead-ins to plate parts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:26:43 -04:00
a2a19938d3 feat: add CuttingParameters property to Plate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:22:28 -04:00
c064c7647a feat: add ApplyLeadIns/RemoveLeadIns to Part with CuttingParameters storage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:21:09 -04:00
8a712b9755 feat: set Layer = Leadout on all LeadOut subclass generated codes
Adds LayerType.Leadout to all LinearMove and ArcMove instances produced
by LineLeadOut and ArcLeadOut Generate() methods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:16:11 -04:00
82de512f44 feat: set Layer = Leadin on all LeadIn subclass generated codes
Adds LayerType.Leadin to all LinearMove and ArcMove instances produced
by LineLeadIn, ArcLeadIn, LineArcLeadIn, CleanHoleLeadIn, and
LineLineLeadIn Generate() methods, plus tests covering all subclasses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:16:06 -04:00
f903cbe18a feat: add Motion.Suppressed property to mark tab-gap codes
Adds Suppressed bool to the Motion base class so LinearMove, ArcMove,
and RapidMove can be flagged for skip during rendering and post-processing
when they fall within a tab gap. Clone() updated on all three subclasses
to preserve the flag. Covered by new MotionSuppressedTests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:13:00 -04:00
3d4204db7b fix: Cincinnati post processor arc feedrate, G89 spacing, pallet exchange, and preamble
- Add radius-based arc feedrate calculation (Variables/Percentages modes)
  with configurable radius ranges (#123/#124/#125 or inline expressions)
- Fix arc distance in SpeedClassifier using actual arc length instead of
  chord length (full circles previously computed as zero)
- Fix G89 P spacing: P now adjacent to filename per CL-707 manual syntax
- Add lead-out feedrate support (#129) and arc lead-in feedrate (#127)
- Fix pallet exchange: StartAndEnd emits M50 in preamble + last sheet only
- Add G121 Smart Rapids emission when UseSmartRapids is enabled
- Add G90 absolute mode to main program preamble alongside G20/G21

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:33:50 -04:00
722f758e94 feat: dual-tangent arc fitting and DXF version export
Add ArcFit.FitWithDualTangent to constrain replacement arcs to match
tangent directions at both endpoints, preventing kinks without
introducing gaps. Add DXF year selection to CAD converter export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:16:09 -04:00
9b2322abe9 refactor: simplify GeometrySimplifier by removing wrappers and extracting shared helpers
Remove pass-through wrappers (FitWithStartTangent, MaxRadialDeviation), extract
PerpendicularDistance and NormalizeAngle helpers to deduplicate mirror axis math,
convert GetExitDirection to switch expression, and simplify ComputeEndTangent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:05:28 -04:00
b15375cca5 feat: capacity-based fill/pack split with best-fit pair placement
Change Nest() to decide fill vs pack based on total area coverage
instead of qty != 1. Items covering < 10% of the plate are packed,
so large parts get prime position and small low-qty parts fill gaps.

Qty=2 items are placed as interlocking best-fit pairs in remnant
spaces after the main pack phase, rather than as separate rectangles.

- Add ShouldFill() capacity-based heuristic
- Split pack phase: regular items pack first, then pairs
- Add PlaceBestFitPairs() for Phase 3 remnant pair placement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:38:57 -04:00
e3b388464d feat: fast-path fill and dual-axis shrink for low quantities
For qty 1-2, skip the full 6-strategy pipeline: place a single part
or a best-fit pair directly. For larger low quantities, shrink the
work area in both dimensions (sqrt scaling with 2x margin) before
running strategies, with fallback to full area if insufficient.

- Add TryFillSmallQuantity fast path (qty=1 single, qty=2 best-fit pair)
- Add ShrinkWorkArea with proportional dual-axis reduction
- Extract RunFillPipeline helper from Fill()
- Make ShrinkFiller.EstimateStartBox internal with margin parameter
- Add MaxQuantity to FillContext for strategy-level access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:38:44 -04:00
ab09f835d3 refactor: extract RunAutoNest_Click into focused helper methods
Break the 113-line click handler into single-responsibility methods:
RunAutoNestAsync, GetOrCreatePlate, NestSinglePlateAsync, and
CreatePreviewPlate (eliminates duplicated plate-cloning code).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:52:07 -04:00
f8b0fb573b fix: fill preview now matches accepted layout
Refresh PlateView preview with settled parts after Compactor.Settle
so the accepted layout matches what was shown, not the pre-settle
positions from the last progress report.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:39:43 -04:00
6ce501da11 feat: smart strategy skipping, pack rotation, and dual-sort packing
- Skip ExtentsFillStrategy for rectangle/circle parts
- Skip PairsFillStrategy for circle parts
- PackBottomLeft now tries rotated orientation when items don't fit
- PackBottomLeft tries both area-descending and length-descending sort
  orders, keeping whichever places more parts (tighter bbox on tie)
- Add user constraint override tests for AngleCandidateBuilder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:25:40 -04:00
05037bc928 feat: wire PartClassifier into engine and update angle selection
Replace RotationAnalysis.FindBestRotation with PartClassifier.Classify in
RunPipeline, propagate ClassificationResult through BuildAngles signatures and
FillContext.PartType, and rewrite AngleCandidateBuilder to dispatch on part type
(Circle=1 angle, Rectangle=2, Irregular=full sweep).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:19:20 -04:00
f83df3a55a test: add PartClassifier unit tests for all shape types
Covers all 9 cases: pure rectangle, rounded rectangle, rect with notch,
circle, L-shape, triangle, serrated edge (perimeter ratio), tilted rect
(primary angle), and empty drawing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:12:40 -04:00
84ad39414a feat: add PartClassifier with rectangle/circle/irregular detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:09:33 -04:00
fdb4a2373a fix: simplify Shape.OffsetOutward winding normalization and sync designer
OffsetOutward now normalizes to CW winding before offsetting instead of
trial-and-error with bounding box comparison. CadConverterForm designer
regenerated with new entityView1 properties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:01:46 -04:00
3a0267c041 chore: add docs/ to gitignore and remove tracked superpowers docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:01:20 -04:00
036f723876 fix: update PlateView fill path and sync stats with preview
- Route best-result updates to progress form preview in
  PlateView.FillWithProgress (Ctrl+F path) — was still using
  the old SetStationaryParts approach
- Only update results stats (parts, density, area) when
  IsOverallBest so they match the preview display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:56:41 -04:00
21a5d3b026 feat: route best-result updates to progress form preview
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:59:51 -04:00
0607c6c7c5 feat: add UpdatePreview method and PreviewPlate property
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:58:47 -04:00
c8cfeb3c6b feat: add SplitContainer and PlateView to NestProgressForm layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:57:12 -04:00
d4f424f274 refactor: simplify FillExtents with PartPair record and FillLinear delegation
Replace verbose value tuple with named PartPair record struct, extract
AnchorToWorkArea/PairBbox helpers to eliminate duplication, and delegate
RepeatColumns to FillLinear.Fill which already handles geometry-aware
column tiling with overlap fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:16:16 -04:00
028b1fabfc fix: move bend line action links above list for more vertical space
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:32:53 -04:00
a7c2fcffe6 test: add edge case tests for Collision; update CLAUDE.md 2026-03-29 09:43:31 -04:00
b834813889 refactor: delegate Part.Intersects to Collision.Check 2026-03-29 09:41:40 -04:00
4fa6100722 test: add hole subtraction and batch collision tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 09:40:28 -04:00
8f2fbee02c feat: add Collision static class with Sutherland-Hodgman clipping and tests
Polygon-polygon collision detection using convex decomposition (ear-clipping
triangulation) followed by Sutherland-Hodgman clipping on each triangle pair.
Handles overlapping, non-overlapping, edge-touching, containment, and concave
polygons. Includes hole subtraction support for future use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:35:41 -04:00
230a11d32e feat: add CollisionResult data class for polygon collision detection
Immutable result type that holds overlap flag, overlap regions (as polygons),
intersection points, and computed overlap area.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:32:51 -04:00
953429dae9 fix: add overlap safety check and diagnostics to FillGrid Step 2
FillGrid had no overlap check after perpendicular tiling of the row
pattern (Step 2), unlike Step 1 which had one. When geometry-aware
FindPatternCopyDistance underestimated row spacing, overlapping parts
were returned unchecked.

Changes:
- Make FillLinear.HasOverlappingParts shape-aware (bbox pre-filter +
  Part.Intersects) instead of bbox-only, preventing false positives on
  interlocking pairs while catching real overlaps
- Add missing overlap safety check after Step 2 perpendicular tiling
  with bbox fallback
- Add diagnostic Debug.WriteLine logging when overlap fallback triggers,
  including engine label, step, direction, work area, spacing, pattern
  details, and overlapping part locations/rotations for reproduction
- Add FillLinear.Label property set at all callsites for log traceability
- Refactor LinearFillStrategy and ExtentsFillStrategy to use shared
  FillHelpers.BestOverAngles helper for angle-sweep logic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:08:38 -04:00
1c2b569ff4 fix: eliminate endpoint gaps in EllipseConverter arc output
EllipseConverter computed arc radius from start point only, causing
~0.0009 unit gaps between consecutive arcs. Use circumcircle of
(start, mid, end) points so both endpoints lie exactly on the arc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:37:01 -04:00
048b10a1e9 refactor: deduplicate BestFitHorizontal and BestFitVertical
Extract shared BestFitAxis helper parameterized by orientation,
eliminating 23-line duplicate in rectangle packing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:23:08 -04:00
3022982f6d refactor: consolidate HasOverlappingParts into FillHelpers
StripeFiller and FillExtents had identical 24-line overlap detection
methods; move to FillHelpers and delegate from both callers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:22:00 -04:00
cc85493a0c refactor: deduplicate EvenlyDistribute horizontal and vertical
Extract shared EvenlyDistribute helper parameterized by axis,
eliminating 27-line duplicate between the two methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:20:01 -04:00
3da287cdc0 refactor: extract generic MergePass from GeometryOptimizer
The arc and line Optimize methods had identical merge-loop structure;
extract a generic MergePass helper with type-specific delegates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:19:04 -04:00
d1a701a7f7 refactor: move shared GetRectangle to Action base class
Both ActionSelect and ActionZoomWindow had identical 29-line
GetRectangle methods; consolidate into the common base class.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:16:00 -04:00
17f786c9e8 refactor: delegate Program.Rotate(angle) to Rotate(angle, origin)
The parameterless rotation is equivalent to rotating around (0,0),
so delegate to the origin overload to eliminate 30-line duplicate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:14:51 -04:00
b7a8e2662c refactor: deduplicate FillHorizontal and FillVertical in FillEndOdd
Extract shared FillAxis helper parameterized by orientation,
eliminating 34-line duplicate between horizontal and vertical fills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:13:00 -04:00
912a47c5e8 refactor: extract shared ArcFit utilities from SplineConverter and GeometrySimplifier
Move identical FitWithStartTangent and MaxRadialDeviation methods
to a shared ArcFit class, eliminating 40-line duplicate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:11:47 -04:00
a85213a524 refactor: deduplicate SortColumnsByHeight and SortRowsByWidth
Extract shared SortStrips helper parameterized by axis selectors,
eliminating 61-line near-duplicate between column and row sorting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:05:54 -04:00
fb696aaf58 refactor: remove dead code and deduplicate SpatialQuery
Remove 4 unused ClosestDistance methods and extract shared
FindVerticalLimits/FindHorizontalLimits helpers from the
GetLargestBox methods, eliminating 6 duplicate code groups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:03:27 -04:00
d854a1f5d2 fix: use arc joins at convex corners in offset geometry
Convex corners were being miter-joined (lines extended to a point)
because IntersectsUnbounded always finds an intersection for non-parallel
lines. Now checks the cross product of original line directions to detect
convex corners and inserts an arc instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:53:20 -04:00
abc707f1d9 fix: allow line-on-line contact and remove extra spacing gap
- Part.Intersects: filter intersection points at a vertex of either
  shape (was both), so edge-touching parts are not flagged as overlapping
- NestEngineBase.HasOverlaps: use epsilon-based bounding box pre-filter
  consistent with FillExtents and Plate.HasOverlappingParts
- PartGeometry.GetOffsetPartLines: remove extra chordTolerance added to
  spacing offset — was causing 0.002" gap beyond the intended part spacing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:36:35 -04:00
61b917c398 chore: remove docs/superpowers and add to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:48:59 -04:00
e3e51611d5 fix: only remove bend-generated etch entities, preserve user etch lines
UpdateEtchEntities was removing all entities on the ETCH layer, which
also deleted user-added etch marks like part numbers. Now tags generated
bend etch lines with a BendEtch tag and filters on that instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:47:26 -04:00
8104bd3626 feat: rotate bend labels parallel to bend line and center them
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:36:10 -04:00
ae262b8a77 feat: add scrollbar and arrow key navigation to FileListControl
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:53:36 -04:00
afbbc9ed79 feat: improve EntityView labels for circles and small entities
Place circle labels on the circumference using golden angle distribution
so concentric circles don't overlap. Hide labels when the entity is too
small on screen to fit the badge.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:45:28 -04:00
f46bcd4e4b feat: add filter toggle to remnant viewer for showing all remnants
The remnant viewer previously always filtered by smallest part dimension,
hiding large remnants that were narrower than the smallest part. Added a
"Filter by part size" checkbox (on by default) so users can toggle this
off to see all remnants regardless of size.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:32:33 -04:00
3f6bc2b2a1 feat: guard Plate quantity/utilization/overlap for IsCutOff parts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:14:28 -04:00
7681a1bad0 feat: add Drawing.IsCutOff flag for cut-off support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:12:49 -04:00
a548d5329a chore: update NestProgressForm designer layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:09:07 -04:00
07012033c7 feat: use direction-specific engines in StripNestEngine
Height shrink now uses HorizontalRemnantEngine (minimizes Y-extent)
and width shrink uses VerticalRemnantEngine (minimizes X-extent).
IterativeShrinkFiller accepts an optional widthFillFunc so each
shrink axis can use a different fill engine.

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

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

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

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

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

Pipeline test now checks against active strategy count dynamically.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:22:13 -04:00
811d23510e feat: add RowFillStrategy and ColumnFillStrategy with registry integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:48:00 -04:00
0597a11a23 feat: implement StripeFiller.Fill with pair iteration, stripe tiling, and remnant fill
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:44:59 -04:00
2ae1d513cf feat: add StripeFiller.ConvergeStripeAngle iterative convergence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:37:59 -04:00
904d30d05d feat: add StripeFiller.FindAngleForTargetSpan with scan-then-bisect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:36:10 -04:00
422 changed files with 64645 additions and 5240 deletions

1
.gitignore vendored
View File

@@ -209,6 +209,7 @@ FakesAssemblies/
# Claude Code # Claude Code
.claude/ .claude/
.superpowers/ .superpowers/
docs/superpowers/
# Launch settings # Launch settings
**/Properties/launchSettings.json **/Properties/launchSettings.json

View File

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

View File

@@ -24,25 +24,28 @@ Eight projects form a layered architecture:
Domain model, geometry, and CNC primitives organized into namespaces: Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`. - **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. - **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, and `RotatingCalipers`. - **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental). - **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed. - **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration. - **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`. - **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning. - **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
### OpenNest.Engine (class library, depends on Core) ### OpenNest.Engine (class library, depends on Core)
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine. `AutoNester` handles mixed-part NFP-based nesting with simulated annealing (not yet integrated into the registry). Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine.
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`. - **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases)`VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency. - **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`. - **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`. - **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups. - **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
- **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions. - **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts. - **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts.
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): NFP-based nesting (not yet integrated)`AutoNester` (mixed-part nesting with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`NestResult`. - **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities`AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data). - **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints. - `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback. - `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
@@ -54,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). - `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
- `ProgramReader` — G-code text parser. - `ProgramReader` — G-code text parser.
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types. - `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) ### 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`). Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
@@ -76,15 +81,15 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
### OpenNest (WinForms WinExe, depends on Core + Engine + IO) ### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
The UI application with MDI interface. The UI application with MDI interface.
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc. - **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`. - **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`. - **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. - **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime.
## File Format ## File Format
Nest files (`.nest`, ZIP-based) use v2 JSON format: Nest files (`.nest`, ZIP-based) use v2 JSON format:
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation) - `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id) - `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed) - `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
@@ -100,6 +105,8 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho
Always keep `README.md` and `CLAUDE.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change. Always keep `README.md` and `CLAUDE.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change.
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
## Key Patterns ## Key Patterns
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`. - OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
@@ -110,3 +117,6 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. - Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - `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.

View File

@@ -5,8 +5,6 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
namespace OpenNest.Api; namespace OpenNest.Api;
@@ -25,20 +23,26 @@ public static class NestRunner
// 1. Import DXFs → Drawings // 1. Import DXFs → Drawings
var drawings = new List<Drawing>(); var drawings = new List<Drawing>();
var importer = new DxfImporter();
foreach (var part in request.Parts) foreach (var part in request.Parts)
{ {
if (!File.Exists(part.DxfPath)) if (!File.Exists(part.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath); throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0) Drawing drawing;
try
{
drawing = CadImporter.ImportDrawing(part.DxfPath,
new CadImportOptions { Quantity = part.Quantity });
}
catch (System.Exception ex)
{
throw new InvalidOperationException(
$"Failed to import DXF: {part.DxfPath}", ex);
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}"); throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
var pgm = ConvertGeometry.ToProgram(geometry);
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(name);
drawing.Program = pgm;
drawings.Add(drawing); drawings.Add(drawing);
} }
@@ -58,6 +62,8 @@ public static class NestRunner
// 3. Multi-plate loop // 3. Multi-plate loop
var nest = new Nest(); var nest = new Nest();
nest.Thickness = request.Thickness;
nest.Material = new Material(request.Material);
var remaining = items.Select(item => item.Quantity).ToList(); var remaining = items.Select(item => item.Quantity).ToList();
while (remaining.Any(q => q > 0)) while (remaining.Any(q => q > 0))
@@ -66,9 +72,7 @@ public static class NestRunner
var plate = new Plate(request.SheetSize) var plate = new Plate(request.SheetSize)
{ {
Thickness = request.Thickness,
PartSpacing = request.Spacing, PartSpacing = request.Spacing,
Material = new Material(request.Material)
}; };
// Build items for this pass with remaining quantities // Build items for this pass with remaining quantities

View File

@@ -1,5 +1,4 @@
using OpenNest; using OpenNest;
using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
using System; using System;
@@ -7,6 +6,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection;
using System.Threading; using System.Threading;
return NestConsole.Run(args); return NestConsole.Run(args);
@@ -20,6 +20,12 @@ static class NestConsole
if (options == null) if (options == null)
return 0; // --help was requested return 0; // --help was requested
if (options.ListPosts)
{
ListPostProcessors(options);
return 0;
}
if (options.InputFiles.Count == 0) if (options.InputFiles.Count == 0)
{ {
PrintUsage(); PrintUsage();
@@ -35,7 +41,6 @@ static class NestConsole
} }
} }
using var log = SetUpLog(options);
var nest = LoadOrCreateNest(options); var nest = LoadOrCreateNest(options);
if (nest == null) if (nest == null)
@@ -62,12 +67,9 @@ static class NestConsole
var overlapCount = CheckOverlaps(plate, options); var overlapCount = CheckOverlaps(plate, options);
// Flush and close the log before printing results.
Trace.Flush();
log?.Dispose();
PrintResults(success, plate, elapsed); PrintResults(success, plate, elapsed);
Save(nest, options); Save(nest, options);
PostProcess(nest, options);
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0; return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
} }
@@ -105,9 +107,6 @@ static class NestConsole
case "--no-save": case "--no-save":
o.NoSave = true; o.NoSave = true;
break; break;
case "--no-log":
o.NoLog = true;
break;
case "--keep-parts": case "--keep-parts":
o.KeepParts = true; o.KeepParts = true;
break; break;
@@ -120,6 +119,18 @@ static class NestConsole
case "--engine" when i + 1 < args.Length: case "--engine" when i + 1 < args.Length:
NestEngineRegistry.ActiveEngineName = args[++i]; NestEngineRegistry.ActiveEngineName = args[++i];
break; break;
case "--post" when i + 1 < args.Length:
o.PostName = args[++i];
break;
case "--post-output" when i + 1 < args.Length:
o.PostOutput = args[++i];
break;
case "--posts-dir" when i + 1 < args.Length:
o.PostsDir = args[++i];
break;
case "--list-posts":
o.ListPosts = true;
break;
case "--help": case "--help":
case "-h": case "-h":
PrintUsage(); PrintUsage();
@@ -134,21 +145,6 @@ static class NestConsole
return o; 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) static Nest LoadOrCreateNest(Options options)
{ {
var nestFile = options.InputFiles.FirstOrDefault(f => var nestFile = options.InputFiles.FirstOrDefault(f =>
@@ -221,30 +217,15 @@ static class NestConsole
static Drawing ImportDxf(string path) static Drawing ImportDxf(string path)
{ {
var importer = new DxfImporter(); try
if (!importer.GetGeometry(path, out var geometry))
{ {
Console.Error.WriteLine($"Error: failed to read DXF file: {path}"); return CadImporter.ImportDrawing(path);
}
catch (System.Exception ex)
{
Console.Error.WriteLine($"Error: failed to import DXF '{path}': {ex.Message}");
return null; return null;
} }
if (geometry.Count == 0)
{
Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}");
return null;
}
var pgm = ConvertGeometry.ToProgram(geometry);
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) static void ApplyTemplate(Plate plate, Options options)
@@ -258,10 +239,9 @@ static class NestConsole
return; return;
} }
var templatePlate = new NestReader(options.TemplateFile).Read().PlateDefaults.CreateNew(); var templateNest = new NestReader(options.TemplateFile).Read();
plate.Thickness = templatePlate.Thickness; var templatePlate = templateNest.PlateDefaults.CreateNew();
plate.Quadrant = templatePlate.Quadrant; plate.Quadrant = templatePlate.Quadrant;
plate.Material = templatePlate.Material;
plate.EdgeSpacing = templatePlate.EdgeSpacing; plate.EdgeSpacing = templatePlate.EdgeSpacing;
plate.PartSpacing = templatePlate.PartSpacing; plate.PartSpacing = templatePlate.PartSpacing;
Console.WriteLine($"Template: {options.TemplateFile}"); Console.WriteLine($"Template: {options.TemplateFile}");
@@ -382,6 +362,100 @@ static class NestConsole
Console.WriteLine($"Saved: {outputFile}"); Console.WriteLine($"Saved: {outputFile}");
} }
static string ResolvePostsDir(Options options)
{
if (options.PostsDir != null)
return options.PostsDir;
var exePath = Assembly.GetEntryAssembly()?.Location
?? typeof(NestConsole).Assembly.Location;
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
}
static List<IPostProcessor> LoadPostProcessors(string postsDir)
{
var processors = new List<IPostProcessor>();
if (!Directory.Exists(postsDir))
return processors;
foreach (var file in Directory.GetFiles(postsDir, "*.dll"))
{
try
{
var assembly = Assembly.LoadFrom(file);
foreach (var type in assembly.GetTypes())
{
if (!typeof(IPostProcessor).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract)
continue;
if (Activator.CreateInstance(type) is IPostProcessor processor)
processors.Add(processor);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}");
}
}
return processors;
}
static void ListPostProcessors(Options options)
{
var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir);
if (processors.Count == 0)
{
Console.WriteLine($"No post processors found in: {postsDir}");
return;
}
Console.WriteLine($"Post processors ({postsDir}):");
foreach (var p in processors)
Console.WriteLine($" {p.Name,-30} {p.Description}");
}
static void PostProcess(Nest nest, Options options)
{
if (options.PostName == null)
return;
var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir);
var post = processors.FirstOrDefault(p =>
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase));
if (post == null)
{
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
if (processors.Count > 0)
Console.Error.WriteLine($"Available: {string.Join(", ", processors.Select(p => p.Name))}");
else
Console.Error.WriteLine($"No post processors found in: {postsDir}");
return;
}
var outputFile = options.PostOutput;
if (outputFile == null)
{
var firstInput = options.InputFiles[0];
outputFile = Path.Combine(
Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc");
}
post.Post(nest, outputFile);
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
}
static void PrintUsage() static void PrintUsage()
{ {
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]"); Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
@@ -406,7 +480,10 @@ static class NestConsole
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); 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(" --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-save Skip saving output file");
Console.Error.WriteLine(" --no-log Skip writing debug log file"); Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
Console.Error.WriteLine(" --list-posts List available post processors and exit");
Console.Error.WriteLine(" -h, --help Show this help"); Console.Error.WriteLine(" -h, --help Show this help");
} }
@@ -421,9 +498,12 @@ static class NestConsole
public Size? PlateSize; public Size? PlateSize;
public bool CheckOverlaps; public bool CheckOverlaps;
public bool NoSave; public bool NoSave;
public bool NoLog;
public bool KeepParts; public bool KeepParts;
public bool AutoNest; public bool AutoNest;
public string TemplateFile; public string TemplateFile;
public string PostName;
public string PostOutput;
public string PostsDir;
public bool ListPosts;
} }
} }

View File

@@ -125,61 +125,36 @@ namespace OpenNest
parts.ForEach(part => Bottom(fixedPart, part)); parts.ForEach(part => Bottom(fixedPart, part));
} }
public static void EvenlyDistributeHorizontally(List<Part> parts) public static void EvenlyDistributeHorizontally(List<Part> parts) =>
EvenlyDistribute(parts, horizontal: true);
public static void EvenlyDistributeVertically(List<Part> parts) =>
EvenlyDistribute(parts, horizontal: false);
private static void EvenlyDistribute(List<Part> parts, bool horizontal)
{ {
if (parts.Count < 3) if (parts.Count < 3)
return; return;
var list = new List<Part>(parts); var list = new List<Part>(parts);
list.Sort((p1, p2) => p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)); list.Sort((p1, p2) => horizontal
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
var lastIndex = list.Count - 1; var lastIndex = list.Count - 1;
var first = list[0]; var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
var last = list[lastIndex]; var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y;
var start = first.BoundingBox.Center.X; var spacing = (end - start) / lastIndex;
var end = last.BoundingBox.Center.X;
var diff = end - start;
var spacing = diff / lastIndex; for (var i = 1; i < lastIndex; ++i)
for (int i = 1; i < lastIndex; ++i)
{ {
var part = list[i]; var part = list[i];
var newX = start + i * spacing; var cur = horizontal ? part.BoundingBox.Center.X : part.BoundingBox.Center.Y;
var curX = part.BoundingBox.Center.X; var delta = start + i * spacing - cur;
part.Offset(newX - curX, 0); part.Offset(horizontal ? delta : 0, horizontal ? 0 : delta);
}
}
public static void EvenlyDistributeVertically(List<Part> parts)
{
if (parts.Count < 3)
return;
var list = new List<Part>(parts);
list.Sort((p1, p2) => p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
var lastIndex = list.Count - 1;
var first = list[0];
var last = list[lastIndex];
var start = first.BoundingBox.Center.Y;
var end = last.BoundingBox.Center.Y;
var diff = end - start;
var spacing = diff / lastIndex;
for (int i = 1; i < lastIndex; ++i)
{
var part = list[i];
var newX = start + i * spacing;
var curX = part.BoundingBox.Center.Y;
part.Offset(0, newX - curX);
} }
} }
} }

View File

@@ -0,0 +1,97 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Drawing;
namespace OpenNest.Bending
{
public class Bend
{
public static readonly Layer EtchLayer = new Layer("ETCH")
{
Color = Color.Green,
IsVisible = true
};
private const double DefaultEtchLength = 1.0;
private const string BendEtchTag = "BendEtch";
public Vector StartPoint { get; set; }
public Vector EndPoint { get; set; }
public BendDirection Direction { get; set; }
public double? Angle { get; set; }
public double? Radius { get; set; }
public string NoteText { get; set; }
[System.Text.Json.Serialization.JsonIgnore]
public Entity SourceEntity { get; set; }
public double Length => StartPoint.DistanceTo(EndPoint);
public double AngleRadians => Angle.HasValue
? OpenNest.Math.Angle.ToRadians(Angle.Value)
: 0;
public Line ToLine() => new Line(StartPoint, EndPoint);
/// <summary>
/// Returns the angle of the bend line itself (not the bend angle).
/// Used for grain direction comparison.
/// </summary>
public double LineAngle => StartPoint.AngleTo(EndPoint);
/// <summary>
/// Generates etch mark entities for this bend (up bends only).
/// Returns 1" dashes at each end of the bend line, or the full line if shorter than 3".
/// </summary>
public List<Line> GetEtchEntities(double etchLength = DefaultEtchLength)
{
var result = new List<Line>();
if (Direction != BendDirection.Up)
return result;
var length = Length;
if (length < etchLength * 3.0)
{
result.Add(CreateEtchLine(StartPoint, EndPoint));
}
else
{
var angle = StartPoint.AngleTo(EndPoint);
var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength;
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)));
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
}
return result;
}
/// <summary>
/// Removes existing etch entities from the list and regenerates from the given bends.
/// </summary>
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
{
entities.RemoveAll(e => e.Tag == BendEtchTag);
if (bends == null) return;
foreach (var bend in bends)
entities.AddRange(bend.GetEtchEntities());
}
private static Line CreateEtchLine(Vector start, Vector end)
{
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green, Tag = BendEtchTag };
}
public override string ToString()
{
var dir = Direction.ToString();
var angle = Angle?.ToString("0.##") ?? "?";
var radius = Radius?.ToString("0.###") ?? "?";
return $"{dir} {angle}° R{radius}";
}
}
}

View File

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

View File

@@ -1,4 +1,5 @@
using OpenNest.Geometry; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC namespace OpenNest.CNC
{ {
@@ -65,7 +66,9 @@ namespace OpenNest.CNC
{ {
return new ArcMove(EndPoint, CenterPoint, Rotation) return new ArcMove(EndPoint, CenterPoint, Rotation)
{ {
Layer = Layer Layer = Layer,
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
}; };
} }

View File

@@ -1,4 +1,6 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
@@ -7,69 +9,345 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
public CuttingParameters Parameters { get; set; } public CuttingParameters Parameters { get; set; }
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
public CuttingResult Apply(Program partProgram, Vector approachPoint) public CuttingResult Apply(Program partProgram, Vector approachPoint)
{ {
var exitPoint = approachPoint; return Apply(partProgram, approachPoint, Vector.Invalid);
}
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
{
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
var profile = new ShapeProfile(entities); var profile = new ShapeProfile(entities);
// Find closest point on perimeter from exit point // Start from the bounding box corner opposite the origin (max X, max Y)
var perimeterPoint = profile.Perimeter.ClosestPointTo(exitPoint, out var perimeterEntity); var bbox = entities.GetBoundingBox();
var startCorner = new Vector(bbox.Right, bbox.Top);
// Chain cutouts by nearest-neighbor from perimeter point, then reverse // Initial pass: sequence cutouts from bbox corner
// so farthest cutouts are cut first, nearest-to-perimeter cut last var seedPoint = startCorner;
var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint); var orderedCutouts = SequenceCutouts(profile.Cutouts, seedPoint);
orderedCutouts.Reverse(); orderedCutouts.Reverse();
// Build output program: cutouts first (farthest to nearest), perimeter last var perimeterSeed = profile.Perimeter.ClosestPointTo(seedPoint, out _);
var result = new Program(); var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
var currentPoint = exitPoint;
foreach (var cutout in orderedCutouts) Vector perimeterPt;
Entity perimeterEntity;
if (!double.IsNaN(nextPartStart.X) && cutoutEntries.Count > 0)
{ {
var contourType = DetectContourType(cutout); // Iterate: each pass refines the perimeter lead-in which changes
var closestPt = cutout.ClosestPointTo(currentPoint, out var entity); // the internal sequence which changes the last cutout position
var normal = ComputeNormal(closestPt, entity, contourType); for (var iter = 0; iter < 3; iter++)
var winding = DetermineWinding(cutout); {
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _);
var leadIn = SelectLeadIn(contourType); orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
var leadOut = SelectLeadOut(contourType); orderedCutouts.Reverse();
cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
}
result.Codes.AddRange(leadIn.Generate(closestPt, normal, winding)); var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
var reindexed = cutout.ReindexAt(closestPt, entity); perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt)); }
// TODO: MicrotabLeadOut — trim last cutting move by GapSize else
result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding)); {
var perimeterRef = cutoutEntries.Count > 0 ? cutoutEntries[0].Point : approachPoint;
currentPoint = closestPt; perimeterPt = profile.Perimeter.ClosestPointTo(perimeterRef, out perimeterEntity);
} }
var lastCutPoint = exitPoint; var result = new Program(Mode.Absolute);
// Perimeter last EmitScribeContours(result, scribeEntities);
foreach (var entry in cutoutEntries)
{ {
var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity); if (!entry.Shape.IsClosed())
lastCutPoint = perimeterPt; EmitRawContour(result, entry.Shape);
var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External); else
var winding = DetermineWinding(profile.Perimeter); EmitContour(result, entry.Shape, entry.Point, entry.Entity);
var leadIn = SelectLeadIn(ContourType.External);
var leadOut = SelectLeadOut(ContourType.External);
result.Codes.AddRange(leadIn.Generate(perimeterPt, normal, winding));
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding));
} }
if (!profile.Perimeter.IsClosed())
EmitRawContour(result, profile.Perimeter);
else
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
result.Mode = Mode.Incremental;
return new CuttingResult return new CuttingResult
{ {
Program = result, Program = result,
LastCutPoint = lastCutPoint LastCutPoint = perimeterPt
}; };
} }
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType)
{
var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
var profile = new ShapeProfile(entities);
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribeEntities);
// 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 (skip open contours)
foreach (var cutout in profile.Cutouts)
{
if (!cutout.IsClosed())
{
EmitRawContour(result, cutout);
}
else if (cutout == targetShape)
{
var ct = DetectContourType(cutout);
EmitContour(result, cutout, point, matchedEntity, ct);
}
else
{
EmitRawContour(result, cutout);
}
}
// Emit perimeter
if (!profile.Perimeter.IsClosed())
{
EmitRawContour(result, profile.Perimeter);
}
else if (profile.Perimeter == targetShape)
{
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
}
else
{
EmitRawContour(result, profile.Perimeter);
}
result.Mode = Mode.Incremental;
return new CuttingResult
{
Program = result,
LastCutPoint = point
};
}
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity)
{
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
if (matched != null)
return (profile.Perimeter, matched);
foreach (var cutout in profile.Cutouts)
{
matched = FindMatchingEntity(cutout, clickedEntity);
if (matched != null)
return (cutout, matched);
}
// Fallback: closest shape, use closest point to find entity
var best = profile.Perimeter;
var bestPt = profile.Perimeter.ClosestPointTo(point, out var bestEntity);
var bestDist = bestPt.DistanceTo(point);
foreach (var cutout in profile.Cutouts)
{
var pt = cutout.ClosestPointTo(point, out var cutoutEntity);
var dist = pt.DistanceTo(point);
if (dist < bestDist)
{
best = cutout;
bestEntity = cutoutEntity;
bestDist = dist;
}
}
return (best, bestEntity);
}
private static Entity FindMatchingEntity(Shape shape, Entity clickedEntity)
{
foreach (var shapeEntity in shape.Entities)
{
if (shapeEntity.GetType() != clickedEntity.GetType())
continue;
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
{
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon)
return shapeEntity;
}
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
{
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon)
return shapeEntity;
}
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
{
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon)
return shapeEntity;
}
}
return null;
}
private void EmitRawContour(Program program, Shape shape)
{
var startPoint = GetShapeStartPoint(shape);
program.Codes.Add(new RapidMove(startPoint));
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
}
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint)
{
var entries = new ContourEntry[cutouts.Count];
var currentPoint = startPoint;
// Walk backward through cutting order (from perimeter outward)
// so each cutout's lead-in point faces the next cutout to be cut
for (var i = cutouts.Count - 1; i >= 0; i--)
{
var closestPt = cutouts[i].ClosestPointTo(currentPoint, out var entity);
entries[i] = new ContourEntry(cutouts[i], closestPt, entity);
currentPoint = closestPt;
}
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);
var winding = DetermineWinding(shape);
var normal = ComputeNormal(point, entity, contourType, winding);
var leadIn = SelectLeadIn(contourType);
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 reindexedShape = shape.ReindexAt(point, entity);
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
}
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
{
if (scribeEntities.Count == 0) return;
var shapes = ShapeBuilder.GetShapes(scribeEntities);
foreach (var shape in shapes)
{
var startPt = GetShapeStartPoint(shape);
program.Codes.Add(new RapidMove(startPt));
program.Codes.AddRange(ConvertShapeToMoves(shape, startPt, LayerType.Scribe));
}
}
private List<Shape> SequenceCutouts(List<Shape> cutouts, Vector startPoint) private List<Shape> SequenceCutouts(List<Shape> cutouts, Vector startPoint)
{ {
var remaining = new List<Shape>(cutouts); var remaining = new List<Shape>(cutouts);
@@ -102,7 +380,7 @@ namespace OpenNest.CNC.CuttingStrategy
return ordered; return ordered;
} }
private ContourType DetectContourType(Shape cutout) public static ContourType DetectContourType(Shape cutout)
{ {
if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle) if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle)
return ContourType.ArcCircle; return ContourType.ArcCircle;
@@ -110,23 +388,33 @@ namespace OpenNest.CNC.CuttingStrategy
return ContourType.Internal; return ContourType.Internal;
} }
private double ComputeNormal(Vector point, Entity entity, ContourType contourType) public static double ComputeNormal(Vector point, Entity entity, ContourType contourType,
RotationType winding = RotationType.CW)
{ {
double normal; double normal;
if (entity is Line line) if (entity is Line line)
{ {
// Perpendicular to line direction // Perpendicular to line direction: tangent + π/2 = left side.
// Left side = outward for CW winding; for CCW winding, outward
// is on the right side, so flip.
var tangent = line.EndPoint.AngleFrom(line.StartPoint); var tangent = line.EndPoint.AngleFrom(line.StartPoint);
normal = tangent + Math.Angle.HalfPI; normal = tangent + Math.Angle.HalfPI;
if (winding == RotationType.CCW)
normal += System.Math.PI;
} }
else if (entity is Arc arc) else if (entity is Arc arc)
{ {
// Radial direction from center to point // Radial direction from center to point.
// Flip when the arc direction differs from the contour winding —
// that indicates a concave feature where radial points inward.
normal = point.AngleFrom(arc.Center); normal = point.AngleFrom(arc.Center);
if (arc.Rotation != winding)
normal += System.Math.PI;
} }
else if (entity is Circle circle) else if (entity is Circle circle)
{ {
// Radial outward — always correct regardless of winding
normal = point.AngleFrom(circle.Center); normal = point.AngleFrom(circle.Center);
} }
else else
@@ -141,11 +429,61 @@ namespace OpenNest.CNC.CuttingStrategy
return Math.Angle.NormalizeRad(normal); return Math.Angle.NormalizeRad(normal);
} }
private RotationType DetermineWinding(Shape shape) public static RotationType DetermineWinding(Shape shape)
{ {
// Use signed area: positive = CCW, negative = CW if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
var area = shape.Area(); return circle.Rotation;
return area >= 0 ? RotationType.CCW : RotationType.CW;
var polygon = shape.ToPolygon();
if (polygon.Vertices.Count < 3)
return RotationType.CCW;
return polygon.RotationDirection();
}
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
{
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
return leadIn;
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
var maxRadius = circle.Radius - Parameters.PierceClearance;
if (maxRadius <= 0)
return leadIn;
var distFromCenter = piercePoint.DistanceTo(circle.Center);
if (distFromCenter <= maxRadius)
return leadIn;
// Compute max distance from contourPoint toward piercePoint that stays
// inside a circle of radius maxRadius centered at circle.Center.
// Solve: |contourPoint + t*d - center|^2 = maxRadius^2
var currentDist = contourPoint.DistanceTo(piercePoint);
if (currentDist < Math.Tolerance.Epsilon)
return leadIn;
var dx = (piercePoint.X - contourPoint.X) / currentDist;
var dy = (piercePoint.Y - contourPoint.Y) / currentDist;
var vx = contourPoint.X - circle.Center.X;
var vy = contourPoint.Y - circle.Center.Y;
var b = 2.0 * (vx * dx + vy * dy);
var c = vx * vx + vy * vy - maxRadius * maxRadius;
var discriminant = b * b - 4.0 * c;
if (discriminant < 0)
return leadIn;
var t = (-b + System.Math.Sqrt(discriminant)) / 2.0;
if (t <= 0)
return leadIn;
var scale = t / currentDist;
if (scale >= 1.0)
return leadIn;
return leadIn.Scale(scale);
} }
private LeadIn SelectLeadIn(ContourType contourType) private LeadIn SelectLeadIn(ContourType contourType)
@@ -168,7 +506,71 @@ namespace OpenNest.CNC.CuttingStrategy
}; };
} }
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint) private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
{
var tabCircle = new Circle(center, tabSize);
var entities = new List<Entity>(shape.Entities);
// Trim end: walk backward removing entities inside the tab circle
while (entities.Count > 0)
{
var entity = entities[entities.Count - 1];
if (entity.Intersects(tabCircle, out var pts) && pts.Count > 0)
{
// Find intersection furthest from center (furthest along path from end)
var best = pts[0];
var bestDist = best.DistanceTo(center);
for (var j = 1; j < pts.Count; j++)
{
var dist = pts[j].DistanceTo(center);
if (dist > bestDist)
{
best = pts[j];
bestDist = dist;
}
}
if (entity is Line line)
{
var (first, _) = line.SplitAt(best);
entities.RemoveAt(entities.Count - 1);
if (first != null)
entities.Add(first);
}
else if (entity is Arc arc)
{
var (first, _) = arc.SplitAt(best);
entities.RemoveAt(entities.Count - 1);
if (first != null)
entities.Add(first);
}
break;
}
// No intersection — entity is entirely inside circle, remove it
if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon)
{
entities.RemoveAt(entities.Count - 1);
continue;
}
break;
}
var result = new Shape();
result.Entities.AddRange(entities);
return result;
}
private static Vector EntityStartPoint(Entity entity)
{
if (entity is Line line) return line.StartPoint;
if (entity is Arc arc) return arc.StartPoint();
return Vector.Zero;
}
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint, LayerType layer = LayerType.Display)
{ {
var moves = new List<ICode>(); var moves = new List<ICode>();
@@ -176,15 +578,15 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
if (entity is Line line) if (entity is Line line)
{ {
moves.Add(new LinearMove(line.EndPoint)); moves.Add(new LinearMove(line.EndPoint) { Layer = layer });
} }
else if (entity is Arc arc) else if (entity is Arc arc)
{ {
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW)); moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
} }
else if (entity is Circle circle) else if (entity is Circle circle)
{ {
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation)); moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer });
} }
else else
{ {
@@ -194,5 +596,14 @@ namespace OpenNest.CNC.CuttingStrategy
return moves; return moves;
} }
private static Vector GetShapeStartPoint(Shape shape)
{
var first = shape.Entities[0];
if (first is Line line) return line.StartPoint;
if (first is Arc arc) return arc.StartPoint();
if (first is Circle circle) return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
return Vector.Zero;
}
} }
} }

View File

@@ -21,6 +21,14 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn(); public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut(); public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
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; }
public Tab TabConfig { get; set; } public Tab TabConfig { get; set; }
public bool TabsEnabled { get; set; } public bool TabsEnabled { get; set; }

View File

@@ -19,7 +19,7 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new ArcMove(contourStartPoint, arcCenter, winding) new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
}; };
} }
@@ -32,5 +32,8 @@ namespace OpenNest.CNC.CuttingStrategy
arcCenterX + Radius * System.Math.Cos(contourNormalAngle), arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)); arcCenterY + Radius * System.Math.Sin(contourNormalAngle));
} }
public override LeadIn Scale(double factor) =>
new ArcLeadIn { Radius = Radius * factor };
} }
} }

View File

@@ -27,8 +27,8 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(arcStart), new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
}; };
} }
@@ -45,5 +45,8 @@ namespace OpenNest.CNC.CuttingStrategy
arcStartX + LineLength * System.Math.Cos(lineAngle), arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)); arcStartY + LineLength * System.Math.Sin(lineAngle));
} }
public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, Kerf = Kerf };
} }
} }

View File

@@ -9,5 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
RotationType winding = RotationType.CW); RotationType winding = RotationType.CW);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle); public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
public virtual LeadIn Scale(double factor) => this;
} }
} }

View File

@@ -27,8 +27,8 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(arcStart), new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
}; };
} }
@@ -45,5 +45,8 @@ namespace OpenNest.CNC.CuttingStrategy
arcStartX + LineLength * System.Math.Cos(lineAngle), arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)); arcStartY + LineLength * System.Math.Sin(lineAngle));
} }
public override LeadIn Scale(double factor) =>
new LineArcLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, ApproachAngle = ApproachAngle };
} }
} }

View File

@@ -17,16 +17,19 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(contourStartPoint) new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
}; };
} }
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{ {
var approachAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
return new Vector( return new Vector(
contourStartPoint.X + Length * System.Math.Cos(approachAngle), contourStartPoint.X + Length * System.Math.Cos(approachAngle),
contourStartPoint.Y + Length * System.Math.Sin(approachAngle)); contourStartPoint.Y + Length * System.Math.Sin(approachAngle));
} }
public override LeadIn Scale(double factor) =>
new LineLeadIn { Length = Length * factor, ApproachAngle = ApproachAngle };
} }
} }

View File

@@ -16,7 +16,7 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1); var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midPoint = new Vector( var midPoint = new Vector(
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle), contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)); contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle));
@@ -24,14 +24,14 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(midPoint), new LinearMove(midPoint) { Layer = LayerType.Leadin },
new LinearMove(contourStartPoint) new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
}; };
} }
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{ {
var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1); var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle); var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle); var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle);
@@ -40,5 +40,8 @@ namespace OpenNest.CNC.CuttingStrategy
midX + Length1 * System.Math.Cos(firstAngle), midX + Length1 * System.Math.Cos(firstAngle),
midY + Length1 * System.Math.Sin(firstAngle)); midY + Length1 * System.Math.Sin(firstAngle));
} }
public override LeadIn Scale(double factor) =>
new LineLineLeadIn { Length1 = Length1 * factor, ApproachAngle1 = ApproachAngle1, Length2 = Length2 * factor, ApproachAngle2 = ApproachAngle2 };
} }
} }

View File

@@ -20,7 +20,7 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> return new List<ICode>
{ {
new ArcMove(endPoint, arcCenter, winding) new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout }
}; };
} }
} }

View File

@@ -12,14 +12,14 @@ namespace OpenNest.CNC.CuttingStrategy
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW) RotationType winding = RotationType.CW)
{ {
var overcutAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
var endPoint = new Vector( var endPoint = new Vector(
contourEndPoint.X + Length * System.Math.Cos(overcutAngle), contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)); contourEndPoint.Y + Length * System.Math.Sin(overcutAngle));
return new List<ICode> return new List<ICode>
{ {
new LinearMove(endPoint) new LinearMove(endPoint) { Layer = LayerType.Leadout }
}; };
} }
} }

View File

@@ -1,16 +0,0 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
public class MicrotabLeadOut : LeadOut
{
public double GapSize { get; set; } = 0.03;
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{
return new List<ICode>();
}
}
}

View File

@@ -17,6 +17,8 @@
public double Value { get; set; } public double Value { get; set; }
public string VariableRef { get; set; }
public CodeType Type public CodeType Type
{ {
get { return CodeType.SetFeedrate; } get { return CodeType.SetFeedrate; }
@@ -24,7 +26,7 @@
public ICode Clone() public ICode Clone()
{ {
return new Feedrate(Value); return new Feedrate(Value) { VariableRef = VariableRef };
} }
public override string ToString() public override string ToString()

View File

@@ -1,4 +1,5 @@
using OpenNest.Geometry; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC namespace OpenNest.CNC
{ {
@@ -31,7 +32,9 @@ namespace OpenNest.CNC
{ {
return new LinearMove(EndPoint) return new LinearMove(EndPoint)
{ {
Layer = Layer Layer = Layer,
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
}; };
} }

View File

@@ -1,4 +1,5 @@
using OpenNest.Geometry; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC namespace OpenNest.CNC
{ {
@@ -12,6 +13,10 @@ namespace OpenNest.CNC
public int Feedrate { get; set; } public int Feedrate { get; set; }
public bool Suppressed { get; set; }
public Dictionary<string, string> VariableRefs { get; set; }
protected Motion() protected Motion()
{ {
Feedrate = CNC.Feedrate.UseDefault; Feedrate = CNC.Feedrate.UseDefault;
@@ -20,21 +25,25 @@ namespace OpenNest.CNC
public virtual void Rotate(double angle) public virtual void Rotate(double angle)
{ {
EndPoint = EndPoint.Rotate(angle); EndPoint = EndPoint.Rotate(angle);
VariableRefs = null;
} }
public virtual void Rotate(double angle, Vector origin) public virtual void Rotate(double angle, Vector origin)
{ {
EndPoint = EndPoint.Rotate(angle, origin); EndPoint = EndPoint.Rotate(angle, origin);
VariableRefs = null;
} }
public virtual void Offset(double x, double y) public virtual void Offset(double x, double y)
{ {
EndPoint = new Vector(EndPoint.X + x, EndPoint.Y + y); EndPoint = new Vector(EndPoint.X + x, EndPoint.Y + y);
VariableRefs = null;
} }
public virtual void Offset(Vector voffset) public virtual void Offset(Vector voffset)
{ {
EndPoint += voffset; EndPoint += voffset;
VariableRefs = null;
} }
public abstract CodeType Type { get; } public abstract CodeType Type { get; }

View File

@@ -1,6 +1,7 @@
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace OpenNest.CNC namespace OpenNest.CNC
@@ -9,6 +10,10 @@ namespace OpenNest.CNC
{ {
public List<ICode> Codes; public List<ICode> Codes;
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<int, Program> SubPrograms { get; } = new();
private Mode mode; private Mode mode;
public Program(Mode mode = Mode.Absolute) public Program(Mode mode = Mode.Absolute)
@@ -51,37 +56,7 @@ namespace OpenNest.CNC
mode = Mode.Absolute; mode = Mode.Absolute;
} }
public virtual void Rotate(double angle) public virtual void Rotate(double angle) => Rotate(angle, new Vector(0, 0));
{
var mode = Mode;
SetModeAbs();
for (int i = 0; i < Codes.Count; ++i)
{
var code = Codes[i];
if (code.Type == CodeType.SubProgramCall)
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program != null)
subpgm.Program.Rotate(angle);
}
if (code is Motion == false)
continue;
var code2 = (Motion)code;
code2.Rotate(angle);
}
if (mode == Mode.Incremental)
SetModeInc();
Rotation = Angle.NormalizeRad(Rotation + angle);
}
public override string ToString() public override string ToString()
{ {
@@ -114,6 +89,17 @@ namespace OpenNest.CNC
{ {
var subpgm = (SubProgramCall)code; 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) if (subpgm.Program != null)
subpgm.Program.Rotate(angle, origin); subpgm.Program.Rotate(angle, origin);
} }
@@ -142,6 +128,12 @@ namespace OpenNest.CNC
{ {
var code = Codes[i]; 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) if (code is Motion == false)
continue; continue;
@@ -164,6 +156,12 @@ namespace OpenNest.CNC
{ {
var code = Codes[i]; 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) if (code is Motion == false)
continue; continue;
@@ -302,6 +300,10 @@ namespace OpenNest.CNC
private Box BoundingBox(ref Vector pos) 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 minX = 0.0;
double minY = 0.0; double minY = 0.0;
double maxX = 0.0; double maxX = 0.0;
@@ -317,7 +319,7 @@ namespace OpenNest.CNC
{ {
var line = (LinearMove)code; var line = (LinearMove)code;
var pt = Mode == Mode.Absolute ? var pt = Mode == Mode.Absolute ?
line.EndPoint : frameOrigin + line.EndPoint :
line.EndPoint + pos; line.EndPoint + pos;
if (pt.X > maxX) if (pt.X > maxX)
@@ -339,7 +341,7 @@ namespace OpenNest.CNC
{ {
var line = (RapidMove)code; var line = (RapidMove)code;
var pt = Mode == Mode.Absolute var pt = Mode == Mode.Absolute
? line.EndPoint ? frameOrigin + line.EndPoint
: line.EndPoint + pos; : line.EndPoint + pos;
if (pt.X > maxX) if (pt.X > maxX)
@@ -372,8 +374,8 @@ namespace OpenNest.CNC
} }
else else
{ {
endpt = arc.EndPoint; endpt = frameOrigin + arc.EndPoint;
centerpt = arc.CenterPoint; centerpt = frameOrigin + arc.CenterPoint;
} }
double minX1; double minX1;
@@ -447,6 +449,12 @@ namespace OpenNest.CNC
case CodeType.SubProgramCall: case CodeType.SubProgramCall:
{ {
var subpgm = (SubProgramCall)code; 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); var box = subpgm.Program.BoundingBox(ref pos);
if (box.Left < minX) if (box.Left < minX)
@@ -484,6 +492,12 @@ namespace OpenNest.CNC
pgm.Codes.AddRange(codes); pgm.Codes.AddRange(codes);
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; return pgm;
} }

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
using OpenNest.Geometry; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC namespace OpenNest.CNC
{ {
@@ -26,7 +27,11 @@ namespace OpenNest.CNC
public override ICode Clone() public override ICode Clone()
{ {
return new RapidMove(EndPoint); return new RapidMove(EndPoint)
{
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
};
} }
public override string ToString() public override string ToString()

View File

@@ -1,4 +1,6 @@
using OpenNest.Math; using System.Text;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC 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> /// <summary>
/// Gets or sets the rotation of the program in degrees. /// Gets or sets the rotation of the program in degrees.
/// </summary> /// </summary>
@@ -78,12 +86,18 @@ namespace OpenNest.CNC
/// <returns></returns> /// <returns></returns>
public ICode Clone() public ICode Clone()
{ {
return new SubProgramCall(program, Rotation); return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
} }
public override string ToString() 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();
} }
} }
} }

View File

@@ -0,0 +1,21 @@
namespace OpenNest.CNC
{
public sealed class VariableDefinition
{
public string Name { get; }
public string Expression { get; }
public double Value { get; }
public bool Inline { get; }
public bool Global { get; }
public VariableDefinition(string name, string expression, double value,
bool inline = false, bool global = false)
{
Name = name;
Expression = expression;
Value = value;
Inline = inline;
Global = global;
}
}
}

View File

@@ -46,7 +46,8 @@ namespace OpenNest.Collections
public bool Remove(T item) public bool Remove(T item)
{ {
var success = items.Remove(item); var success = items.Remove(item);
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success)); if (success)
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
return success; return success;
} }

View File

@@ -0,0 +1,137 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Converters
{
public enum ContourClassification
{
Perimeter,
Hole,
Etch,
Open
}
public sealed class ContourInfo
{
public Shape Shape { get; }
public ContourClassification Type { get; private set; }
public string Label { get; private set; }
private ContourInfo(Shape shape, ContourClassification type, string label)
{
Shape = shape;
Type = type;
Label = label;
}
public string DirectionLabel
{
get
{
if (Type == ContourClassification.Open || Type == ContourClassification.Etch)
return "Open";
var poly = Shape.ToPolygon();
if (poly == null || poly.Vertices.Count < 3)
return "?";
return poly.RotationDirection() == RotationType.CW ? "CW" : "CCW";
}
}
public string DimensionLabel
{
get
{
if (Shape.Entities.Count == 1 && Shape.Entities[0] is Circle c)
return $"Circle R{c.Radius:0.#}";
Shape.UpdateBounds();
var box = Shape.BoundingBox;
return $"{box.Width:0.#} x {box.Length:0.#}";
}
}
public void Reverse()
{
Shape.Reverse();
}
public void SetLabel(string label)
{
Label = label;
}
public static List<ContourInfo> Classify(List<Shape> shapes)
{
if (shapes.Count == 0)
return new List<ContourInfo>();
// Ensure bounding boxes are up to date before comparing
foreach (var s in shapes)
s.UpdateBounds();
// Find perimeter — largest bounding box area
var perimeterIndex = 0;
var maxArea = shapes[0].BoundingBox.Area();
for (var i = 1; i < shapes.Count; i++)
{
var area = shapes[i].BoundingBox.Area();
if (area > maxArea)
{
maxArea = area;
perimeterIndex = i;
}
}
var result = new List<ContourInfo>();
var holeCount = 0;
var etchCount = 0;
var openCount = 0;
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
for (var i = 0; i < shapes.Count; i++)
{
if (i == perimeterIndex) continue;
var shape = shapes[i];
var type = ClassifyShape(shape);
string label;
switch (type)
{
case ContourClassification.Hole:
holeCount++;
label = $"Hole {holeCount}";
break;
case ContourClassification.Etch:
etchCount++;
label = etchCount == 1 ? "Etch" : $"Etch {etchCount}";
break;
default:
openCount++;
label = openCount == 1 ? "Open" : $"Open {openCount}";
break;
}
result.Add(new ContourInfo(shape, type, label));
}
// Perimeter last
result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter"));
return result;
}
private static ContourClassification ClassifyShape(Shape shape)
{
// Check etch layer — all entities must be on ETCH layer
if (shape.Entities.Count > 0 &&
shape.Entities.All(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)))
return ContourClassification.Etch;
if (shape.IsClosed())
return ContourClassification.Hole;
return ContourClassification.Open;
}
}
}

View File

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

View File

@@ -1,4 +1,4 @@
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Converters namespace OpenNest.Converters
@@ -9,7 +9,6 @@ namespace OpenNest.Converters
/// Converts the program to absolute coordinates. /// Converts the program to absolute coordinates.
/// Does NOT check program mode before converting. /// Does NOT check program mode before converting.
/// </summary> /// </summary>
/// <param name="pgm"></param>
public static void ToAbsolute(Program pgm) public static void ToAbsolute(Program pgm)
{ {
var pos = new Vector(0, 0); var pos = new Vector(0, 0);
@@ -17,21 +16,27 @@ namespace OpenNest.Converters
for (int i = 0; i < pgm.Codes.Count; ++i) for (int i = 0; i < pgm.Codes.Count; ++i)
{ {
var code = pgm.Codes[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; pos = motion.EndPoint;
} }
} }
} }
/// <summary> /// <summary>
/// Converts the program to intermental coordinates. /// Converts the program to incremental coordinates.
/// Does NOT check program mode before converting. /// Does NOT check program mode before converting.
/// </summary> /// </summary>
/// <param name="pgm"></param>
public static void ToIncremental(Program pgm) public static void ToIncremental(Program pgm)
{ {
var pos = new Vector(0, 0); var pos = new Vector(0, 0);
@@ -39,9 +44,16 @@ namespace OpenNest.Converters
for (int i = 0; i < pgm.Codes.Count; ++i) for (int i = 0; i < pgm.Codes.Count; ++i)
{ {
var code = pgm.Codes[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; var pos2 = motion.EndPoint;
motion.Offset(-pos.X, -pos.Y); 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;
}
} }
} }

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) 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; mode = program.Mode;
for (int i = 0; i < program.Length; ++i) for (int i = 0; i < program.Length; ++i)
@@ -41,12 +44,15 @@ namespace OpenNest.Converters
break; break;
case CodeType.SubProgramCall: case CodeType.SubProgramCall:
var tmpmode = mode;
var subpgm = (SubProgramCall)code; var subpgm = (SubProgramCall)code;
var geoProgram = new Shape(); var savedMode = mode;
AddProgram(subpgm.Program, ref mode, ref curpos, ref geoProgram.Entities);
geometry.Add(geoProgram); // The sub-program's frame origin in this program's frame is
mode = tmpmode; // 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; break;
} }
} }
@@ -59,9 +65,11 @@ namespace OpenNest.Converters
if (mode == Mode.Incremental) if (mode == Mode.Incremental)
pt += curpos; pt += curpos;
var layer = ConvertLayer(linearMove.Layer);
var line = new Line(curpos, pt) var line = new Line(curpos, pt)
{ {
Layer = ConvertLayer(linearMove.Layer) Layer = layer,
Color = layer.Color
}; };
geometry.Add(line); geometry.Add(line);
curpos = pt; curpos = pt;
@@ -76,7 +84,8 @@ namespace OpenNest.Converters
var line = new Line(curpos, pt) var line = new Line(curpos, pt)
{ {
Layer = SpecialLayers.Rapid Layer = SpecialLayers.Rapid,
Color = SpecialLayers.Rapid.Color
}; };
geometry.Add(line); geometry.Add(line);
curpos = pt; curpos = pt;
@@ -103,9 +112,9 @@ namespace OpenNest.Converters
var layer = ConvertLayer(arcMove.Layer); var layer = ConvertLayer(arcMove.Layer);
if (startAngle.IsEqualTo(endAngle)) if (startAngle.IsEqualTo(endAngle))
geometry.Add(new Circle(center, radius) { Layer = layer }); geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
else else
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer }); geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
curpos = endpt; curpos = endpt;
} }

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

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

View File

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

View File

@@ -1,6 +1,9 @@
using OpenNest.CNC; using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -10,8 +13,32 @@ namespace OpenNest
public class Drawing public class Drawing
{ {
private static int nextId; private static int nextId;
private static int nextColorIndex;
private Program program; private Program program;
public static Color[] PartColors = new Color[]
{
Color.FromArgb(205, 92, 92), // Indian Red
Color.FromArgb(148, 103, 189), // Medium Purple
Color.FromArgb(75, 180, 175), // Teal
Color.FromArgb(210, 190, 75), // Goldenrod
Color.FromArgb(190, 85, 175), // Orchid
Color.FromArgb(185, 115, 85), // Sienna
Color.FromArgb(120, 100, 190), // Slate Blue
Color.FromArgb(200, 100, 140), // Rose
Color.FromArgb(80, 175, 155), // Sea Green
Color.FromArgb(195, 160, 85), // Dark Khaki
Color.FromArgb(175, 95, 160), // Plum
Color.FromArgb(215, 130, 130), // Light Coral
};
public static Color GetNextColor()
{
var color = PartColors[nextColorIndex % PartColors.Length];
nextColorIndex++;
return color;
}
public Drawing() public Drawing()
: this(string.Empty, new Program()) : this(string.Empty, new Program())
{ {
@@ -56,10 +83,26 @@ namespace OpenNest
public Color Color { get; set; } public Color Color { get; set; }
public bool IsCutOff { get; set; }
public NestConstraints Constraints { get; set; } public NestConstraints Constraints { get; set; }
public SourceInfo Source { get; set; } public SourceInfo Source { get; set; }
public List<Bend> Bends { get; set; } = new List<Bend>();
/// <summary>
/// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file.
/// </summary>
public List<Entity> SourceEntities { get; set; }
/// <summary>
/// IDs of entities in <see cref="SourceEntities"/> that are suppressed (hidden).
/// Suppressed entities are excluded from the active Program but preserved for re-enabling.
/// </summary>
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
public double Area { get; protected set; } public double Area { get; protected set; }
public void UpdateArea() public void UpdateArea()

View File

@@ -155,6 +155,17 @@ namespace OpenNest.Geometry
Center.Y + Radius * System.Math.Sin(EndAngle)); Center.Y + Radius * System.Math.Sin(EndAngle));
} }
/// <summary>
/// Mid point of the arc (point at the angle midway between start and end).
/// </summary>
public Vector MidPoint()
{
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
return new Vector(
Center.X + Radius * System.Math.Cos(midAngle),
Center.Y + Radius * System.Math.Sin(midAngle));
}
/// <summary> /// <summary>
/// Splits the arc at the given point, returning two sub-arcs. /// Splits the arc at the given point, returning two sub-arcs.
/// Either half may be null if the split point coincides with an endpoint. /// Either half may be null if the split point coincides with an endpoint.
@@ -256,6 +267,13 @@ namespace OpenNest.Geometry
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; } 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> /// <summary>
/// Reverses the rotation direction. /// Reverses the rotation direction.
/// </summary> /// </summary>
@@ -409,8 +427,8 @@ namespace OpenNest.Geometry
boundingBox.X = minX; boundingBox.X = minX;
boundingBox.Y = minY; boundingBox.Y = minY;
boundingBox.Width = maxX - minX; boundingBox.Length = maxX - minX;
boundingBox.Length = maxY - minY; boundingBox.Width = maxY - minY;
} }
public override Entity OffsetEntity(double distance, OffsetSide side) public override Entity OffsetEntity(double distance, OffsetSide side)

View File

@@ -0,0 +1,130 @@
using System.Collections.Generic;
namespace OpenNest.Geometry
{
/// <summary>
/// Shared arc-fitting utilities used by SplineConverter and GeometrySimplifier.
/// </summary>
internal static class ArcFit
{
/// <summary>
/// Fits a circular arc constrained to be tangent to the given direction at the
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
/// the arc passes through both endpoints and departs P1 in the given direction.
/// </summary>
internal static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
var mx = (p1.X + pn.X) / 2;
var my = (p1.Y + pn.Y) / 2;
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var bx = -dy / chordLen;
var by = dx / chordLen;
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
if (tLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var nx = -tangent.Y / tLen;
var ny = tangent.X / tLen;
var det = nx * by - ny * bx;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var s = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
var cx = p1.X + s * nx;
var cy = p1.Y + s * ny;
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (radius < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
/// <summary>
/// Fits a circular arc constrained to be tangent to the given directions at both
/// the first and last points. The center lies at the intersection of the normals
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
/// deviation includes any endpoint gap at Pn.
/// </summary>
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
List<Vector> points, Vector startTangent, Vector endTangent)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
if (stLen < 1e-10 || etLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
// Normal to start tangent at P1 (perpendicular)
var n1x = -startTangent.Y / stLen;
var n1y = startTangent.X / stLen;
// Normal to end tangent at Pn
var n2x = -endTangent.Y / etLen;
var n2y = endTangent.X / etLen;
// Solve: P1 + t1*N1 = Pn + t2*N2
var det = n1x * (-n2y) - (-n2x) * n1y;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
var cx = p1.X + t1 * n1x;
var cy = p1.Y + t1 * n1y;
// Use radius from P1 (guarantees exact start tangent and passes through P1)
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (r1 < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
// Measure endpoint gap at Pn
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
var endpointDev = System.Math.Abs(r2 - r1);
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
}
/// <summary>
/// Computes the maximum radial deviation of interior points from a circle.
/// </summary>
internal static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
{
var maxDev = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
var px = points[i].X - cx;
var py = points[i].Y - cy;
var dist = System.Math.Sqrt(px * px + py * py);
var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev;
}
return maxDev;
}
}
}

View File

@@ -12,8 +12,8 @@ namespace OpenNest.Geometry
double minX = boxes[0].X; double minX = boxes[0].X;
double minY = boxes[0].Y; double minY = boxes[0].Y;
double maxX = boxes[0].X + boxes[0].Width; double maxX = boxes[0].Right;
double maxY = boxes[0].Y + boxes[0].Length; double maxY = boxes[0].Top;
foreach (var box in boxes) foreach (var box in boxes)
{ {

View File

@@ -14,15 +14,15 @@ namespace OpenNest.Geometry
public Box(double x, double y, double w, double h) public Box(double x, double y, double w, double h)
{ {
Location = new Vector(x, y); Location = new Vector(x, y);
Width = w; Length = w;
Length = h; Width = h;
} }
public Vector Location; public Vector Location;
public Vector Center public Vector Center
{ {
get { return new Vector(X + Width * 0.5, Y + Length * 0.5); } get { return new Vector(X + Length * 0.5, Y + Width * 0.5); }
} }
public Size Size; public Size Size;
@@ -76,12 +76,12 @@ namespace OpenNest.Geometry
public Box Translate(double x, double y) public Box Translate(double x, double y)
{ {
return new Box(X + x, Y + y, Width, Length); return new Box(X + x, Y + y, Length, Width);
} }
public Box Translate(Vector offset) public Box Translate(Vector offset)
{ {
return new Box(X + offset.X, Y + offset.Y, Width, Length); return new Box(X + offset.X, Y + offset.Y, Length, Width);
} }
public double Left public double Left
@@ -91,12 +91,12 @@ namespace OpenNest.Geometry
public double Right public double Right
{ {
get { return X + Width; } get { return X + Length; }
} }
public double Top public double Top
{ {
get { return Y + Length; } get { return Y + Width; }
} }
public double Bottom public double Bottom
@@ -207,7 +207,7 @@ namespace OpenNest.Geometry
public Box Offset(double d) public Box Offset(double d)
{ {
return new Box(X - d, Y - d, Width + d * 2, Length + d * 2); return new Box(X - d, Y - d, Length + d * 2, Width + d * 2);
} }
public override string ToString() public override string ToString()

View File

@@ -9,7 +9,7 @@
var x = large.Left; var x = large.Left;
var y = small.Top; var y = small.Top;
var w = large.Width; var w = large.Length;
var h = large.Top - y; var h = large.Top - y;
return new Box(x, y, w, h); return new Box(x, y, w, h);
@@ -23,7 +23,7 @@
var x = large.Left; var x = large.Left;
var y = large.Bottom; var y = large.Bottom;
var w = small.Left - x; var w = small.Left - x;
var h = large.Length; var h = large.Width;
return new Box(x, y, w, h); return new Box(x, y, w, h);
} }
@@ -35,7 +35,7 @@
var x = large.Left; var x = large.Left;
var y = large.Bottom; var y = large.Bottom;
var w = large.Width; var w = large.Length;
var h = small.Top - y; var h = small.Top - y;
return new Box(x, y, w, h); return new Box(x, y, w, h);
@@ -49,7 +49,7 @@
var x = small.Right; var x = small.Right;
var y = large.Bottom; var y = large.Bottom;
var w = large.Right - x; var w = large.Right - x;
var h = large.Length; var h = large.Width;
return new Box(x, y, w, h); return new Box(x, y, w, h);
} }

View File

@@ -137,7 +137,9 @@ namespace OpenNest.Geometry
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false) public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
{ {
var points = new List<Vector>(); var points = new List<Vector>();
var stepAngle = Angle.TwoPI / segments; var stepAngle = Rotation == RotationType.CW
? -Angle.TwoPI / segments
: Angle.TwoPI / segments;
var r = circumscribe && segments > 0 var r = circumscribe && segments > 0
? Radius / System.Math.Cos(stepAngle / 2.0) ? Radius / System.Math.Cos(stepAngle / 2.0)
@@ -163,6 +165,13 @@ namespace OpenNest.Geometry
get { return Circumference(); } get { return Circumference(); }
} }
public override Entity Clone()
{
var copy = new Circle(center, radius) { Rotation = Rotation };
CopyBaseTo(copy);
return copy;
}
/// <summary> /// <summary>
/// Reverses the rotation direction. /// Reverses the rotation direction.
/// </summary> /// </summary>

View File

@@ -0,0 +1,330 @@
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Geometry
{
public static class Collision
{
public static CollisionResult Check(Polygon a, Polygon b,
List<Polygon> holesA = null, List<Polygon> holesB = null)
{
// Step 1: Bounding box pre-filter
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
return CollisionResult.None;
// Step 2: Quick intersection test for crossing points
var intersectionPoints = FindCrossingPoints(a, b);
// Step 3: Convex decomposition
var trisA = TriangulateWithBounds(a);
var trisB = TriangulateWithBounds(b);
// Step 4: Clip all triangle pairs
var regions = new List<Polygon>();
foreach (var triA in trisA)
{
foreach (var triB in trisB)
{
if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox))
continue;
var clipped = ClipConvex(triA, triB);
if (clipped != null)
regions.Add(clipped);
}
}
// Step 5: Hole subtraction
if (regions.Count > 0)
regions = SubtractHoles(regions, holesA, holesB);
if (regions.Count == 0)
return new CollisionResult(false, regions, intersectionPoints);
// Step 6: Build result
return new CollisionResult(true, regions, intersectionPoints);
}
public static bool HasOverlap(Polygon a, Polygon b,
List<Polygon> holesA = null, List<Polygon> holesB = null)
{
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
return false;
// Full check is needed: crossing points alone miss containment cases
// (one polygon entirely inside another has zero edge crossings).
return Check(a, b, holesA, holesB).Overlaps;
}
public static List<CollisionResult> CheckAll(List<Polygon> polygons,
List<List<Polygon>> holes = null)
{
var results = new List<CollisionResult>();
for (var i = 0; i < polygons.Count; i++)
{
for (var j = i + 1; j < polygons.Count; j++)
{
var holesA = holes != null && i < holes.Count ? holes[i] : null;
var holesB = holes != null && j < holes.Count ? holes[j] : null;
var result = Check(polygons[i], polygons[j], holesA, holesB);
if (result.Overlaps)
results.Add(result);
}
}
return results;
}
public static bool HasAnyOverlap(List<Polygon> polygons,
List<List<Polygon>> holes = null)
{
for (var i = 0; i < polygons.Count; i++)
{
for (var j = i + 1; j < polygons.Count; j++)
{
var holesA = holes != null && i < holes.Count ? holes[i] : null;
var holesB = holes != null && j < holes.Count ? holes[j] : null;
if (HasOverlap(polygons[i], polygons[j], holesA, holesB))
return true;
}
}
return false;
}
private static bool BoundingBoxesOverlap(Box a, Box b)
{
var overlapX = System.Math.Min(a.Right, b.Right)
- System.Math.Max(a.Left, b.Left);
var overlapY = System.Math.Min(a.Top, b.Top)
- System.Math.Max(a.Bottom, b.Bottom);
return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon;
}
private static List<Vector> FindCrossingPoints(Polygon a, Polygon b)
{
if (!Intersect.Intersects(a, b, out var rawPts))
return new List<Vector>();
// Filter boundary contacts (vertex touches)
var vertsA = CollectVertices(a);
var vertsB = CollectVertices(b);
var filtered = new List<Vector>();
foreach (var pt in rawPts)
{
if (IsNearAnyVertex(pt, vertsA) || IsNearAnyVertex(pt, vertsB))
continue;
filtered.Add(pt);
}
return filtered;
}
private static List<Vector> CollectVertices(Polygon polygon)
{
var verts = new List<Vector>(polygon.Vertices.Count);
foreach (var v in polygon.Vertices)
verts.Add(v);
return verts;
}
private static bool IsNearAnyVertex(Vector pt, List<Vector> vertices)
{
foreach (var v in vertices)
{
if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y))
return true;
}
return false;
}
/// <summary>
/// Triangulates a polygon and ensures each triangle has its bounding box updated.
/// </summary>
private static List<Polygon> TriangulateWithBounds(Polygon polygon)
{
var tris = ConvexDecomposition.Triangulate(polygon);
foreach (var tri in tris)
tri.UpdateBounds();
return tris;
}
/// <summary>
/// Sutherland-Hodgman polygon clipping. Clips subject against each edge
/// of clip. Both must be convex. Returns null if no overlap.
/// </summary>
private static Polygon ClipConvex(Polygon subject, Polygon clip)
{
var output = new List<Vector>(subject.Vertices);
// Remove closing vertex if present
if (output.Count > 1 && output[0].X == output[output.Count - 1].X
&& output[0].Y == output[output.Count - 1].Y)
output.RemoveAt(output.Count - 1);
var clipVerts = new List<Vector>(clip.Vertices);
if (clipVerts.Count > 1 && clipVerts[0].X == clipVerts[clipVerts.Count - 1].X
&& clipVerts[0].Y == clipVerts[clipVerts.Count - 1].Y)
clipVerts.RemoveAt(clipVerts.Count - 1);
for (var i = 0; i < clipVerts.Count; i++)
{
if (output.Count == 0)
return null;
var edgeStart = clipVerts[i];
var edgeEnd = clipVerts[(i + 1) % clipVerts.Count];
var input = output;
output = new List<Vector>();
for (var j = 0; j < input.Count; j++)
{
var current = input[j];
var next = input[(j + 1) % input.Count];
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
if (currentInside)
{
output.Add(current);
if (!nextInside)
{
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
if (ix.IsValid())
output.Add(ix);
}
}
else if (nextInside)
{
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
if (ix.IsValid())
output.Add(ix);
}
}
}
if (output.Count < 3)
return null;
var result = new Polygon();
result.Vertices.AddRange(output);
result.Close();
result.UpdateBounds();
// Reject degenerate slivers
if (result.Area() < Tolerance.Epsilon)
return null;
return result;
}
/// <summary>
/// Cross product of vectors (edgeStart->edgeEnd) and (edgeStart->point).
/// Positive = point is left of edge (inside for CCW polygon).
/// </summary>
private static double Cross(Vector edgeStart, Vector edgeEnd, Vector point)
{
return (edgeEnd.X - edgeStart.X) * (point.Y - edgeStart.Y)
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
}
/// <summary>
/// Intersection of lines (a1->a2) and (b1->b2). Returns Vector.Invalid if parallel.
/// </summary>
private static Vector LineIntersection(Vector a1, Vector a2, Vector b1, Vector b2)
{
var d1x = a2.X - a1.X;
var d1y = a2.Y - a1.Y;
var d2x = b2.X - b1.X;
var d2y = b2.Y - b1.Y;
var cross = d1x * d2y - d1y * d2x;
if (System.Math.Abs(cross) < Tolerance.Epsilon)
return Vector.Invalid;
var t = ((b1.X - a1.X) * d2y - (b1.Y - a1.Y) * d2x) / cross;
return new Vector(a1.X + t * d1x, a1.Y + t * d1y);
}
/// <summary>
/// Subtracts holes from overlap regions.
/// </summary>
private static List<Polygon> SubtractHoles(List<Polygon> regions,
List<Polygon> holesA, List<Polygon> holesB)
{
var allHoles = new List<Polygon>();
if (holesA != null) allHoles.AddRange(holesA);
if (holesB != null) allHoles.AddRange(holesB);
if (allHoles.Count == 0)
return regions;
foreach (var hole in allHoles)
{
var holeTris = TriangulateWithBounds(hole);
var surviving = new List<Polygon>();
foreach (var region in regions)
{
var pieces = SubtractTriangles(region, holeTris);
surviving.AddRange(pieces);
}
regions = surviving;
if (regions.Count == 0)
break;
}
return regions;
}
/// <summary>
/// Subtracts hole triangles from a region. Conservative: partial overlaps
/// keep the full piece triangle (acceptable for visual shading).
/// </summary>
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
{
var current = new List<Polygon> { region };
foreach (var holeTri in holeTris)
{
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
continue;
var next = new List<Polygon>();
foreach (var piece in current)
{
var pieceTris = TriangulateWithBounds(piece);
foreach (var pieceTri in pieceTris)
{
var inside = ClipConvex(pieceTri, holeTri);
if (inside == null)
{
// No overlap with hole - keep
next.Add(pieceTri);
}
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
{
// Partial overlap - keep the piece (conservative)
next.Add(pieceTri);
}
// else: fully inside hole - discard
}
}
current = next;
}
return current;
}
}
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Geometry
{
public class CollisionResult
{
public static readonly CollisionResult None = new(false, new List<Polygon>(), new List<Vector>());
public CollisionResult(bool overlaps, List<Polygon> overlapRegions, List<Vector> intersectionPoints)
{
Overlaps = overlaps;
OverlapRegions = overlapRegions;
IntersectionPoints = intersectionPoints;
OverlapArea = overlapRegions.Sum(r => r.Area());
}
public bool Overlaps { get; }
public IReadOnlyList<Polygon> OverlapRegions { get; }
public IReadOnlyList<Vector> IntersectionPoints { get; }
public double OverlapArea { get; }
}
}

View File

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

View File

@@ -1,4 +1,5 @@
using OpenNest.Math; using OpenNest.Math;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
@@ -10,10 +11,16 @@ namespace OpenNest.Geometry
protected Entity() protected Entity()
{ {
Id = Guid.NewGuid();
Layer = OpenNest.Geometry.Layer.Default; Layer = OpenNest.Geometry.Layer.Default;
boundingBox = new Box(); boundingBox = new Box();
} }
/// <summary>
/// Unique identifier for this entity, stable across edit sessions.
/// </summary>
public Guid Id { get; set; }
/// <summary> /// <summary>
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color). /// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
/// </summary> /// </summary>
@@ -29,6 +36,11 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
public bool IsVisible { get; set; } = true; public bool IsVisible { get; set; } = true;
/// <summary>
/// Optional tag for identifying generated entities (e.g. bend etch marks).
/// </summary>
public string Tag { get; set; }
/// <summary> /// <summary>
/// Smallest box that contains the entity. /// Smallest box that contains the entity.
/// </summary> /// </summary>
@@ -239,6 +251,23 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public abstract bool Intersects(Shape shape, out List<Vector> pts); 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> /// <summary>
/// Type of entity. /// Type of entity.
/// </summary> /// </summary>
@@ -247,7 +276,15 @@ namespace OpenNest.Geometry
public static class EntityExtensions public static class EntityExtensions
{ {
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI) 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>(); var points = new List<Vector>();
@@ -286,17 +323,35 @@ namespace OpenNest.Geometry
case EntityType.Shape: case EntityType.Shape:
var shape = (Shape)entity; var shape = (Shape)entity;
var subResult = shape.Entities.FindBestRotation(startAngle, endAngle); points.AddRange(shape.Entities.CollectPoints());
return subResult; break;
} }
} }
return points;
}
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI)
{
// Check for Shape entity first (recursive case returns early)
foreach (var entity in entities)
{
if (entity.Type == EntityType.Shape)
{
var shape = (Shape)entity;
var subResult = shape.Entities.FindBestRotation(startAngle, endAngle);
return subResult;
}
}
var points = entities.CollectPoints();
if (points.Count == 0) if (points.Count == 0)
return new BoundingRectangleResult(startAngle, 0, 0); return new BoundingRectangleResult(startAngle, 0, 0);
var hull = ConvexHull.Compute(points); var hull = ConvexHull.Compute(points);
bool constrained = !startAngle.IsEqualTo(0) || !endAngle.IsEqualTo(Angle.TwoPI); var constrained = !startAngle.IsEqualTo(0) || !endAngle.IsEqualTo(Angle.TwoPI);
return constrained return constrained
? RotatingCalipers.MinimumBoundingRectangle(hull, startAngle, endAngle) ? RotatingCalipers.MinimumBoundingRectangle(hull, startAngle, endAngle)

View File

@@ -7,65 +7,46 @@ namespace OpenNest.Geometry
{ {
public static class GeometryOptimizer public static class GeometryOptimizer
{ {
public static void Optimize(IList<Arc> arcs) public static void Optimize(IList<Arc> arcs) =>
MergePass(arcs,
(list, item, i) => list.GetCoradialArs(item, i),
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined));
public static void Optimize(IList<Line> lines) =>
MergePass(lines,
(list, item, i) => list.GetCollinearLines(item, i),
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
private delegate bool TryJoin<T>(T a, T b, out T joined);
private static void MergePass<T>(IList<T> items,
Func<IList<T>, T, int, List<T>> findCandidates,
TryJoin<T> tryJoin) where T : class
{ {
for (int i = 0; i < arcs.Count; ++i) for (var i = 0; i < items.Count; ++i)
{ {
var arc = arcs[i]; var item = items[i];
var candidates = findCandidates(items, item, i);
var coradialArcs = arcs.GetCoradialArs(arc, i);
int index = 0;
while (index < coradialArcs.Count)
{
Arc arc2 = coradialArcs[index];
Arc joinArc;
if (!TryJoinArcs(arc, arc2, out joinArc))
{
index++;
continue;
}
coradialArcs.Remove(arc2);
arcs.Remove(arc2);
arc = joinArc;
index = 0;
}
arcs[i] = arc;
}
}
public static void Optimize(IList<Line> lines)
{
for (int i = 0; i < lines.Count; ++i)
{
var line = lines[i];
var collinearLines = lines.GetCollinearLines(line, i);
var index = 0; var index = 0;
while (index < collinearLines.Count) while (index < candidates.Count)
{ {
Line line2 = collinearLines[index]; var candidate = candidates[index];
Line joinLine;
if (!TryJoinLines(line, line2, out joinLine)) if (!tryJoin(item, candidate, out var joined))
{ {
index++; index++;
continue; continue;
} }
collinearLines.Remove(line2); candidates.Remove(candidate);
lines.Remove(line2); items.Remove(candidate);
line = joinLine; item = joined;
index = 0; index = 0;
} }
lines[i] = line; items[i] = item;
} }
} }
@@ -76,6 +57,9 @@ namespace OpenNest.Geometry
if (line1 == line2) if (line1 == line2)
return false; return false;
if (line1.Layer?.Name != line2.Layer?.Name)
return false;
if (!line1.IsCollinearTo(line2)) if (!line1.IsCollinearTo(line2))
return false; return false;
@@ -113,9 +97,9 @@ namespace OpenNest.Geometry
var b = b1 < b2 ? b1 : b2; var b = b1 < b2 ? b1 : b2;
if (!line1.IsVertical() && line1.Slope() < 0) if (!line1.IsVertical() && line1.Slope() < 0)
lineOut = new Line(new Vector(l, t), new Vector(r, b)); lineOut = new Line(new Vector(l, t), new Vector(r, b)) { Layer = line1.Layer, Color = line1.Color };
else else
lineOut = new Line(new Vector(l, b), new Vector(r, t)); lineOut = new Line(new Vector(l, b), new Vector(r, t)) { Layer = line1.Layer, Color = line1.Color };
return true; return true;
} }
@@ -127,28 +111,44 @@ namespace OpenNest.Geometry
if (arc1 == arc2) if (arc1 == arc2)
return false; return false;
if (arc1.Layer?.Name != arc2.Layer?.Name)
return false;
if (arc1.Center != arc2.Center) if (arc1.Center != arc2.Center)
return false; return false;
if (!arc1.Radius.IsEqualTo(arc2.Radius)) if (!arc1.Radius.IsEqualTo(arc2.Radius))
return false; return false;
if (arc1.StartAngle > arc1.EndAngle) var start1 = arc1.StartAngle;
arc1.StartAngle -= Angle.TwoPI; var end1 = arc1.EndAngle;
var start2 = arc2.StartAngle;
var end2 = arc2.EndAngle;
if (arc2.StartAngle > arc2.EndAngle) if (start1 > end1)
arc2.StartAngle -= Angle.TwoPI; start1 -= Angle.TwoPI;
if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle) if (start2 > end2)
start2 -= Angle.TwoPI;
// Check that arcs are adjacent (endpoints touch), not overlapping
var touch1 = end1.IsEqualTo(start2) || (end1 + Angle.TwoPI).IsEqualTo(start2);
var touch2 = end2.IsEqualTo(start1) || (end2 + Angle.TwoPI).IsEqualTo(start1);
if (!touch1 && !touch2)
return false; return false;
var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle; var startAngle = start1 < start2 ? start1 : start2;
var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle; var endAngle = end1 > end2 ? end1 : end2;
// Don't merge if the result would be a full circle (start == end)
var sweep = endAngle - startAngle;
if (sweep >= Angle.TwoPI - Tolerance.Epsilon)
return false;
if (startAngle < 0) startAngle += Angle.TwoPI; if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI; if (endAngle < 0) endAngle += Angle.TwoPI;
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle); arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle) { Layer = arc1.Layer, Color = arc1.Color };
return true; return true;
} }

View File

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

View File

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

View File

@@ -257,6 +257,13 @@ namespace OpenNest.Geometry
} }
} }
public override Entity Clone()
{
var copy = new Line(pt1, pt2);
CopyBaseTo(copy);
return copy;
}
/// <summary> /// <summary>
/// Reversed the line. /// Reversed the line.
/// </summary> /// </summary>
@@ -370,23 +377,23 @@ namespace OpenNest.Geometry
if (StartPoint.X < EndPoint.X) if (StartPoint.X < EndPoint.X)
{ {
boundingBox.X = StartPoint.X; boundingBox.X = StartPoint.X;
boundingBox.Width = EndPoint.X - StartPoint.X; boundingBox.Length = EndPoint.X - StartPoint.X;
} }
else else
{ {
boundingBox.X = EndPoint.X; boundingBox.X = EndPoint.X;
boundingBox.Width = StartPoint.X - EndPoint.X; boundingBox.Length = StartPoint.X - EndPoint.X;
} }
if (StartPoint.Y < EndPoint.Y) if (StartPoint.Y < EndPoint.Y)
{ {
boundingBox.Y = StartPoint.Y; boundingBox.Y = StartPoint.Y;
boundingBox.Length = EndPoint.Y - StartPoint.Y; boundingBox.Width = EndPoint.Y - StartPoint.Y;
} }
else else
{ {
boundingBox.Y = EndPoint.Y; boundingBox.Y = EndPoint.Y;
boundingBox.Length = StartPoint.Y - EndPoint.Y; boundingBox.Width = StartPoint.Y - EndPoint.Y;
} }
} }

View File

@@ -168,6 +168,13 @@ namespace OpenNest.Geometry
get { return Perimeter(); } get { return Perimeter(); }
} }
public override Entity Clone()
{
var copy = new Polygon { Vertices = new List<Vector>(Vertices) };
CopyBaseTo(copy);
return copy;
}
/// <summary> /// <summary>
/// Reverses the rotation direction of the polygon. /// Reverses the rotation direction of the polygon.
/// </summary> /// </summary>
@@ -311,18 +318,74 @@ namespace OpenNest.Geometry
boundingBox.X = minX; boundingBox.X = minX;
boundingBox.Y = minY; boundingBox.Y = minY;
boundingBox.Width = maxX - minX; boundingBox.Length = maxX - minX;
boundingBox.Length = maxY - minY; boundingBox.Width = maxY - minY;
} }
public override Entity OffsetEntity(double distance, OffsetSide side) public override Entity OffsetEntity(double distance, OffsetSide side)
{ {
throw new NotImplementedException(); if (Vertices.Count < 3)
return null;
var isClosed = IsClosed();
var count = isClosed ? Vertices.Count - 1 : Vertices.Count;
if (count < 3)
return null;
var ccw = CalculateArea() > 0;
var outward = ccw ? OffsetSide.Left : OffsetSide.Right;
var sign = side == outward ? 1.0 : -1.0;
var d = distance * sign;
var normals = new Vector[count];
for (var i = 0; i < count; i++)
{
var next = (i + 1) % count;
var dx = Vertices[next].X - Vertices[i].X;
var dy = Vertices[next].Y - Vertices[i].Y;
var len = System.Math.Sqrt(dx * dx + dy * dy);
if (len < Tolerance.Epsilon)
return null;
normals[i] = new Vector(-dy / len * d, dx / len * d);
}
var result = new Polygon();
for (var i = 0; i < count; i++)
{
var prev = (i - 1 + count) % count;
var a1 = new Vector(Vertices[prev].X + normals[prev].X, Vertices[prev].Y + normals[prev].Y);
var a2 = new Vector(Vertices[i].X + normals[prev].X, Vertices[i].Y + normals[prev].Y);
var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y);
var b2 = new Vector(Vertices[(i + 1) % count].X + normals[i].X, Vertices[(i + 1) % count].Y + normals[i].Y);
var edgeA = new Line(a1, a2);
var edgeB = new Line(b1, b2);
if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid())
result.Vertices.Add(pt);
else
result.Vertices.Add(new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y));
}
result.Close();
result.RemoveSelfIntersections();
result.UpdateBounds();
return result;
} }
public override Entity OffsetEntity(double distance, Vector pt) public override Entity OffsetEntity(double distance, Vector pt)
{ {
throw new NotImplementedException(); var left = OffsetEntity(distance, OffsetSide.Left);
var right = OffsetEntity(distance, OffsetSide.Right);
if (left == null) return right;
if (right == null) return left;
var distLeft = left.ClosestPointTo(pt).DistanceTo(pt);
var distRight = right.ClosestPointTo(pt).DistanceTo(pt);
return distLeft > distRight ? left : right;
} }
/// <summary> /// <summary>

View File

@@ -349,6 +349,15 @@ namespace OpenNest.Geometry
return polygon; 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> /// <summary>
/// Reverses the rotation direction of the shape. /// Reverses the rotation direction of the shape.
/// </summary> /// </summary>
@@ -532,9 +541,29 @@ namespace OpenNest.Geometry
Line line, Line offsetLine, Line line, Line offsetLine,
double distance, OffsetSide side, Shape offsetShape) double distance, OffsetSide side, Shape offsetShape)
{ {
Vector intersection; // Determine if this is a convex corner using the cross product of
// the original line directions. Convex corners need an arc; concave
// corners use the line intersection (miter join).
var d1 = lastLine.EndPoint - lastLine.StartPoint;
var d2 = line.EndPoint - line.StartPoint;
var cross = d1.X * d2.Y - d1.Y * d2.X;
if (Intersect.Intersects(offsetLine, lastOffsetLine, out intersection)) var isConvex = (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon) ||
(side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
if (isConvex)
{
var arc = new Arc(
line.StartPoint,
distance,
line.StartPoint.AngleTo(lastOffsetLine.EndPoint),
line.StartPoint.AngleTo(offsetLine.StartPoint),
side == OffsetSide.Left
);
offsetShape.Entities.Add(arc);
}
else if (Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out var intersection))
{ {
offsetLine.StartPoint = intersection; offsetLine.StartPoint = intersection;
lastOffsetLine.EndPoint = intersection; lastOffsetLine.EndPoint = intersection;
@@ -558,6 +587,76 @@ namespace OpenNest.Geometry
throw new NotImplementedException(); throw new NotImplementedException();
} }
/// <summary>
/// Offsets the shape outward by the given distance.
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
/// making the method independent of the original contour winding direction.
/// </summary>
public Shape OffsetOutward(double distance)
{
var poly = ToPolygon();
if (poly == null || poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CW)
return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Shape is CCW — reverse to CW so Left offset goes outward.
var copy = new Shape();
for (var i = Entities.Count - 1; i >= 0; i--)
{
switch (Entities[i])
{
case Line l:
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break;
case Arc a:
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break;
case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW });
break;
}
}
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
}
/// <summary>
/// Offsets the shape inward by the given distance.
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
/// making the method independent of the original contour winding direction.
/// </summary>
public Shape OffsetInward(double distance)
{
var poly = ToPolygon();
if (poly == null || poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CCW)
return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Create a reversed copy to avoid mutating shared entity objects.
var copy = new Shape();
for (var i = Entities.Count - 1; i >= 0; i--)
{
switch (Entities[i])
{
case Line l:
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break;
case Arc a:
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break;
case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW });
break;
}
}
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
}
/// <summary> /// <summary>
/// Gets the closest point on the shape to the given point. /// Gets the closest point on the shape to the given point.
/// </summary> /// </summary>

View File

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

View File

@@ -104,6 +104,98 @@ namespace OpenNest.Geometry
return double.MaxValue; return double.MaxValue;
} }
/// <summary>
/// Solves ray-circle intersection, returning the two parametric t values.
/// Returns false if no real intersection exists.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
private static bool SolveRayCircle(
double vx, double vy,
double cx, double cy, double r,
double dirX, double dirY,
out double t1, out double t2)
{
var ox = vx - cx;
var oy = vy - cy;
var a = dirX * dirX + dirY * dirY;
var b = 2.0 * (ox * dirX + oy * dirY);
var c = ox * ox + oy * oy - r * r;
var discriminant = b * b - 4.0 * a * c;
if (discriminant < 0)
{
t1 = t2 = double.MaxValue;
return false;
}
var sqrtD = System.Math.Sqrt(discriminant);
var inv2a = 1.0 / (2.0 * a);
t1 = (-b - sqrtD) * inv2a;
t2 = (-b + sqrtD) * inv2a;
return true;
}
/// <summary>
/// Computes the distance from a point along a direction to an arc.
/// Solves ray-circle intersection, then constrains hits to the arc's
/// angular span. Returns double.MaxValue if no hit.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayArcDistance(
double vx, double vy,
double cx, double cy, double r,
double startAngle, double endAngle, bool reversed,
double dirX, double dirY)
{
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
return double.MaxValue;
var best = double.MaxValue;
if (t1 > -Tolerance.Epsilon)
{
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
vy + t1 * dirY - cy, vx + t1 * dirX - cx));
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t1 > Tolerance.Epsilon ? t1 : 0;
}
if (t2 > -Tolerance.Epsilon && t2 < best)
{
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
vy + t2 * dirY - cy, vx + t2 * dirX - cx));
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t2 > Tolerance.Epsilon ? t2 : 0;
}
return best;
}
/// <summary>
/// Computes the distance from a point along a direction to a full circle.
/// Returns double.MaxValue if no hit.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayCircleDistance(
double vx, double vy,
double cx, double cy, double r,
double dirX, double dirY)
{
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
return double.MaxValue;
if (t1 > Tolerance.Epsilon) return t1;
if (t1 >= -Tolerance.Epsilon) return 0;
if (t2 > Tolerance.Epsilon) return t2;
if (t2 >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
/// <summary> /// <summary>
/// Computes the minimum translation distance along a push direction before /// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines. /// any edge of movingLines contacts any edge of stationaryLines.
@@ -111,57 +203,7 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction) public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
{ {
var minDist = double.MaxValue; return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
// Sort edges for pruning if not already sorted (usually they aren't here)
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite);
if (d < minDist) minDist = d;
}
return minDist;
} }
/// <summary> /// <summary>
@@ -176,21 +218,10 @@ namespace OpenNest.Geometry
var movingOffset = new Vector(movingDx, movingDy); var movingOffset = new Vector(movingDx, movingDy);
// Case 1: Each moving vertex -> each stationary edge // Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>(); var movingVertices = CollectVertices(movingLines, movingOffset);
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1 + movingOffset);
movingVertices.Add(movingLines[i].pt2 + movingOffset);
}
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count]; var stationaryEdges = ToEdgeArray(stationaryLines);
for (int i = 0; i < stationaryLines.Count; i++) SortEdgesForPruning(stationaryEdges, direction);
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var mv in movingVertices) foreach (var mv in movingVertices)
{ {
@@ -200,21 +231,10 @@ namespace OpenNest.Geometry
// Case 2: Each stationary vertex -> each moving edge (opposite direction) // Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction); var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>(); var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var movingEdges = new (Vector start, Vector end)[movingLines.Count]; var movingEdges = ToEdgeArray(movingLines);
for (int i = 0; i < movingLines.Count; i++) SortEdgesForPruning(movingEdges, opposite);
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var sv in stationaryVertices) foreach (var sv in stationaryVertices)
{ {
@@ -253,15 +273,11 @@ namespace OpenNest.Geometry
{ {
var minDist = double.MaxValue; var minDist = double.MaxValue;
// Extract unique vertices from moving edges. SortEdgesForPruning(stationaryEdges, direction);
var movingVertices = new HashSet<Vector>();
for (var i = 0; i < movingEdges.Length; i++)
{
movingVertices.Add(movingEdges[i].start + movingOffset);
movingVertices.Add(movingEdges[i].end + movingOffset);
}
// Case 1: Each moving vertex -> each stationary edge // Case 1: Each moving vertex -> each stationary edge
var movingVertices = CollectVertices(movingEdges, movingOffset);
foreach (var mv in movingVertices) foreach (var mv in movingVertices)
{ {
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction); var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
@@ -270,12 +286,9 @@ namespace OpenNest.Geometry
// Case 2: Each stationary vertex -> each moving edge (opposite direction) // Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction); var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>(); SortEdgesForPruning(movingEdges, opposite);
for (var i = 0; i < stationaryEdges.Length; i++)
{ var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
}
foreach (var sv in stationaryVertices) foreach (var sv in stationaryVertices)
{ {
@@ -293,49 +306,38 @@ namespace OpenNest.Geometry
var minDist = double.MaxValue; var minDist = double.MaxValue;
var vx = vertex.X; var vx = vertex.X;
var vy = vertex.Y; var vy = vertex.Y;
var horizontal = IsHorizontalDirection(direction);
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary. // Pruning: edges are sorted by their perpendicular min-coordinate.
if (direction == PushDirection.Left || direction == PushDirection.Right) // 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; perpValue = vy;
var e2 = edges[i].end + edgeOffset; edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
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;
} }
} else
else // Up/Down
{
for (var i = 0; i < edges.Length; i++)
{ {
var e1 = edges[i].start + edgeOffset; perpValue = vx;
var e2 = edges[i].end + edgeOffset; edgeMin = e1.X < e2.X ? e1.X : e2.X;
edgeMax = e1.X > e2.X ? e1.X : e2.X;
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;
} }
// 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; return minDist;
@@ -467,12 +469,7 @@ namespace OpenNest.Geometry
var dirX = direction.X; var dirX = direction.X;
var dirY = direction.Y; var dirY = direction.Y;
var movingVertices = new HashSet<Vector>(); var movingVertices = CollectVertices(movingLines, Vector.Zero);
for (var i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
foreach (var mv in movingVertices) foreach (var mv in movingVertices)
{ {
@@ -487,12 +484,7 @@ namespace OpenNest.Geometry
var oppX = -dirX; var oppX = -dirX;
var oppY = -dirY; var oppY = -dirY;
var stationaryVertices = new HashSet<Vector>(); var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
for (var i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
foreach (var sv in stationaryVertices) foreach (var sv in stationaryVertices)
{ {
@@ -507,6 +499,311 @@ namespace OpenNest.Geometry
return minDist; return minDist;
} }
/// <summary>
/// Computes the minimum translation distance along a push direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
/// stationaryEntities. Delegates to the Vector-based overload.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction)
{
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0));
}
/// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
/// stationaryEntities. Works with native Line, Arc, and Circle entities
/// without tessellation.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, Vector direction)
{
var minDist = double.MaxValue;
var dirX = direction.X;
var dirY = direction.Y;
var movingVertices = ExtractEntityVertices(movingEntities);
for (var v = 0; v < movingVertices.Length; v++)
{
var vx = movingVertices[v].X;
var vy = movingVertices[v].Y;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) return 0;
}
}
}
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
for (var v = 0; v < stationaryVertices.Length; v++)
{
var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) return 0;
}
}
}
// Phase 3: Arc-to-line closest-point check.
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
// closest point on a small corner arc to a straight edge may lie between
// those samples. Use ClosestPointTo to find it and fire a ray from there.
minDist = ArcToLineClosestDistance(movingEntities, stationaryEntities, dirX, dirY, minDist);
if (minDist <= 0) return 0;
minDist = ArcToLineClosestDistance(stationaryEntities, movingEntities, oppX, oppY, minDist);
if (minDist <= 0) return 0;
// Phase 4: Curve-to-curve direct distance.
// The vertex-to-entity approach misses the closest contact between two
// curved entities (circles/arcs) because only a few cardinal vertices are
// sampled. The true closest contact along the push direction is found by
// treating it as a ray from one center to an expanded circle at the other
// center (radius = r1 + r2).
for (var i = 0; i < movingEntities.Count; i++)
{
var me = movingEntities[i];
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
continue;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var se = stationaryEntities[j];
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
continue;
var d = RayCircleDistance(mcx, mcy, scx, scy, mr + sr, dirX, dirY);
if (d >= minDist)
continue;
// For arcs, verify the contact point falls within both arcs' angular ranges.
if (me is Arc || se is Arc)
{
var mx = mcx + d * dirX;
var my = mcy + d * dirY;
var toCx = scx - mx;
var toCy = scy - my;
if (me is Arc mArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
continue;
}
if (se is Arc sArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
continue;
}
}
minDist = d;
if (d <= 0) return 0;
}
}
return minDist;
}
private static double ArcToLineClosestDistance(
List<Entity> arcEntities, List<Entity> lineEntities,
double dirX, double dirY, double minDist)
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (arcEntities[i] is 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++)
{
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++)
{
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; }
}
}
}
return minDist;
}
private static double RayEntityDistance(
double vx, double vy, Entity entity, double dirX, double dirY)
{
if (entity is Line line)
{
return RayEdgeDistance(vx, vy,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
dirX, dirY);
}
if (entity is Arc arc)
{
return RayArcDistance(vx, vy,
arc.Center.X, arc.Center.Y, arc.Radius,
arc.StartAngle, arc.EndAngle, arc.IsReversed,
dirX, dirY);
}
if (entity is Circle circle)
{
return RayCircleDistance(vx, vy,
circle.Center.X, circle.Center.Y, circle.Radius,
dirX, dirY);
}
return double.MaxValue;
}
private static Vector[] ExtractEntityVertices(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity is Line line)
{
vertices.Add(line.pt1);
vertices.Add(line.pt2);
}
else if (entity is Arc arc)
{
vertices.Add(arc.StartPoint());
vertices.Add(arc.EndPoint());
AddArcExtremeVertices(vertices, arc);
}
else if (entity is Circle circle)
{
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
}
}
return vertices.ToArray();
}
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
if (arc.IsReversed)
Generic.Swap(ref a1, ref a2);
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
private static HashSet<Vector> CollectVertices(List<Line> lines, Vector offset)
{
return CollectVertices(ToEdgeArray(lines), offset);
}
private static HashSet<Vector> CollectVertices((Vector start, Vector end)[] edges, Vector offset)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < edges.Length; i++)
{
vertices.Add(edges[i].start + offset);
vertices.Add(edges[i].end + offset);
}
return vertices;
}
private static (Vector start, Vector end)[] ToEdgeArray(List<Line> lines)
{
var edges = new (Vector start, Vector end)[lines.Count];
for (var i = 0; i < lines.Count; i++)
edges[i] = (lines[i].pt1, lines[i].pt2);
return edges;
}
private static void SortEdgesForPruning((Vector start, Vector end)[] edges, PushDirection direction)
{
if (direction == PushDirection.Left || direction == PushDirection.Right)
System.Array.Sort(edges, (a, b) =>
System.Math.Min(a.start.Y, a.end.Y).CompareTo(System.Math.Min(b.start.Y, b.end.Y)));
else
System.Array.Sort(edges, (a, b) =>
System.Math.Min(a.start.X, a.end.X).CompareTo(System.Math.Min(b.start.X, b.end.X)));
}
private static bool TryGetCurveParams(Entity entity, out double cx, out double cy, out double r)
{
if (entity is Circle circle)
{
cx = circle.Center.X; cy = circle.Center.Y; r = circle.Radius;
return true;
}
if (entity is Arc arc)
{
cx = arc.Center.X; cy = arc.Center.Y; r = arc.Radius;
return true;
}
cx = cy = r = 0;
return false;
}
private static double BoxProjectionMin(Box box, double dx, double dy) private static double BoxProjectionMin(Box box, double dx, double dy)
{ {
var x = dx >= 0 ? box.Left : box.Right; var x = dx >= 0 ? box.Left : box.Right;
@@ -523,177 +820,17 @@ namespace OpenNest.Geometry
#endregion #endregion
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsHorizontalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Right)
continue;
var distance = box.Left - compareBox.Right;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceRight(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsHorizontalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Left)
continue;
var distance = compareBox.Left - box.Right;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceUp(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsVerticalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Bottom)
continue;
var distance = compareBox.Bottom - box.Top;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceDown(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsVerticalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Top)
continue;
var distance = box.Bottom - compareBox.Top;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable<Box> boxes) public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable<Box> boxes)
{ {
var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList(); var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList();
#region Find Top/Bottom Limits if (!FindVerticalLimits(pt, bounds, verticalBoxes, out var top, out var btm))
return Box.Empty;
var top = double.MaxValue;
var btm = double.MinValue;
foreach (var box in verticalBoxes)
{
var boxBtm = box.Bottom;
var boxTop = box.Top;
if (boxBtm > pt.Y && boxBtm < top)
top = boxBtm;
else if (box.Top < pt.Y && boxTop > btm)
btm = boxTop;
}
if (top == double.MaxValue)
{
if (bounds.Top > pt.Y)
top = bounds.Top;
else return Box.Empty;
}
if (btm == double.MinValue)
{
if (bounds.Bottom < pt.Y)
btm = bounds.Bottom;
else return Box.Empty;
}
#endregion
var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList(); var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList();
#region Find Left/Right Limits if (!FindHorizontalLimits(pt, bounds, horizontalBoxes, out var lft, out var rgt))
return Box.Empty;
var lft = double.MinValue;
var rgt = double.MaxValue;
foreach (var box in horizontalBoxes)
{
var boxLft = box.Left;
var boxRgt = box.Right;
if (boxLft > pt.X && boxLft < rgt)
rgt = boxLft;
else if (boxRgt < pt.X && boxRgt > lft)
lft = boxRgt;
}
if (rgt == double.MaxValue)
{
if (bounds.Right > pt.X)
rgt = bounds.Right;
else return Box.Empty;
}
if (lft == double.MinValue)
{
if (bounds.Left < pt.X)
lft = bounds.Left;
else return Box.Empty;
}
#endregion
return new Box(lft, btm, rgt - lft, top - btm); return new Box(lft, btm, rgt - lft, top - btm);
} }
@@ -702,75 +839,77 @@ namespace OpenNest.Geometry
{ {
var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList(); var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList();
#region Find Left/Right Limits if (!FindHorizontalLimits(pt, bounds, horizontalBoxes, out var lft, out var rgt))
return Box.Empty;
var lft = double.MinValue;
var rgt = double.MaxValue;
foreach (var box in horizontalBoxes)
{
var boxLft = box.Left;
var boxRgt = box.Right;
if (boxLft > pt.X && boxLft < rgt)
rgt = boxLft;
else if (boxRgt < pt.X && boxRgt > lft)
lft = boxRgt;
}
if (rgt == double.MaxValue)
{
if (bounds.Right > pt.X)
rgt = bounds.Right;
else return Box.Empty;
}
if (lft == double.MinValue)
{
if (bounds.Left < pt.X)
lft = bounds.Left;
else return Box.Empty;
}
#endregion
var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList(); var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList();
#region Find Top/Bottom Limits if (!FindVerticalLimits(pt, bounds, verticalBoxes, out var top, out var btm))
return Box.Empty;
var top = double.MaxValue; return new Box(lft, btm, rgt - lft, top - btm);
var btm = double.MinValue; }
foreach (var box in verticalBoxes) private static bool FindVerticalLimits(Vector pt, Box bounds, List<Box> boxes, out double top, out double btm)
{
top = double.MaxValue;
btm = double.MinValue;
foreach (var box in boxes)
{ {
var boxBtm = box.Bottom; var boxBtm = box.Bottom;
var boxTop = box.Top; var boxTop = box.Top;
if (boxBtm > pt.Y && boxBtm < top) if (boxBtm > pt.Y && boxBtm < top)
top = boxBtm; top = boxBtm;
else if (box.Top < pt.Y && boxTop > btm) else if (box.Top < pt.Y && boxTop > btm)
btm = boxTop; btm = boxTop;
} }
if (top == double.MaxValue) if (top == double.MaxValue)
{ {
if (bounds.Top > pt.Y) if (bounds.Top > pt.Y) top = bounds.Top;
top = bounds.Top; else return false;
else return Box.Empty;
} }
if (btm == double.MinValue) if (btm == double.MinValue)
{ {
if (bounds.Bottom < pt.Y) if (bounds.Bottom < pt.Y) btm = bounds.Bottom;
btm = bounds.Bottom; else return false;
else return Box.Empty;
} }
#endregion return true;
}
return new Box(lft, btm, rgt - lft, top - btm); private static bool FindHorizontalLimits(Vector pt, Box bounds, List<Box> boxes, out double lft, out double rgt)
{
lft = double.MinValue;
rgt = double.MaxValue;
foreach (var box in boxes)
{
var boxLft = box.Left;
var boxRgt = box.Right;
if (boxLft > pt.X && boxLft < rgt)
rgt = boxLft;
else if (boxRgt < pt.X && boxRgt > lft)
lft = boxRgt;
}
if (rgt == double.MaxValue)
{
if (bounds.Right > pt.X) rgt = bounds.Right;
else return false;
}
if (lft == double.MinValue)
{
if (bounds.Left < pt.X) lft = bounds.Left;
else return false;
}
return true;
} }
} }
} }

View File

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

View File

@@ -0,0 +1,9 @@
namespace OpenNest
{
public interface IConfigurablePostProcessor : IPostProcessor
{
object Config { get; }
void SaveConfig();
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace OpenNest
{
public interface IMaterialProvidingPostProcessor
{
IEnumerable<string> GetMaterialNames();
}
}

View File

@@ -0,0 +1,7 @@
namespace OpenNest
{
public interface IPostProcessorNestAware
{
void PrepareForNest(Nest nest);
}
}

View File

@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace OpenNest.Math
{
/// <summary>
/// Recursive descent parser for simple arithmetic expressions supporting
/// +, -, *, /, parentheses, unary minus/plus, and $variable references.
/// </summary>
public static class ExpressionEvaluator
{
public static double Evaluate(string expression, IReadOnlyDictionary<string, double> variables)
{
var parser = new Parser(expression, variables);
var result = parser.ParseExpression();
parser.SkipWhitespace();
if (!parser.IsEnd)
throw new FormatException($"Unexpected character at position {parser.Position}: '{parser.Current}'");
return result;
}
private ref struct Parser
{
private readonly ReadOnlySpan<char> _input;
private readonly IReadOnlyDictionary<string, double> _variables;
private int _pos;
public Parser(string input, IReadOnlyDictionary<string, double> variables)
{
_input = input.AsSpan();
_variables = variables;
_pos = 0;
}
public int Position => _pos;
public bool IsEnd => _pos >= _input.Length;
public char Current => _input[_pos];
public void SkipWhitespace()
{
while (_pos < _input.Length && _input[_pos] == ' ')
_pos++;
}
// Expression = Term (('+' | '-') Term)*
public double ParseExpression()
{
SkipWhitespace();
var left = ParseTerm();
while (true)
{
SkipWhitespace();
if (IsEnd) break;
var op = Current;
if (op != '+' && op != '-') break;
_pos++;
SkipWhitespace();
var right = ParseTerm();
left = op == '+' ? left + right : left - right;
}
return left;
}
// Term = Unary (('*' | '/') Unary)*
private double ParseTerm()
{
var left = ParseUnary();
while (true)
{
SkipWhitespace();
if (IsEnd) break;
var op = Current;
if (op != '*' && op != '/') break;
_pos++;
SkipWhitespace();
var right = ParseUnary();
left = op == '*' ? left * right : left / right;
}
return left;
}
// Unary = ('-' | '+')? Primary
private double ParseUnary()
{
SkipWhitespace();
if (!IsEnd && Current == '-')
{
_pos++;
return -ParsePrimary();
}
if (!IsEnd && Current == '+')
{
_pos++;
}
return ParsePrimary();
}
// Primary = '(' Expression ')' | '$' Identifier | Number
private double ParsePrimary()
{
SkipWhitespace();
if (IsEnd)
throw new FormatException("Unexpected end of expression.");
if (Current == '(')
{
_pos++; // consume '('
var value = ParseExpression();
SkipWhitespace();
if (IsEnd || Current != ')')
throw new FormatException("Expected closing parenthesis.");
_pos++; // consume ')'
return value;
}
if (Current == '$')
{
_pos++; // consume '$'
var start = _pos;
while (_pos < _input.Length && (char.IsLetterOrDigit(_input[_pos]) || _input[_pos] == '_'))
_pos++;
if (_pos == start)
throw new FormatException("Expected variable name after '$'.");
var name = _input.Slice(start, _pos - start).ToString();
if (!_variables.TryGetValue(name, out var varValue))
throw new KeyNotFoundException($"Undefined variable: ${name}");
return varValue;
}
// Number
var numStart = _pos;
while (_pos < _input.Length && (char.IsDigit(_input[_pos]) || _input[_pos] == '.'))
_pos++;
if (_pos == numStart)
throw new FormatException($"Unexpected character '{Current}' at position {_pos}.");
var numSpan = _input.Slice(numStart, _pos - numStart).ToString();
if (!double.TryParse(numSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
throw new FormatException($"Invalid number: '{numSpan}'");
return number;
}
}
}
}

View File

@@ -1,6 +1,7 @@
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.Geometry; using OpenNest.Geometry;
using System; using System;
using System.Collections.Generic;
namespace OpenNest namespace OpenNest
{ {
@@ -21,6 +22,7 @@ namespace OpenNest
Plates.ItemRemoved += Plates_PlateRemoved; Plates.ItemRemoved += Plates_PlateRemoved;
Drawings = new DrawingCollection(); Drawings = new DrawingCollection();
PlateDefaults = new PlateSettings(); PlateDefaults = new PlateSettings();
Material = new Material();
Customer = string.Empty; Customer = string.Empty;
Notes = string.Empty; Notes = string.Empty;
} }
@@ -36,6 +38,12 @@ namespace OpenNest
public string Notes { get; set; } public string Notes { get; set; }
public string AssistGas { get; set; } = "";
public double Thickness { get; set; }
public Material Material { get; set; }
public Units Units { get; set; } public Units Units { get; set; }
public DateTime DateCreated { get; set; } public DateTime DateCreated { get; set; }
@@ -44,6 +52,10 @@ namespace OpenNest
public PlateSettings PlateDefaults { get; set; } public PlateSettings PlateDefaults { get; set; }
public List<PlateOption> PlateOptions { get; set; } = new();
public double SalvageRate { get; set; } = 0.5;
public Plate CreatePlate() public Plate CreatePlate()
{ {
var plate = PlateDefaults.CreateNew(); var plate = PlateDefaults.CreateNew();
@@ -82,18 +94,6 @@ namespace OpenNest
set { plate.Quadrant = value; } set { plate.Quadrant = value; }
} }
public double Thickness
{
get { return plate.Thickness; }
set { plate.Thickness = value; }
}
public Material Material
{
get { return plate.Material; }
set { plate.Material = value; }
}
public Size Size public Size Size
{ {
get { return plate.Size; } get { return plate.Size; }
@@ -114,9 +114,7 @@ namespace OpenNest
public void SetFromExisting(Plate plate) public void SetFromExisting(Plate plate)
{ {
Thickness = plate.Thickness;
Quadrant = plate.Quadrant; Quadrant = plate.Quadrant;
Material = plate.Material;
Size = plate.Size; Size = plate.Size;
EdgeSpacing = plate.EdgeSpacing; EdgeSpacing = plate.EdgeSpacing;
PartSpacing = plate.PartSpacing; PartSpacing = plate.PartSpacing;
@@ -126,11 +124,9 @@ namespace OpenNest
{ {
return new Plate() return new Plate()
{ {
Thickness = Thickness,
Size = Size, Size = Size,
EdgeSpacing = EdgeSpacing, EdgeSpacing = EdgeSpacing,
PartSpacing = PartSpacing, PartSpacing = PartSpacing,
Material = Material,
Quadrant = Quadrant, Quadrant = Quadrant,
Quantity = 1 Quantity = 1
}; };

View File

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

View File

@@ -1,6 +1,7 @@
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -21,6 +22,7 @@ namespace OpenNest
{ {
private Vector location; private Vector location;
private bool ownsProgram; private bool ownsProgram;
private double preLeadInRotation;
public readonly Drawing BaseDrawing; public readonly Drawing BaseDrawing;
@@ -55,12 +57,61 @@ namespace OpenNest
public bool HasManualLeadIns { get; set; } public bool HasManualLeadIns { get; set; }
public bool LeadInsLocked { get; set; }
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, nextPartStart);
Program = result.Program;
CuttingParameters = parameters;
HasManualLeadIns = true;
UpdateBounds();
}
public void ApplySingleLeadIn(CNC.CuttingStrategy.CuttingParameters parameters,
Geometry.Vector point, Geometry.Entity entity, CNC.CuttingStrategy.ContourType contourType)
{
preLeadInRotation = Rotation;
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
var result = strategy.ApplySingle(Program, point, entity, contourType);
Program = result.Program;
CuttingParameters = parameters;
HasManualLeadIns = true;
UpdateBounds();
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
var location = Location;
Program = BaseDrawing.Program.Clone() as Program;
ownsProgram = true;
if (!Math.Tolerance.IsEqualTo(rotation, 0))
Program.Rotate(rotation);
Location = location;
HasManualLeadIns = false;
LeadInsLocked = false;
CuttingParameters = null;
UpdateBounds();
}
/// <summary> /// <summary>
/// Gets the rotation of the part in radians. /// Gets the rotation of the part in radians.
/// </summary> /// </summary>
public double Rotation public double Rotation
{ {
get { return Program.Rotation; } get { return HasManualLeadIns ? preLeadInRotation : Program.Rotation; }
} }
/// <summary> /// <summary>
@@ -72,6 +123,7 @@ namespace OpenNest
EnsureOwnedProgram(); EnsureOwnedProgram();
Program.Rotate(angle); Program.Rotate(angle);
location = Location.Rotate(angle); location = Location.Rotate(angle);
preLeadInRotation = Program.Rotation;
UpdateBounds(); UpdateBounds();
} }
@@ -85,6 +137,7 @@ namespace OpenNest
EnsureOwnedProgram(); EnsureOwnedProgram();
Program.Rotate(angle); Program.Rotate(angle);
location = Location.Rotate(angle, origin); location = Location.Rotate(angle, origin);
preLeadInRotation = Program.Rotation;
UpdateBounds(); UpdateBounds();
} }
@@ -142,7 +195,14 @@ namespace OpenNest
{ {
var rotation = Rotation; var rotation = Rotation;
Program = BaseDrawing.Program.Clone() as Program; Program = BaseDrawing.Program.Clone() as Program;
Program.Rotate(Program.Rotation - rotation);
if (!Math.Tolerance.IsEqualTo(rotation, 0))
Program.Rotate(rotation);
HasManualLeadIns = false;
LeadInsLocked = false;
CuttingParameters = null;
UpdateBounds();
} }
/// <summary> /// <summary>
@@ -170,10 +230,18 @@ namespace OpenNest
if (perimeter1 == null || perimeter2 == null) if (perimeter1 == null || perimeter2 == null)
return false; return false;
perimeter1.Offset(Location); var polygon1 = perimeter1.ToPolygon();
perimeter2.Offset(part.Location); var polygon2 = perimeter2.ToPolygon();
return perimeter1.Intersects(perimeter2, out pts); if (polygon1 == null || polygon2 == null)
return false;
polygon1.Offset(Location);
polygon2.Offset(part.Location);
var result = Geometry.Collision.Check(polygon1, polygon2);
pts = result.IntersectionPoints.ToList();
return result.Overlaps;
} }
public double Left public double Left
@@ -221,7 +289,7 @@ namespace OpenNest
var part = new Part(BaseDrawing, Program, var part = new Part(BaseDrawing, Program,
location + offset, location + offset,
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y, new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
BoundingBox.Width, BoundingBox.Length)); BoundingBox.Length, BoundingBox.Width));
return part; return part;
} }

View File

@@ -39,25 +39,120 @@ namespace OpenNest
return lines; return lines;
} }
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001) /// <summary>
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
/// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs.
/// </summary>
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
if (offsetShape == null)
return new List<Entity>();
// Offset the shape's entities to the part's location.
// OffsetOutward creates a new Shape, so mutating is safe.
foreach (var entity in offsetShape.Entities)
entity.Offset(part.Location);
return offsetShape.Entities;
}
/// <summary>
/// Returns all entities (perimeter + cutouts) with spacing offset applied,
/// without tessellation. Perimeter is offset outward, cutouts inward.
/// </summary>
public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var entities = new List<Entity>();
var perimeter = profile.Perimeter.OffsetOutward(spacing);
if (perimeter != null)
{
foreach (var entity in perimeter.Entities)
entity.Offset(part.Location);
entities.AddRange(perimeter.Entities);
}
foreach (var cutout in profile.Cutouts)
{
var inset = cutout.OffsetInward(spacing);
if (inset == null) continue;
foreach (var entity in inset.Entities)
entity.Offset(part.Location);
entities.AddRange(inset.Entities);
}
return entities;
}
/// <summary>
/// Returns perimeter entities at the part's world location, without tessellation
/// or spacing offset.
/// </summary>
public static List<Entity> GetPerimeterEntities(Part part)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
}
/// <summary>
/// Returns all entities (perimeter + cutouts) at the part's world location,
/// without tessellation or spacing offset.
/// </summary>
public static List<Entity> GetPartEntities(Part part)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
foreach (var cutout in profile.Cutouts)
entities.AddRange(CopyEntitiesAtLocation(cutout.Entities, part.Location));
return entities;
}
private static List<Entity> CopyEntitiesAtLocation(List<Entity> source, Vector location)
{
var result = new List<Entity>(source.Count);
foreach (var entity in source)
{
var copy = entity.Clone();
copy.Offset(location);
result.Add(copy);
}
return result;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001,
bool perimeterOnly = false)
{ {
var entities = ConvertProgram.ToGeometry(part.Program); var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>(); var lines = new List<Line>();
var totalSpacing = spacing;
foreach (var shape in shapes) AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location);
if (!perimeterOnly)
{ {
// Add chord tolerance to compensate for inscribed polygon chords foreach (var cutout in profile.Cutouts)
// being inside the actual offset arcs. AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape; chordTolerance, part.Location);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(polygon.ToLines());
} }
return lines; return lines;
@@ -66,21 +161,17 @@ namespace OpenNest
public static List<Line> GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001) public static List<Line> GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001)
{ {
var entities = ConvertProgram.ToGeometry(part.Program); var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>(); var lines = new List<Line>();
var totalSpacing = spacing;
foreach (var shape in shapes) AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
{ chordTolerance, part.Location, facingDirection);
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
if (offsetEntity == null) foreach (var cutout in profile.Cutouts)
continue; AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location, facingDirection);
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines; return lines;
} }
@@ -104,21 +195,17 @@ namespace OpenNest
public static List<Line> GetOffsetPartLines(Part part, double spacing, Vector facingDirection, double chordTolerance = 0.001) public static List<Line> GetOffsetPartLines(Part part, double spacing, Vector facingDirection, double chordTolerance = 0.001)
{ {
var entities = ConvertProgram.ToGeometry(part.Program); var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>(); var lines = new List<Line>();
var totalSpacing = spacing;
foreach (var shape in shapes) AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
{ chordTolerance, part.Location, facingDirection);
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
if (offsetEntity == null) foreach (var cutout in profile.Cutouts)
continue; AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location, facingDirection);
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines; return lines;
} }
@@ -189,5 +276,41 @@ namespace OpenNest
return lines; return lines;
} }
private static void AddOffsetLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(polygon.ToLines());
}
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location, PushDirection facingDirection)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location, Vector facingDirection)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
} }
} }

View File

@@ -1,6 +1,7 @@
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using OpenNest.Shapes;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -43,28 +44,25 @@ namespace OpenNest
{ {
EdgeSpacing = new Spacing(); EdgeSpacing = new Spacing();
Size = size; Size = size;
Material = new Material();
Parts = new ObservableList<Part>(); Parts = new ObservableList<Part>();
Parts.ItemAdded += Parts_PartAdded; Parts.ItemAdded += Parts_PartAdded;
Parts.ItemRemoved += Parts_PartRemoved; Parts.ItemRemoved += Parts_PartRemoved;
CutOffs = new ObservableList<CutOff>();
Quadrant = 1; Quadrant = 1;
} }
private void Parts_PartAdded(object sender, ItemAddedEventArgs<Part> e) private void Parts_PartAdded(object sender, ItemAddedEventArgs<Part> e)
{ {
e.Item.BaseDrawing.Quantity.Nested += Quantity; if (!e.Item.BaseDrawing.IsCutOff)
e.Item.BaseDrawing.Quantity.Nested += Quantity;
} }
private void Parts_PartRemoved(object sender, ItemRemovedEventArgs<Part> e) private void Parts_PartRemoved(object sender, ItemRemovedEventArgs<Part> e)
{ {
e.Item.BaseDrawing.Quantity.Nested -= Quantity; if (!e.Item.BaseDrawing.IsCutOff)
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
} }
/// <summary>
/// Thickness of the plate.
/// </summary>
public double Thickness { get; set; }
/// <summary> /// <summary>
/// The spacing between parts. /// The spacing between parts.
/// </summary> /// </summary>
@@ -80,16 +78,104 @@ namespace OpenNest
/// </summary> /// </summary>
public Size Size { get; set; } public Size Size { get; set; }
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
/// <summary> /// <summary>
/// Material the plate is made out of. /// Material grain direction in radians. 0 = horizontal.
/// </summary> /// </summary>
public Material Material { get; set; } public double GrainAngle { get; set; }
/// <summary> /// <summary>
/// The parts that the plate contains. /// The parts that the plate contains.
/// </summary> /// </summary>
public ObservableList<Part> Parts { get; set; } public ObservableList<Part> Parts { get; set; }
/// <summary>
/// The cut-off lines defined on this plate.
/// </summary>
public ObservableList<CutOff> CutOffs { get; set; }
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
/// regenerated and added back if it produces any geometry.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings)
{
// Remove existing cut-off parts
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
var cache = BuildPerimeterCache(this);
// Regenerate and materialize each cut-off
foreach (var cutoff in CutOffs)
{
cutoff.Regenerate(this, settings, cache);
if (cutoff.Drawing.Program.Codes.Count == 0)
continue;
var part = new Part(cutoff.Drawing);
Parts.Add(part);
}
}
/// <summary>
/// Builds a dictionary mapping each non-cut-off part to its perimeter entity.
/// Closed shapes use ShapeProfile; open contours fall back to ConvexHull.
/// </summary>
public static Dictionary<Part, Geometry.Entity> BuildPerimeterCache(Plate plate)
{
var cache = new Dictionary<Part, Geometry.Entity>();
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff)
continue;
Geometry.Entity perimeter = null;
try
{
var entities = Converters.ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count > 0)
{
var profile = new Geometry.ShapeProfile(entities);
if (profile.Perimeter.IsClosed())
{
perimeter = profile.Perimeter;
perimeter.Offset(part.Location);
}
else
{
var points = entities.CollectPoints();
if (points.Count >= 3)
{
var hull = Geometry.ConvexHull.Compute(points);
hull.Offset(part.Location);
perimeter = hull;
}
}
}
}
catch
{
perimeter = null;
}
cache[part] = perimeter;
}
return cache;
}
/// <summary> /// <summary>
/// The number of times to cut the plate. /// The number of times to cut the plate.
/// </summary> /// </summary>
@@ -240,11 +326,20 @@ namespace OpenNest
/// <param name="angle"></param> /// <param name="angle"></param>
public void Rotate(double angle) public void Rotate(double angle)
{ {
for (int i = 0; i < Parts.Count; ++i) for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
for (var i = 0; i < Parts.Count; ++i)
{ {
var part = Parts[i]; var part = Parts[i];
part.Rotate(angle); part.Rotate(angle);
} }
foreach (var cutoff in CutOffs)
cutoff.Position = cutoff.Position.Rotate(angle);
} }
/// <summary> /// <summary>
@@ -254,11 +349,24 @@ namespace OpenNest
/// <param name="origin"></param> /// <param name="origin"></param>
public void Rotate(double angle, Vector origin) public void Rotate(double angle, Vector origin)
{ {
for (int i = 0; i < Parts.Count; ++i) for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
for (var i = 0; i < Parts.Count; ++i)
{ {
var part = Parts[i]; var part = Parts[i];
part.Rotate(angle, origin); part.Rotate(angle, origin);
} }
foreach (var cutoff in CutOffs)
{
var pos = cutoff.Position - origin;
pos = pos.Rotate(angle);
cutoff.Position = pos + origin;
}
} }
/// <summary> /// <summary>
@@ -268,11 +376,22 @@ namespace OpenNest
/// <param name="y"></param> /// <param name="y"></param>
public void Offset(double x, double y) public void Offset(double x, double y)
{ {
for (int i = 0; i < Parts.Count; ++i) // Remove cut-off parts before transforming
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
for (var i = 0; i < Parts.Count; ++i)
{ {
var part = Parts[i]; var part = Parts[i];
part.Offset(x, y); part.Offset(x, y);
} }
// Transform cut-off positions
foreach (var cutoff in CutOffs)
cutoff.Position = new Vector(cutoff.Position.X + x, cutoff.Position.Y + y);
} }
/// <summary> /// <summary>
@@ -281,11 +400,20 @@ namespace OpenNest
/// <param name="voffset"></param> /// <param name="voffset"></param>
public void Offset(Vector voffset) public void Offset(Vector voffset)
{ {
for (int i = 0; i < Parts.Count; ++i) for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
}
for (var i = 0; i < Parts.Count; ++i)
{ {
var part = Parts[i]; var part = Parts[i];
part.Offset(voffset); part.Offset(voffset);
} }
foreach (var cutoff in CutOffs)
cutoff.Position = new Vector(cutoff.Position.X + voffset.X, cutoff.Position.Y + voffset.Y);
} }
/// <summary> /// <summary>
@@ -297,7 +425,7 @@ namespace OpenNest
{ {
var plateBox = new Box(); var plateBox = new Box();
// Convention: Size.Length = X axis (horizontal), Size.Width = Y axis (vertical) // Width = Y axis (vertical), Length = X axis (horizontal)
switch (Quadrant) switch (Quadrant)
{ {
case 1: case 1:
@@ -324,8 +452,8 @@ namespace OpenNest
return new Box(); return new Box();
} }
plateBox.Width = Size.Length; plateBox.Width = Size.Width;
plateBox.Length = Size.Width; plateBox.Length = Size.Length;
if (!includeParts) if (!includeParts)
return plateBox; return plateBox;
@@ -341,11 +469,11 @@ namespace OpenNest
? partsBox.Bottom ? partsBox.Bottom
: plateBox.Bottom; : plateBox.Bottom;
boundingBox.Width = partsBox.Right > plateBox.Right boundingBox.Length = partsBox.Right > plateBox.Right
? partsBox.Right - boundingBox.X ? partsBox.Right - boundingBox.X
: plateBox.Right - boundingBox.X; : plateBox.Right - boundingBox.X;
boundingBox.Length = partsBox.Top > plateBox.Top boundingBox.Width = partsBox.Top > plateBox.Top
? partsBox.Top - boundingBox.Y ? partsBox.Top - boundingBox.Y
: plateBox.Top - boundingBox.Y; : plateBox.Top - boundingBox.Y;
@@ -362,8 +490,8 @@ namespace OpenNest
box.X += EdgeSpacing.Left; box.X += EdgeSpacing.Left;
box.Y += EdgeSpacing.Bottom; box.Y += EdgeSpacing.Bottom;
box.Width -= EdgeSpacing.Left + EdgeSpacing.Right; box.Length -= EdgeSpacing.Left + EdgeSpacing.Right;
box.Length -= EdgeSpacing.Top + EdgeSpacing.Bottom; box.Width -= EdgeSpacing.Top + EdgeSpacing.Bottom;
return box; return box;
} }
@@ -421,6 +549,65 @@ namespace OpenNest
Rounding.RoundUpToNearest(xExtent, roundingFactor)); 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> /// <summary>
/// Gets the area of the top surface of the plate. /// Gets the area of the top surface of the plate.
/// </summary> /// </summary>
@@ -433,19 +620,17 @@ namespace OpenNest
/// <summary> /// <summary>
/// Gets the volume of the plate. /// Gets the volume of the plate.
/// </summary> /// </summary>
/// <returns></returns> public double Volume(double thickness)
public double Volume()
{ {
return Area() * Thickness; return Area() * thickness;
} }
/// <summary> /// <summary>
/// Gets the weight of the plate. /// Gets the weight of the plate.
/// </summary> /// </summary>
/// <returns></returns> public double Weight(double thickness, double density)
public double Weight()
{ {
return Volume() * Material.Density; return Volume(thickness) * density;
} }
/// <summary> /// <summary>
@@ -454,24 +639,37 @@ namespace OpenNest
/// <returns>Returns a number between 0.0 and 1.0</returns> /// <returns>Returns a number between 0.0 and 1.0</returns>
public double Utilization() public double Utilization()
{ {
return Parts.Sum(part => part.BaseDrawing.Area) / Area(); return Parts.Where(p => !p.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) / Area();
} }
public bool HasOverlappingParts(out List<Vector> pts) public bool HasOverlappingParts(out List<Vector> pts)
{ {
pts = new List<Vector>(); pts = new List<Vector>();
var realParts = Parts.Where(p => !p.BaseDrawing.IsCutOff).ToList();
for (int i = 0; i < Parts.Count; i++) for (var i = 0; i < realParts.Count; i++)
{ {
var part1 = Parts[i]; var part1 = realParts[i];
var b1 = part1.BoundingBox;
for (int j = i + 1; j < Parts.Count; j++) for (var j = i + 1; j < realParts.Count; j++)
{ {
var part2 = Parts[j]; var part2 = realParts[j];
var b2 = part2.BoundingBox;
List<Vector> pts2; // Skip pairs whose bounding boxes don't meaningfully overlap.
// Floating-point rounding can produce sub-epsilon overlaps for
// parts that are merely edge-touching, so require the overlap
// region to exceed Epsilon in both dimensions.
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (part1.Intersects(part2, out pts2)) if (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon)
continue;
if (part1.Intersects(part2, out var pts2))
pts.AddRange(pts2); pts.AddRange(pts2);
} }
} }

View File

@@ -0,0 +1,243 @@
using OpenNest.Collections;
using System;
namespace OpenNest
{
public class PlateChangedEventArgs : EventArgs
{
public Plate Plate { get; }
public int Index { get; }
public PlateChangedEventArgs(Plate plate, int index)
{
Plate = plate;
Index = index;
}
}
public class PlateManager : IDisposable
{
private readonly Nest nest;
private bool disposed;
private bool suppressNavigation;
private bool batching;
private Plate subscribedLast;
private Plate subscribedSecondToLast;
public event EventHandler<PlateChangedEventArgs> CurrentPlateChanged;
public event EventHandler PlateListChanged;
public PlateManager(Nest nest)
{
this.nest = nest;
nest.Plates.ItemAdded += OnPlateAdded;
nest.Plates.ItemRemoved += OnPlateRemoved;
}
public int CurrentIndex { get; private set; }
public Plate CurrentPlate => nest.Plates.Count > 0 ? nest.Plates[CurrentIndex] : null;
public int Count => nest.Plates.Count;
public bool IsFirst => Count == 0 || CurrentIndex <= 0;
public bool IsLast => CurrentIndex + 1 >= Count;
public bool CanRemoveCurrent => Count > 1 && CurrentPlate != null && CurrentPlate.Parts.Count > 0;
public void LoadFirst()
{
if (Count == 0)
return;
CurrentIndex = 0;
FireCurrentPlateChanged();
}
public void LoadLast()
{
if (Count == 0)
return;
CurrentIndex = Count - 1;
FireCurrentPlateChanged();
}
public bool LoadNext()
{
if (CurrentIndex + 1 >= Count)
return false;
CurrentIndex++;
FireCurrentPlateChanged();
return true;
}
public bool LoadPrevious()
{
if (Count == 0 || CurrentIndex - 1 < 0)
return false;
CurrentIndex--;
FireCurrentPlateChanged();
return true;
}
public void LoadAt(int index)
{
if (index < 0 || index >= Count)
return;
CurrentIndex = index;
FireCurrentPlateChanged();
}
public void EnsureSentinel()
{
suppressNavigation = true;
try
{
if (Count == 0 || nest.Plates[^1].Parts.Count > 0)
nest.CreatePlate();
while (Count > 1
&& nest.Plates[^1].Parts.Count == 0
&& nest.Plates[^2].Parts.Count == 0)
{
nest.Plates.RemoveAt(Count - 1);
}
}
finally
{
suppressNavigation = false;
}
SubscribeToTailPlates();
}
public void BeginBatch()
{
batching = true;
}
public void EndBatch()
{
batching = false;
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
FireCurrentPlateChanged();
}
public Plate GetOrCreateEmpty()
{
for (var i = Count - 1; i >= 0; i--)
{
if (nest.Plates[i].Parts.Count == 0)
return nest.Plates[i];
}
return nest.CreatePlate();
}
public void RemoveCurrent()
{
if (Count < 2)
return;
nest.Plates.RemoveAt(CurrentIndex);
}
private void SubscribeToTailPlates()
{
UnsubscribeFromTailPlates();
if (Count > 0)
{
subscribedLast = nest.Plates[^1];
subscribedLast.PartAdded += OnTailPartAdded;
subscribedLast.PartRemoved += OnTailPartRemoved;
}
if (Count > 1)
{
subscribedSecondToLast = nest.Plates[^2];
subscribedSecondToLast.PartAdded += OnTailPartAdded;
subscribedSecondToLast.PartRemoved += OnTailPartRemoved;
}
}
private void UnsubscribeFromTailPlates()
{
if (subscribedLast != null)
{
subscribedLast.PartAdded -= OnTailPartAdded;
subscribedLast.PartRemoved -= OnTailPartRemoved;
subscribedLast = null;
}
if (subscribedSecondToLast != null)
{
subscribedSecondToLast.PartAdded -= OnTailPartAdded;
subscribedSecondToLast.PartRemoved -= OnTailPartRemoved;
subscribedSecondToLast = null;
}
}
private void OnTailPartAdded(object sender, ItemAddedEventArgs<Part> e)
{
if (!batching)
EnsureSentinel();
}
private void OnTailPartRemoved(object sender, ItemRemovedEventArgs<Part> e)
{
if (!batching)
EnsureSentinel();
}
private void OnPlateAdded(object sender, ItemAddedEventArgs<Plate> e)
{
if (!suppressNavigation && !batching)
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
if (!suppressNavigation)
{
CurrentIndex = Count - 1;
FireCurrentPlateChanged();
}
}
private void OnPlateRemoved(object sender, ItemRemovedEventArgs<Plate> e)
{
if (CurrentIndex >= Count && Count > 0)
CurrentIndex = Count - 1;
if (!suppressNavigation && !batching)
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
if (!suppressNavigation)
FireCurrentPlateChanged();
}
private void FireCurrentPlateChanged()
{
CurrentPlateChanged?.Invoke(this, new PlateChangedEventArgs(CurrentPlate, CurrentIndex));
}
public void Dispose()
{
if (disposed)
return;
disposed = true;
UnsubscribeFromTailPlates();
nest.Plates.ItemAdded -= OnPlateAdded;
nest.Plates.ItemRemoved -= OnPlateRemoved;
}
}
}

View File

@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace OpenNest
{
public class PlateOptimizerResult
{
public List<Part> Parts { get; set; } = new();
public PlateOption ChosenSize { get; set; }
public double NetCost { get; set; }
public double Utilization { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
namespace OpenNest
{
public class PlateOption
{
public double Width { get; set; }
public double Length { get; set; }
public double Cost { get; set; }
public double Area => Width * Length;
}
}

View File

@@ -7,6 +7,13 @@ namespace OpenNest.Shapes
{ {
public double Diameter { get; set; } public double Diameter { get; set; }
public override string GenerateName() => $"Circle {Dim(Diameter)} Dia";
public override void SetPreviewDefaults()
{
Diameter = 8;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var entities = new List<Entity> var entities = new List<Entity>

View File

@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
public double Base { get; set; } public double Base { get; set; }
public double Height { get; set; } public double Height { get; set; }
public override string GenerateName() => $"Isosceles Triangle {Dim(Base)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Base = 8;
Height = 10;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var midX = Base / 2.0; var midX = Base / 2.0;

View File

@@ -10,6 +10,16 @@ namespace OpenNest.Shapes
public double LegWidth { get; set; } public double LegWidth { get; set; }
public double LegHeight { get; set; } public double LegHeight { get; set; }
public override string GenerateName() => $"L {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 8;
Height = 10;
LegWidth = 3;
LegHeight = 3;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var lw = LegWidth > 0 ? LegWidth : Width / 2.0; var lw = LegWidth > 0 ? LegWidth : Width / 2.0;

View File

@@ -3,28 +3,40 @@ using System.Collections.Generic;
namespace OpenNest.Shapes namespace OpenNest.Shapes
{ {
public class OctagonShape : ShapeDefinition public class NgonShape : ShapeDefinition
{ {
public int Sides { get; set; }
public double Width { 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() public override Drawing GetDrawing()
{ {
var n = Sides < 3 ? 3 : Sides;
var center = Width / 2.0; 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]; var vertices = new Vector[n];
for (var i = 0; i < 8; i++) 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( vertices[i] = new Vector(
center + circumRadius * System.Math.Cos(angle), center + circumRadius * System.Math.Cos(angle),
center + circumRadius * System.Math.Sin(angle)); center + circumRadius * System.Math.Sin(angle));
} }
var entities = new List<Entity>(); 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])); entities.Add(new Line(vertices[i], vertices[next]));
} }

View File

@@ -3,22 +3,41 @@ using System.Collections.Generic;
namespace OpenNest.Shapes namespace OpenNest.Shapes
{ {
public class FlangeShape : ShapeDefinition public class PipeFlangeShape : ShapeDefinition
{ {
public double NominalPipeSize { get; set; }
public double OD { get; set; } public double OD { get; set; }
public double HoleDiameter { get; set; } public double HoleDiameter { get; set; }
public double HolePatternDiameter { get; set; } public double HolePatternDiameter { get; set; }
public int HoleCount { 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()
{
OD = 7.5;
HoleDiameter = 0.875;
HolePatternDiameter = 5.5;
HoleCount = 8;
PipeSize = "2";
PipeClearance = 0.0625;
Blind = false;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var entities = new List<Entity>(); var entities = new List<Entity>();
// Outer circle
entities.Add(new Circle(0, 0, OD / 2.0)); entities.Add(new Circle(0, 0, OD / 2.0));
// Bolt holes evenly spaced on the bolt circle
var boltCircleRadius = HolePatternDiameter / 2.0; var boltCircleRadius = HolePatternDiameter / 2.0;
var holeRadius = HoleDiameter / 2.0; var holeRadius = HoleDiameter / 2.0;
var angleStep = 2.0 * System.Math.PI / HoleCount; var angleStep = 2.0 * System.Math.PI / HoleCount;
@@ -31,6 +50,12 @@ namespace OpenNest.Shapes
entities.Add(new Circle(cx, cy, holeRadius)); 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); return CreateDrawing(entities);
} }
} }

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

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,
}
}

View File

@@ -5,17 +5,25 @@ namespace OpenNest.Shapes
{ {
public class RectangleShape : ShapeDefinition public class RectangleShape : ShapeDefinition
{ {
public double Length { get; set; }
public double Width { get; set; } public double Width { get; set; }
public double Height { get; set; }
public override string GenerateName() => $"Rectangle {Dim(Length)}x{Dim(Width)}";
public override void SetPreviewDefaults()
{
Length = 12;
Width = 6;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var entities = new List<Entity> var entities = new List<Entity>
{ {
new Line(0, 0, Width, 0), new Line(0, 0, Length, 0),
new Line(Width, 0, Width, Height), new Line(Length, 0, Length, Width),
new Line(Width, Height, 0, Height), new Line(Length, Width, 0, Width),
new Line(0, Height, 0, 0) new Line(0, Width, 0, 0)
}; };
return CreateDrawing(entities); return CreateDrawing(entities);

View File

@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
public double Width { get; set; } public double Width { get; set; }
public double Height { get; set; } public double Height { get; set; }
public override string GenerateName() => $"Right Triangle {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 8;
Height = 6;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var entities = new List<Entity> var entities = new List<Entity>

View File

@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
public double OuterDiameter { get; set; } public double OuterDiameter { get; set; }
public double InnerDiameter { get; set; } public double InnerDiameter { get; set; }
public override string GenerateName() => $"Ring {Dim(OuterDiameter)}x{Dim(InnerDiameter)}";
public override void SetPreviewDefaults()
{
OuterDiameter = 10;
InnerDiameter = 6;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var entities = new List<Entity> var entities = new List<Entity>

View File

@@ -6,10 +6,19 @@ namespace OpenNest.Shapes
{ {
public class RoundedRectangleShape : ShapeDefinition public class RoundedRectangleShape : ShapeDefinition
{ {
public double Length { get; set; }
public double Width { get; set; } public double Width { get; set; }
public double Height { get; set; }
public double Radius { 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;
Width = 6;
Radius = 1;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var r = Radius; var r = Radius;
@@ -17,36 +26,36 @@ namespace OpenNest.Shapes
if (r <= 0) if (r <= 0)
{ {
entities.Add(new Line(0, 0, Width, 0)); entities.Add(new Line(0, 0, Length, 0));
entities.Add(new Line(Width, 0, Width, Height)); entities.Add(new Line(Length, 0, Length, Width));
entities.Add(new Line(Width, Height, 0, Height)); entities.Add(new Line(Length, Width, 0, Width));
entities.Add(new Line(0, Height, 0, 0)); entities.Add(new Line(0, Width, 0, 0));
} }
else else
{ {
// Bottom edge (left to right, above bottom-left arc to bottom-right arc) // Bottom edge (left to right, above bottom-left arc to bottom-right arc)
entities.Add(new Line(r, 0, Width - r, 0)); entities.Add(new Line(r, 0, Length - r, 0));
// Bottom-right corner arc: center at (Width-r, r), from 270deg to 360deg // Bottom-right corner arc: center at (Length-r, r), from 270deg to 360deg
entities.Add(new Arc(Width - r, r, r, entities.Add(new Arc(Length - r, r, r,
Angle.ToRadians(270), Angle.ToRadians(360))); Angle.ToRadians(270), Angle.ToRadians(360)));
// Right edge // Right edge
entities.Add(new Line(Width, r, Width, Height - r)); entities.Add(new Line(Length, r, Length, Width - r));
// Top-right corner arc: center at (Width-r, Height-r), from 0deg to 90deg // Top-right corner arc: center at (Length-r, Width-r), from 0deg to 90deg
entities.Add(new Arc(Width - r, Height - r, r, entities.Add(new Arc(Length - r, Width - r, r,
Angle.ToRadians(0), Angle.ToRadians(90))); Angle.ToRadians(0), Angle.ToRadians(90)));
// Top edge (right to left) // Top edge (right to left)
entities.Add(new Line(Width - r, Height, r, Height)); entities.Add(new Line(Length - r, Width, r, Width));
// Top-left corner arc: center at (r, Height-r), from 90deg to 180deg // Top-left corner arc: center at (r, Width-r), from 90deg to 180deg
entities.Add(new Arc(r, Height - r, r, entities.Add(new Arc(r, Width - r, r,
Angle.ToRadians(90), Angle.ToRadians(180))); Angle.ToRadians(90), Angle.ToRadians(180)));
// Left edge // Left edge
entities.Add(new Line(0, Height - r, 0, r)); entities.Add(new Line(0, Width - r, 0, r));
// Bottom-left corner arc: center at (r, r), from 180deg to 270deg // Bottom-left corner arc: center at (r, r), from 180deg to 270deg
entities.Add(new Arc(r, r, r, entities.Add(new Arc(r, r, r,

View File

@@ -26,12 +26,24 @@ namespace OpenNest.Shapes
public abstract Drawing GetDrawing(); 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 public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
{ {
var json = File.ReadAllText(path); var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions); return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
} }
protected static string Dim(double value) => value.ToString("0.###");
protected Drawing CreateDrawing(List<Entity> entities) protected Drawing CreateDrawing(List<Entity> entities)
{ {
var pgm = ConvertGeometry.ToProgram(entities); var pgm = ConvertGeometry.ToProgram(entities);

View File

@@ -10,6 +10,16 @@ namespace OpenNest.Shapes
public double StemWidth { get; set; } public double StemWidth { get; set; }
public double BarHeight { get; set; } public double BarHeight { get; set; }
public override string GenerateName() => $"T {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 10;
Height = 8;
StemWidth = 3;
BarHeight = 3;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var sw = StemWidth > 0 ? StemWidth : Width / 3.0; var sw = StemWidth > 0 ? StemWidth : Width / 3.0;

View File

@@ -9,6 +9,15 @@ namespace OpenNest.Shapes
public double BottomWidth { get; set; } public double BottomWidth { get; set; }
public double Height { 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;
BottomWidth = 10;
Height = 6;
}
public override Drawing GetDrawing() public override Drawing GetDrawing()
{ {
var offset = (BottomWidth - TopWidth) / 2.0; var offset = (BottomWidth - TopWidth) / 2.0;

View File

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

View File

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

View File

@@ -0,0 +1,719 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Splits a Drawing into multiple pieces along split lines with optional feature geometry.
/// </summary>
public static class DrawingSplitter
{
public static List<Drawing> Split(Drawing drawing, List<SplitLine> splitLines, SplitParameters parameters)
{
if (splitLines.Count == 0)
return new List<Drawing> { drawing };
var profile = BuildProfile(drawing);
DecomposeCircles(profile);
var perimeter = profile.Perimeter;
var bounds = perimeter.BoundingBox;
var sortedLines = splitLines
.Where(l => IsLineInsideBounds(l, bounds))
.OrderBy(l => l.Position)
.ToList();
if (sortedLines.Count == 0)
return new List<Drawing> { drawing };
var regions = BuildClipRegions(sortedLines, bounds);
var feature = GetFeature(parameters.Type);
// 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, cutoutPolygons);
if (pieceEntities.Count == 0)
continue;
var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines);
var allEntities = new List<Entity>();
allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities);
// 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;
}
private static ShapeProfile BuildProfile(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
return new ShapeProfile(entities);
}
private static List<Entity> CollectCutouts(List<Shape> cutouts, Box region, List<SplitLine> splitLines)
{
var entities = new List<Entity>();
foreach (var cutout in cutouts)
{
if (IsCutoutInRegion(cutout, region))
entities.AddRange(cutout.Entities);
else if (DoesCutoutCrossSplitLine(cutout, splitLines))
{
var clipped = ClipCutoutToRegion(cutout, region, splitLines);
if (clipped.Count > 0)
entities.AddRange(clipped);
}
}
return entities;
}
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex, Box region)
{
var pieceBounds = entities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
var offsetX = -pieceBounds.X;
var offsetY = -pieceBounds.Y;
foreach (var e in entities)
e.Offset(offsetX, offsetY);
var pgm = ConvertGeometry.ToProgram(entities);
var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm);
piece.Color = source.Color;
piece.Priority = source.Priority;
piece.Material = source.Material;
piece.Constraints = source.Constraints;
piece.Customer = source.Customer;
piece.Source = source.Source;
piece.Quantity.Required = source.Quantity.Required;
if (source.Bends != null && source.Bends.Count > 0)
{
piece.Bends = new List<Bending.Bend>();
foreach (var bend in source.Bends)
{
var clipped = ClipLineToBox(bend.StartPoint, bend.EndPoint, region);
if (clipped == null)
continue;
piece.Bends.Add(new Bending.Bend
{
StartPoint = new Vector(clipped.Value.Start.X + offsetX, clipped.Value.Start.Y + offsetY),
EndPoint = new Vector(clipped.Value.End.X + offsetX, clipped.Value.End.Y + offsetY),
Direction = bend.Direction,
Angle = bend.Angle,
Radius = bend.Radius,
NoteText = bend.NoteText,
});
}
}
return piece;
}
/// <summary>
/// Clips a line segment to an axis-aligned box using Liang-Barsky algorithm.
/// Returns the clipped start/end or null if the line is entirely outside.
/// </summary>
private static (Vector Start, Vector End)? ClipLineToBox(Vector start, Vector end, Box box)
{
var dx = end.X - start.X;
var dy = end.Y - start.Y;
double t0 = 0, t1 = 1;
double[] p = { -dx, dx, -dy, dy };
double[] q = { start.X - box.Left, box.Right - start.X, start.Y - box.Bottom, box.Top - start.Y };
for (var i = 0; i < 4; i++)
{
if (System.Math.Abs(p[i]) < Math.Tolerance.Epsilon)
{
if (q[i] < -Math.Tolerance.Epsilon)
return null;
}
else
{
var t = q[i] / p[i];
if (p[i] < 0)
t0 = System.Math.Max(t0, t);
else
t1 = System.Math.Min(t1, t);
if (t0 > t1)
return null;
}
}
var clippedStart = new Vector(start.X + t0 * dx, start.Y + t0 * dy);
var clippedEnd = new Vector(start.X + t1 * dx, start.Y + t1 * dy);
return (clippedStart, clippedEnd);
}
private static void DecomposeCircles(ShapeProfile profile)
{
DecomposeCirclesInShape(profile.Perimeter);
foreach (var cutout in profile.Cutouts)
DecomposeCirclesInShape(cutout);
}
private static void DecomposeCirclesInShape(Shape shape)
{
for (var i = shape.Entities.Count - 1; i >= 0; i--)
{
if (shape.Entities[i] is Circle circle)
{
var arc1 = new Arc(circle.Center, circle.Radius, 0, System.Math.PI);
var arc2 = new Arc(circle.Center, circle.Radius, System.Math.PI, System.Math.PI * 2);
shape.Entities.RemoveAt(i);
shape.Entities.Insert(i, arc2);
shape.Entities.Insert(i, arc1);
}
}
}
private static bool IsLineInsideBounds(SplitLine line, Box bounds)
{
return line.Axis == CutOffAxis.Vertical
? line.Position > bounds.Left + OpenNest.Math.Tolerance.Epsilon
&& line.Position < bounds.Right - OpenNest.Math.Tolerance.Epsilon
: line.Position > bounds.Bottom + OpenNest.Math.Tolerance.Epsilon
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
}
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds)
{
var verticals = sortedLines.Where(l => l.Axis == CutOffAxis.Vertical).OrderBy(l => l.Position).ToList();
var horizontals = sortedLines.Where(l => l.Axis == CutOffAxis.Horizontal).OrderBy(l => l.Position).ToList();
var xEdges = new List<double> { bounds.Left };
xEdges.AddRange(verticals.Select(v => v.Position));
xEdges.Add(bounds.Right);
var yEdges = new List<double> { bounds.Bottom };
yEdges.AddRange(horizontals.Select(h => h.Position));
yEdges.Add(bounds.Top);
var regions = new List<Box>();
for (var yi = 0; yi < yEdges.Count - 1; yi++)
for (var xi = 0; xi < xEdges.Count - 1; xi++)
regions.Add(new Box(xEdges[xi], yEdges[yi], xEdges[xi + 1] - xEdges[xi], yEdges[yi + 1] - yEdges[yi]));
return regions;
}
/// <summary>
/// Clip perimeter to a region by walking entities, splitting at split line crossings,
/// and stitching in feature edges. No polygon clipping library needed.
/// </summary>
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
List<Polygon> cutoutPolygons)
{
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>();
foreach (var entity in perimeter.Entities)
ProcessEntity(entity, region, entities);
if (entities.Count == 0)
return new List<Entity>();
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<Entity> entities)
{
if (entity is Line line)
{
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;
}
if (entity is Arc arc)
{
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>
private static List<SplitLine> GetBoundarySplitLines(Box region, List<SplitLine> splitLines)
{
var result = new List<SplitLine>();
foreach (var sl in splitLines)
{
if (sl.Axis == CutOffAxis.Vertical)
{
if (System.Math.Abs(sl.Position - region.Left) < OpenNest.Math.Tolerance.Epsilon
|| System.Math.Abs(sl.Position - region.Right) < OpenNest.Math.Tolerance.Epsilon)
result.Add(sl);
}
else
{
if (System.Math.Abs(sl.Position - region.Bottom) < OpenNest.Math.Tolerance.Epsilon
|| System.Math.Abs(sl.Position - region.Top) < OpenNest.Math.Tolerance.Epsilon)
result.Add(sl);
}
}
return result;
}
/// <summary>
/// Returns -1 or +1 indicating which side of the split line the region center is on.
/// </summary>
private static int RegionSideOf(Box region, SplitLine sl)
{
return SplitLineIntersect.SideOf(region.Center, sl);
}
/// <summary>
/// Returns the midpoint of an entity. For lines: average of endpoints.
/// For arcs: point at the mid-angle.
/// </summary>
private static Vector MidPoint(Entity entity)
{
if (entity is Line line)
return line.MidPoint;
if (entity is Arc arc)
{
var midAngle = (arc.StartAngle + arc.EndAngle) / 2;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(midAngle),
arc.Center.Y + arc.Radius * System.Math.Sin(midAngle));
}
return new Vector(0, 0);
}
/// <summary>
/// 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,
Box region, List<SplitLine> boundarySplitLines,
ISplitFeature feature, SplitParameters parameters,
List<Polygon> cutoutPolygons)
{
foreach (var sl in boundarySplitLines)
{
var isVertical = sl.Axis == CutOffAxis.Vertical;
var extentStart = isVertical ? region.Bottom : region.Left;
var extentEnd = isVertical ? region.Top : region.Right;
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
continue;
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
// 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)
{
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);
}
}
}
ts.Sort();
var segments = new List<Line>();
for (var i = 0; i < ts.Count - 1; i++)
{
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 segments;
}
/// <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)
{
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;
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 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)
{
var bb = cutout.BoundingBox;
foreach (var sl in splitLines)
{
if (sl.Axis == CutOffAxis.Vertical && bb.Left < sl.Position && bb.Right > sl.Position)
return true;
if (sl.Axis == CutOffAxis.Horizontal && bb.Bottom < sl.Position && bb.Top > sl.Position)
return true;
}
return false;
}
/// <summary>
/// Clip a cutout shape to a region by walking entities 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 entities = new List<Entity>();
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++)
{
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;
}
}
// For each outer, attach the holes that fall inside it.
for (var i = 0; i < shapes.Count; i++)
{
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);
}
return pieces;
}
/// <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]);
}
/// <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)
{
return entity switch
{
Line l => l.StartPoint,
Arc a => a.StartPoint(),
_ => new Vector(0, 0)
};
}
private static Vector GetEndPoint(Entity entity)
{
return entity switch
{
Line l => l.EndPoint,
Arc a => a.EndPoint(),
_ => new Vector(0, 0)
};
}
private static ISplitFeature GetFeature(SplitType type)
{
return type switch
{
SplitType.Straight => new StraightSplit(),
SplitType.WeldGapTabs => new WeldGapTabSplit(),
SplitType.SpikeGroove => new SpikeGrooveSplit(),
_ => new StraightSplit()
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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