Author SHA1 Message Date
ajandClaude Opus 4.6 a085339ba9 fix: improve arc-tangency fitting and add layered engrave/cut passes for GravographIS
GeometrySimplifier/ArcFit now fit arcs that pass exactly through run
endpoints while balancing tangency error between trusted and estimated
directions, fixing arcs that previously bulged or broke tangent
continuity at fillet/compound-curve junctions.

GravographIS post processor gains per-layer (engrave/cut) tool passes
via a new GravographISPostConfig, so ENGRAVE/ETCH-tagged geometry runs
as a separate scribe pass with its own feed/depth and an operator
pause before the cut pass (spring-floated spindle needs a tool swap).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-06 23:15:18 -04:00
ajandClaude Opus 4.8 e493d83899 feat(io): add Gravograph .CHR font reader with text-to-geometry
Add ChrFont, a reader for Gravograph .CHR engraving fonts, plus UI to
convert placed text into engraved geometry in the CAD converter.

The .CHR files are obfuscated with a single-byte XOR. Different
GravoStyle releases use different keys (0x2F in older versions, 0xCF in
the 7000 series, and others across the font library), so the key is
auto-detected from byte 1 of the file: the font name is ASCII stored as
UTF-16LE, so the high byte of its first character is 0x00 in plaintext
and the raw byte equals the key. This reads every font in a GravoStyle
install regardless of version, not just one hardcoded key.

UI: right-clicking a text item in EntityView raises TextConvertRequested;
CadConverterForm renders it via ChrFont with H/V alignment and adds the
result on an ENGRAVE layer.

Tests use Xunit.SkippableFact and a gitignored test-config.json so the
suite points at a local .CHR file without committing proprietary assets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 14:37:48 -04:00
aj 987a5e25bc Add Gravograph IS post processor 2026-05-23 12:40:53 -04:00
ajandClaude Opus 4.6 86582d28c3 fix(io): map DXF text vertical alignment for correct rendering
TextEntity import was only mapping HorizontalAlignment to CadText,
leaving VAlign at its default (Near/top). Middle-center text rendered
shifted to the bottom instead of vertically centered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 07:17:47 -04:00
ajandClaude Opus 4.6 f064368008 fix(io): remove zero-sweep arcs during DXF import
DXF files can contain degenerate arcs where start angle equals end angle
(zero sweep), often left as construction artifacts by CAD software.
These create spurious shapes in ShapeBuilder — e.g. SULLYS-033.dxf
showed 5 loops instead of 4 (3 cutouts + perimeter).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 06:50:20 -04:00
ajandClaude Opus 4.6 9148797897 fix(ui): remove cut-off preview debounce for immediate cursor tracking
The 16ms timer delay made the preview feel laggy. Regenerate directly
on mouse move instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 06:50:17 -04:00
aj da77cc9270 Fix best-fit viewer bounds for angled pairs 2026-05-18 22:17:47 -04:00
ajandClaude Opus 4.6 27f0685058 fix(engine): skip intersecting parts as obstacles during compactor push
Parts that already overlap the moving group are now excluded from the
obstacle list so they don't block the push direction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 19:07:42 -04:00
ajandClaude Opus 4.6 53988acefc fix(io): deduplicate circles and full-circle arcs during DXF import
Duplicate circle entities at the same location inflated pierce counts
and cut pricing (e.g. SULLYS-035 showed 9 pierces instead of 8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:03 -04:00
ajandClaude Opus 4.6 a8d90be2ea feat: add layer filter overloads to Dxf.GetGeometry()
Add optional Func<string, bool> layerFilter parameter to ConvertEntities
and two new GetGeometry overloads (path and stream) that accept a layer
filter. This lets callers control which layers to exclude instead of
being limited to the hardcoded IsNonCutLayer check. Existing overloads
without the filter continue to use the default IsNonCutLayer behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:02 -04:00
ajandClaude Opus 4.6 c25b6bc23a feat(ui): render DXF text annotations in CAD converter preview
Extract MText and TextEntity from the CadDocument during DXF import
and render them in the EntityView. Handles text alignment (left/center/
right via InsertPoint vs AlignmentPoint) and replaces AutoCAD control
codes (%%p → ±, %%d → °, %%c → ⌀). MText formatting codes are
stripped before display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:45:44 -04:00
ajandClaude Opus 4.6 1c994718fb feat(io): add DWG file import support via ACadSharp DwgReader
ACadSharp already includes DwgReader, so this wires it up across the
entire import pipeline — Dxf.Import, CadConverter drag-drop, nest
import dialog, console CLI, BOM analyzer, and training data collector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
ajandClaude Opus 4.6 9d58e6fba8 fix(ui): stay on drawings tab after DXF import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
ajandClaude Opus 4.6 2bae5340f0 test: add nest invariance tests for fill count across import orientations
Verify that filling an L-shaped part produces consistent counts
regardless of the orientation it was imported at, and that all
placed parts stay within the plate work area.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:22:20 -04:00
aj 199095ee43 fix(engine): canonicalize PlaceBestFitPairs builds to match BestFitCache frame 2026-04-23 08:22:20 -04:00
aj eb493d501a feat(engine): wrap single-item Fill with canonicalize/un-rotate bookends 2026-04-23 08:22:20 -04:00
aj 6c98732117 feat(engine): BestFitCache operates in canonical frame; TryPlaceBestFitPair builds from canonical drawing 2026-04-23 08:22:20 -04:00
aj a2e9fd4d14 feat(engine): extract ML features from canonical drawing frame 2026-04-23 08:22:20 -04:00
aj d228b6b812 refactor(engine): share MBR between PartClassifier and CanonicalAngle 2026-04-23 08:22:20 -04:00
aj c634aecd4b docs(core): refresh SourceInfo.Angle doc now that setter wiring lands 2026-04-23 08:22:19 -04:00
aj 14b7c1cf32 feat(core): store Source.Angle; recompute when Program changes 2026-04-23 08:22:19 -04:00
aj 402af91af5 feat(engine): add CanonicalFrame helper for drawing-to-canonical rotation 2026-04-23 08:22:19 -04:00
aj 9a6b656e3c feat(core): add CanonicalAngle helper for MBR-aligning angle 2026-04-23 08:22:19 -04:00
ajandClaude Opus 4.7 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
ajandClaude Opus 4.7 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 6fdf0ad3c5 refactor(cnc): extract rapid enumeration into RapidEnumerator
Pulls the rapid-walk logic (sub-program unwrapping, first-pierce lookup,
incremental-vs-absolute handling, first-rapid skipping) out of
PlateRenderer.DrawRapids into a reusable RapidEnumerator in Core so it
can be unit-tested and reused outside the renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:49:04 -04:00
aj 4f7bfcc3ad Merge remote-tracking branch 'origin/master' 2026-04-15 12:46:40 -04:00
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 0e45c13515 feat(shapes): add PlateSizes catalog and wire Ctrl+P to snap-to-standard
PlateSizes holds standard mill sheet sizes (48x96 through 96x240) and
exposes Recommend() which snaps small layouts to an increment and
rounds larger layouts up to the nearest fitting sheet. Plate.SnapToStandardSize
applies the result while preserving long-axis orientation, and the
existing Ctrl+P "Resize to Fit" menu in EditNestForm now calls it
instead of the simple round-up AutoSize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:16:29 -04:00
aj 54def611fa refactor(ui): switch CreateShapeFromInputs to control-type branching 2026-04-10 17:52:03 -04:00
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 9d66b78a11 feat(ui): add bool checkbox support to ShapeLibraryForm
BuildParameterControls now creates a CheckBox (wired to UpdatePreview) for bool properties instead of a TextBox; CreateShapeFromInputs reads the Checked value via a short-circuit before the TextBox cast.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:36:10 -04:00
aj 6adc5b0967 refactor(shapes): rename FlangeShape to PipeFlangeShape 2026-04-10 17:33:28 -04:00
aj d215d02844 style(shapes): remove redundant usings and document PipeSizes bound 2026-04-10 17:31:22 -04:00
ajandClaude Sonnet 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 87b965f895 refactor(ui): use CadImporter in BomImportForm
Replaces the hand-rolled DXF->Drawing pipeline (Dxf.Import + bend
detection + normalize + ConvertGeometry + pierce offset extraction)
with a single CadImporter.ImportDrawing call. Brings BomImportForm's
output in line with the rest of the callers: drawings now carry
Source.Offset, SourceEntities, SuppressedEntityIds, and detected bends,
and round-trip cleanly through nest files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:11:49 -04:00
aj 08f60690a7 docs: document CadImporter service in CLAUDE.md 2026-04-10 13:27:46 -04:00
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 d921558b9c feat: add CadImporter.BuildDrawing stage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:51:58 -04:00
ajandClaude Opus 4.6 bf3e3e1f42 feat: add CadImporter.Import stage with bend detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:37:12 -04:00
aj e120ece014 feat: add CadImportResult data object for CadImporter 2026-04-10 12:28:17 -04:00
aj 264e8264be feat: add CadImportOptions for CadImporter service 2026-04-10 12:25:04 -04:00
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 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
ajandClaude Sonnet 4.6 89a4e6b981 feat: add MultiPlateNester with sorting logic
Implements static MultiPlateNester.SortItems with BoundingBoxArea and Size sort orders, covered by two passing xUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:48:57 -04:00
aj ebad3577dd feat: add MultiPlateResult type for part-first nesting 2026-04-06 13:46:51 -04:00
aj a8dc275da4 feat: add PartSortOrder enum for part-first nesting 2026-04-06 13:46:49 -04:00
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
ajandClaude Opus 4.6 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
211 changed files with 20403 additions and 2613 deletions
+3 -3
View File
@@ -211,8 +211,8 @@ FakesAssemblies/
.superpowers/ .superpowers/
docs/superpowers/ docs/superpowers/
# Documentation (manuals, templates, etc.)
docs/
# Launch settings # Launch settings
**/Properties/launchSettings.json **/Properties/launchSettings.json
# Local test config (contains user-specific paths to proprietary test assets)
OpenNest.Tests/test-config.json
+4
View File
@@ -57,6 +57,8 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format). - `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`).
@@ -117,3 +119,5 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - `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). - **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. - **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.
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
+13 -9
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;
@@ -30,15 +28,21 @@ public static class NestRunner
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);
var geometry = Dxf.GetGeometry(part.DxfPath); Drawing drawing;
if (geometry.Count == 0) 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 normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(name);
drawing.Program = pgm;
drawings.Add(drawing); drawings.Add(drawing);
} }
+10 -45
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;
@@ -42,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)
@@ -69,10 +67,6 @@ 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); PostProcess(nest, options);
@@ -113,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;
@@ -154,28 +145,14 @@ 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 =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase) f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); || f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
var dxfFiles = options.InputFiles.Where(f => var dxfFiles = options.InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList(); f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
// If we have a nest file, load it and optionally add DXFs. // If we have a nest file, load it and optionally add DXFs.
if (nestFile != null) if (nestFile != null)
@@ -211,7 +188,7 @@ static class NestConsole
// DXF-only mode: create a fresh nest. // DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0) if (dxfFiles.Count == 0)
{ {
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified"); Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
return null; return null;
} }
@@ -241,25 +218,15 @@ static class NestConsole
static Drawing ImportDxf(string path) static Drawing ImportDxf(string path)
{ {
var geometry = Dxf.GetGeometry(path); try
if (geometry.Count == 0)
{ {
Console.Error.WriteLine($"Error: failed to read DXF file or no geometry found: {path}"); return CadImporter.ImportDrawing(path);
}
catch (System.Exception ex)
{
Console.Error.WriteLine($"Error: failed to import DXF '{path}': {ex.Message}");
return null; return null;
} }
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm == null)
{
Console.Error.WriteLine($"Error: failed to convert geometry: {path}");
return null;
}
var name = Path.GetFileNameWithoutExtension(path);
return new Drawing(name, pgm);
} }
static void ApplyTemplate(Plate plate, Options options) static void ApplyTemplate(Plate plate, Options options)
@@ -495,7 +462,7 @@ static class NestConsole
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]"); Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Arguments:"); Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files"); Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Modes:"); Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)"); Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
@@ -514,7 +481,6 @@ 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 <name> Run a post processor after nesting");
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)"); 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(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
@@ -533,7 +499,6 @@ 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;
@@ -1,5 +1,6 @@
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.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
@@ -11,6 +12,11 @@ namespace OpenNest.CNC.CuttingStrategy
private record ContourEntry(Shape Shape, Vector Point, Entity Entity); private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
public CuttingResult Apply(Program partProgram, Vector approachPoint) public CuttingResult Apply(Program partProgram, Vector approachPoint)
{
return Apply(partProgram, approachPoint, Vector.Invalid);
}
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
{ {
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -20,26 +26,60 @@ namespace OpenNest.CNC.CuttingStrategy
var profile = new ShapeProfile(entities); var profile = new ShapeProfile(entities);
// Forward pass: sequence cutouts nearest-neighbor from perimeter // Start from the bounding box corner opposite the origin (max X, max Y)
var perimeterPoint = profile.Perimeter.ClosestPointTo(approachPoint, out _); var bbox = entities.GetBoundingBox();
var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint); var startCorner = new Vector(bbox.Right, bbox.Top);
// Initial pass: sequence cutouts from bbox corner
var seedPoint = startCorner;
var orderedCutouts = SequenceCutouts(profile.Cutouts, seedPoint);
orderedCutouts.Reverse(); orderedCutouts.Reverse();
// Backward pass: walk from perimeter back through cutting order var perimeterSeed = profile.Perimeter.ClosestPointTo(seedPoint, out _);
// so each lead-in faces the next cutout to be cut, not the previous var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterPoint);
Vector perimeterPt;
Entity perimeterEntity;
if (!double.IsNaN(nextPartStart.X) && cutoutEntries.Count > 0)
{
// Iterate: each pass refines the perimeter lead-in which changes
// the internal sequence which changes the last cutout position
for (var iter = 0; iter < 3; iter++)
{
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _);
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
orderedCutouts.Reverse();
cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
}
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
}
else
{
var perimeterRef = cutoutEntries.Count > 0 ? cutoutEntries[0].Point : approachPoint;
perimeterPt = profile.Perimeter.ClosestPointTo(perimeterRef, out perimeterEntity);
}
var result = new Program(Mode.Absolute); var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribeEntities); EmitScribeContours(result, scribeEntities);
foreach (var entry in cutoutEntries) foreach (var entry in cutoutEntries)
EmitContour(result, entry.Shape, entry.Point, entry.Entity); {
if (!entry.Shape.IsClosed())
EmitRawContour(result, entry.Shape);
else
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
}
// Perimeter last if (!profile.Perimeter.IsClosed())
var lastRefPoint = cutoutEntries.Count > 0 ? cutoutEntries[cutoutEntries.Count - 1].Point : approachPoint; EmitRawContour(result, profile.Perimeter);
var perimeterPt = profile.Perimeter.ClosestPointTo(lastRefPoint, out var perimeterEntity); else
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External); EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
result.Mode = Mode.Incremental; result.Mode = Mode.Incremental;
@@ -67,10 +107,14 @@ namespace OpenNest.CNC.CuttingStrategy
// Find the target shape that contains the clicked entity // Find the target shape that contains the clicked entity
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity); var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
// Emit cutouts — only the target gets lead-in/out // Emit cutouts — only the target gets lead-in/out (skip open contours)
foreach (var cutout in profile.Cutouts) foreach (var cutout in profile.Cutouts)
{ {
if (cutout == targetShape) if (!cutout.IsClosed())
{
EmitRawContour(result, cutout);
}
else if (cutout == targetShape)
{ {
var ct = DetectContourType(cutout); var ct = DetectContourType(cutout);
EmitContour(result, cutout, point, matchedEntity, ct); EmitContour(result, cutout, point, matchedEntity, ct);
@@ -82,7 +126,11 @@ namespace OpenNest.CNC.CuttingStrategy
} }
// Emit perimeter // Emit perimeter
if (profile.Perimeter == targetShape) if (!profile.Perimeter.IsClosed())
{
EmitRawContour(result, profile.Perimeter);
}
else if (profile.Perimeter == targetShape)
{ {
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External); EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
} }
@@ -187,6 +235,40 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ContourEntry>(entries); 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) private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
{ {
var contourType = forceType ?? DetectContourType(shape); var contourType = forceType ?? DetectContourType(shape);
@@ -197,16 +279,59 @@ namespace OpenNest.CNC.CuttingStrategy
var leadOut = SelectLeadOut(contourType); var leadOut = SelectLeadOut(contourType);
if (contourType == ContourType.ArcCircle && entity is Circle circle) 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); 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)); program.Codes.AddRange(leadIn.Generate(point, normal, winding));
var reindexed = shape.ReindexAt(point, entity); var reindexedShape = shape.ReindexAt(point, entity);
if (Parameters.TabsEnabled && Parameters.TabConfig != null) if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
reindexed = TrimShapeForTab(reindexed, point, Parameters.TabConfig.Size); reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
program.Codes.AddRange(ConvertShapeToMoves(reindexed, point)); program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(point, normal, winding)); program.Codes.AddRange(leadOut.Generate(point, normal, winding));
} }
@@ -309,7 +434,12 @@ namespace OpenNest.CNC.CuttingStrategy
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle) if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
return circle.Rotation; return circle.Rotation;
return shape.ToPolygon().RotationDirection(); var polygon = shape.ToPolygon();
if (polygon.Vertices.Count < 3)
return RotationType.CCW;
return polygon.RotationDirection();
} }
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle) private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
@@ -23,6 +23,9 @@ namespace OpenNest.CNC.CuttingStrategy
public double PierceClearance { get; set; } = 0.0625; public 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 AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; } public double AutoTabMaxSize { get; set; }
@@ -1,16 +0,0 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
public class MicrotabLeadOut : LeadOut
{
public double GapSize { get; set; } = 0.03;
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{
return new List<ICode>();
}
}
}
+42 -4
View File
@@ -12,6 +12,8 @@ namespace OpenNest.CNC
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<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)
@@ -87,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);
} }
@@ -115,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;
@@ -137,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;
@@ -275,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;
@@ -290,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)
@@ -312,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)
@@ -345,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;
@@ -420,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)
@@ -460,6 +495,9 @@ namespace OpenNest.CNC
foreach (var kvp in Variables) foreach (var kvp in Variables)
pgm.Variables[kvp.Key] = kvp.Value; pgm.Variables[kvp.Key] = kvp.Value;
foreach (var kvp in SubPrograms)
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
return pgm; return pgm;
} }
+80
View File
@@ -0,0 +1,80 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC
{
public static class RapidEnumerator
{
public readonly record struct Segment(Vector From, Vector To);
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
{
var results = new List<Segment>();
// Draw the rapid from the previous tool position to the program's first
// pierce point. This also primes pos so the interior walk interprets
// Incremental deltas from the correct absolute location (basePos), which
// matters for raw pre-lead-in programs that are emitted Incremental.
var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce));
var pos = firstPierce;
Walk(pgm, basePos, ref pos, skipFirst: true, results);
return results;
}
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
{
for (var i = 0; i < pgm.Length; i++)
{
if (pgm[i] is SubProgramCall call && call.Program != null)
return FirstPiercePoint(call.Program, basePos + call.Offset);
if (pgm[i] is Motion motion)
return motion.EndPoint + basePos;
}
return basePos;
}
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results)
{
var skipped = !skipFirst;
for (var i = 0; i < pgm.Length; ++i)
{
var code = pgm[i];
if (code is SubProgramCall { Program: { } program } call)
{
var holeBase = basePos + call.Offset;
var firstPierce = FirstPiercePoint(program, holeBase);
if (!skipped)
skipped = true;
else
results.Add(new Segment(pos, firstPierce));
var subPos = holeBase;
Walk(program, holeBase, ref subPos, skipFirst: true, results);
pos = subPos;
}
else if (code is Motion motion)
{
var endpt = pgm.Mode == Mode.Incremental
? motion.EndPoint + pos
: motion.EndPoint + basePos;
if (code.Type == CodeType.RapidMove)
{
if (!skipped)
skipped = true;
else
results.Add(new Segment(pos, endpt));
}
pos = endpt;
}
}
}
}
}
+17 -3
View File
@@ -1,4 +1,6 @@
using OpenNest.Math; using System.Text;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC 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();
} }
} }
} }
+78
View File
@@ -0,0 +1,78 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using System.Linq;
namespace OpenNest
{
/// <summary>
/// Computes the rotation that maps a drawing to its canonical (MBR-axis-aligned) frame.
/// Lives in OpenNest.Core so Drawing.Program setter can invoke it directly without
/// a circular dependency on OpenNest.Engine.
/// </summary>
public static class CanonicalAngle
{
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
public const double SnapToZero = 0.001;
/// <summary>
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
/// across both callers.
/// </summary>
public static double FromMbr(BoundingRectangleResult mbr)
{
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
return 0.0;
// The MBR edge angle can represent any of four equivalent orientations
// (edge-i, edge-i + π/2, edge-i + π, edge-i - π/2) depending on which hull
// edge the algorithm happened to pick. Normalize -mbr.Angle to the
// representative in [-π/4, π/4] so snap-to-zero works for inputs near
// ANY of the equivalent orientations.
var angle = -mbr.Angle;
const double halfPi = System.Math.PI / 2.0;
angle -= halfPi * System.Math.Round(angle / halfPi);
if (System.Math.Abs(angle) < SnapToZero)
return 0.0;
return angle;
}
public static double Compute(Drawing drawing)
{
if (drawing?.Program == null)
return 0.0;
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0)
return 0.0;
var perimeter = shapes[0];
var perimeterArea = perimeter.Area();
for (var i = 1; i < shapes.Count; i++)
{
var area = shapes[i].Area();
if (area > perimeterArea)
{
perimeter = shapes[i];
perimeterArea = area;
}
}
var polygon = perimeter.ToPolygonWithTolerance(0.1);
if (polygon == null || polygon.Vertices.Count < 3)
return 0.0;
var hull = ConvexHull.Compute(polygon.Vertices);
if (hull.Vertices.Count < 3)
return 0.0;
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
return FromMbr(mbr);
}
}
}
+29 -9
View File
@@ -1,5 +1,6 @@
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
namespace OpenNest.Converters namespace OpenNest.Converters
@@ -81,12 +82,22 @@ namespace OpenNest.Converters
var startpt = arc.StartPoint(); var startpt = arc.StartPoint();
var endpt = arc.EndPoint(); var endpt = arc.EndPoint();
if (startpt != lastpt) if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt); pgm.MoveTo(startpt);
lastpt = endpt; lastpt = endpt;
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW); var layer = ClassifyLayer(arc);
var sweep = System.Math.Abs(arc.SweepAngle());
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
}
else
{
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
}
return lastpt; return lastpt;
} }
@@ -94,10 +105,10 @@ namespace OpenNest.Converters
{ {
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y); var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
if (startpt != lastpt) if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt); pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, RotationType.CCW); pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) });
lastpt = startpt; lastpt = startpt;
return lastpt; return lastpt;
@@ -105,16 +116,25 @@ namespace OpenNest.Converters
private static Vector AddLine(Program pgm, Vector lastpt, Line line) private static Vector AddLine(Program pgm, Vector lastpt, Line line)
{ {
if (line.StartPoint != lastpt) if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(line.StartPoint); pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint); pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
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;
} }
// Engrave/etch geometry maps to Scribe so the post processor can treat it as a
// separate tool pass; everything else keeps the move's default Cut layer.
private static LayerType ClassifyLayer(Entity geo)
{
var name = geo.Layer?.Name;
if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
return LayerType.Scribe;
return LayerType.Cut;
}
} }
} }
+53 -9
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;
}
} }
} }
+12 -6
View File
@@ -20,6 +20,9 @@ namespace OpenNest.Converters
private static void AddProgram(Program program, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) 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;
} }
} }
@@ -106,7 +112,7 @@ 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, Color = layer.Color }); 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, Color = layer.Color }); geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
+45 -2
View File
@@ -2,6 +2,7 @@
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Linq; using System.Linq;
@@ -15,7 +16,7 @@ namespace OpenNest
private static int nextColorIndex; private static int nextColorIndex;
private Program program; private Program program;
public static readonly Color[] PartColors = new Color[] public static Color[] PartColors = new Color[]
{ {
Color.FromArgb(205, 92, 92), // Indian Red Color.FromArgb(205, 92, 92), // Indian Red
Color.FromArgb(148, 103, 189), // Medium Purple Color.FromArgb(148, 103, 189), // Medium Purple
@@ -53,9 +54,9 @@ namespace OpenNest
Id = Interlocked.Increment(ref nextId); Id = Interlocked.Increment(ref nextId);
Name = name; Name = name;
Material = new Material(); Material = new Material();
Program = pgm;
Constraints = new NestConstraints(); Constraints = new NestConstraints();
Source = new SourceInfo(); Source = new SourceInfo();
Program = pgm;
} }
public int Id { get; } public int Id { get; }
@@ -77,9 +78,29 @@ namespace OpenNest
{ {
program = value; program = value;
UpdateArea(); UpdateArea();
RecomputeCanonicalAngle();
} }
} }
/// <summary>
/// Recomputes and stores the canonical angle from the current Program.
/// Callers that mutate Program in place (rather than reassigning it) must invoke this explicitly.
/// Cut-off drawings are left with Angle=0.
/// </summary>
public void RecomputeCanonicalAngle()
{
if (Source == null)
Source = new SourceInfo();
if (program == null || IsCutOff)
{
Source.Angle = 0.0;
return;
}
Source.Angle = CanonicalAngle.Compute(this);
}
public Color Color { get; set; } public Color Color { get; set; }
public bool IsCutOff { get; set; } public bool IsCutOff { get; set; }
@@ -90,6 +111,18 @@ namespace OpenNest
public List<Bend> Bends { get; set; } = new List<Bend>(); 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()
@@ -150,5 +183,15 @@ namespace OpenNest
/// Offset distances to the original location. /// Offset distances to the original location.
/// </summary> /// </summary>
public Vector Offset { get; set; } public Vector Offset { get; set; }
/// <summary>
/// Rotation (radians) that maps the source program geometry to its canonical
/// (MBR-axis-aligned) frame. Populated automatically by the <see cref="Drawing.Program"/>
/// setter via <see cref="CanonicalAngle.Compute"/>. A value of 0 means the drawing is
/// already canonical or <see cref="Drawing.IsCutOff"/> is true. Callers that mutate
/// <see cref="Drawing.Program"/> in place must invoke
/// <see cref="Drawing.RecomputeCanonicalAngle"/> to refresh.
/// </summary>
public double Angle { get; set; }
} }
} }
+27 -14
View File
@@ -93,6 +93,9 @@ namespace OpenNest.Geometry
} }
} }
public bool IsFullCircle() =>
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
/// <summary> /// <summary>
/// Angle in radians between start and end angles. /// Angle in radians between start and end angles.
/// </summary> /// </summary>
@@ -267,6 +270,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>
@@ -397,26 +407,29 @@ namespace OpenNest.Geometry
maxY = startpt.Y; maxY = startpt.Y;
} }
var angle1 = StartAngle; var sweep = SweepAngle();
var angle2 = EndAngle; if (sweep > Tolerance.Epsilon)
{
var angle1 = StartAngle;
var angle2 = EndAngle;
// switch the angle to counter clockwise. if (IsReversed)
if (IsReversed) Generic.Swap(ref angle1, ref angle2);
Generic.Swap(ref angle1, ref angle2);
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2)) if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
maxY = Center.Y + Radius; maxY = Center.Y + Radius;
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2)) if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
minX = Center.X - Radius; minX = Center.X - Radius;
const double oneHalfPI = System.Math.PI * 1.5; const double oneHalfPI = System.Math.PI * 1.5;
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2)) if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
minY = Center.Y - Radius; minY = Center.Y - Radius;
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2)) if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
maxX = Center.X + Radius; maxX = Center.X + Radius;
}
boundingBox.X = minX; boundingBox.X = minX;
boundingBox.Y = minY; boundingBox.Y = minY;
+35 -37
View File
@@ -57,13 +57,14 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Fits a circular arc constrained to be tangent to the given directions at both /// Fits a circular arc that passes exactly through both the first and last points
/// the first and last points. The center lies at the intersection of the normals /// while matching the given endpoint tangents as closely as possible. For any
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives /// circle through two points, the tangents at those points make equal mirrored
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent); /// angles with the chord, so the achievable inscribed angle is the average of the
/// deviation includes any endpoint gap at Pn. /// two requested ones — when the requested tangents are consistent with a single
/// circular arc, both are matched exactly.
/// </summary> /// </summary>
internal static (Vector center, double radius, double deviation) FitWithDualTangent( internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents(
List<Vector> points, Vector startTangent, Vector endTangent) List<Vector> points, Vector startTangent, Vector endTangent)
{ {
if (points.Count < 3) if (points.Count < 3)
@@ -72,42 +73,39 @@ namespace OpenNest.Geometry
var p1 = points[0]; var p1 = points[0];
var pn = points[^1]; 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 dx = pn.X - p1.X;
var dy = pn.Y - p1.Y; var dy = pn.Y - p1.Y;
var t1 = (dx * (-n2y) - (-n2x) * dy) / det; var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
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); return (Vector.Invalid, 0, double.MaxValue);
// Measure endpoint gap at Pn var ux = dx / chordLen;
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y)); var uy = dy / chordLen;
var endpointDev = System.Math.Abs(r2 - r1);
var interiorDev = MaxRadialDeviation(points, cx, cy, r1); // Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev)); var theta1 = SignedAngle(ux, uy, startTangent);
var theta2 = -SignedAngle(ux, uy, endTangent);
var theta = (theta1 + theta2) / 2;
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
return (Vector.Invalid, 0, double.MaxValue);
var halfChord = chordLen / 2;
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
var d = -halfChord / System.Math.Tan(theta);
var cx = (p1.X + pn.X) / 2 + d * -uy;
var cy = (p1.Y + pn.Y) / 2 + d * ux;
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
private static double SignedAngle(double ux, double uy, Vector to)
{
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
if (len < 1e-10) return 0;
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
} }
/// <summary> /// <summary>
+17 -2
View File
@@ -1,8 +1,9 @@
using OpenNest.Math; using System;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
public class Box public class Box : IComparable<Box>
{ {
public static readonly Box Empty = new Box(); public static readonly Box Empty = new Box();
@@ -214,5 +215,19 @@ namespace OpenNest.Geometry
{ {
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length); return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
} }
public int CompareTo(Box other)
{
var cmp = Width.CompareTo(other.Width);
return cmp != 0 ? cmp : Length.CompareTo(other.Length);
}
public static bool operator >(Box a, Box b) => a.CompareTo(b) > 0;
public static bool operator <(Box a, Box b) => a.CompareTo(b) < 0;
public static bool operator >=(Box a, Box b) => a.CompareTo(b) >= 0;
public static bool operator <=(Box a, Box b) => a.CompareTo(b) <= 0;
} }
} }
+10 -1
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>
+5 -1
View File
@@ -173,7 +173,11 @@ namespace OpenNest.Geometry
if (maxDev <= tolerance) if (maxDev <= tolerance)
{ {
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1)); var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1);
if (arc.SweepAngle() < Tolerance.Epsilon)
results.Add(new Line(p0, p1));
else
results.Add(arc);
} }
else else
{ {
+32
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>
@@ -244,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>
@@ -252,6 +276,14 @@ namespace OpenNest.Geometry
public static class EntityExtensions public static class EntityExtensions
{ {
public static List<Entity> CloneAll(this IEnumerable<Entity> entities)
{
var result = new List<Entity>();
foreach (var e in entities)
result.Add(e.Clone());
return result;
}
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities) public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
{ {
var points = new List<Vector>(); var points = new List<Vector>();
@@ -17,6 +17,38 @@ namespace OpenNest.Geometry
(list, item, i) => list.GetCollinearLines(item, i), (list, item, i) => list.GetCollinearLines(item, i),
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)); (Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
public static void Deduplicate(IList<Circle> circles)
{
for (var i = circles.Count - 1; i >= 1; i--)
{
for (var j = i - 1; j >= 0; j--)
{
if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
&& circles[i].Radius.IsEqualTo(circles[j].Radius))
{
circles.RemoveAt(i);
break;
}
}
}
}
public static void Deduplicate(IList<Circle> circles, IList<Arc> arcs)
{
for (var i = circles.Count - 1; i >= 0; i--)
{
for (var j = arcs.Count - 1; j >= 0; j--)
{
if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
&& arcs[j].Radius.IsEqualTo(circles[i].Radius)
&& arcs[j].IsFullCircle())
{
arcs.RemoveAt(j);
}
}
}
}
private delegate bool TryJoin<T>(T a, T b, out T joined); private delegate bool TryJoin<T>(T a, T b, out T joined);
private static void MergePass<T>(IList<T> items, private static void MergePass<T>(IList<T> items,
+164 -30
View File
@@ -374,11 +374,8 @@ public class GeometrySimplifier
var points = CollectPoints(entities, start, k); var points = CollectPoints(entities, start, k);
if (points.Count < 3) return null; if (points.Count < 3) return null;
var startTangent = chainedTangent.IsValid() var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
? chainedTangent var endTangent = EstimateEndTangent(entities, k, points);
: 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); var (center, radius, dev) = TryFit(points, startTangent, endTangent);
if (!center.IsValid()) return null; if (!center.IsValid()) return null;
@@ -386,8 +383,10 @@ public class GeometrySimplifier
while (k + 1 <= runEnd) while (k + 1 <= runEnd)
{ {
var extPoints = CollectPoints(entities, start, k + 1); var extPoints = CollectPoints(entities, start, k + 1);
var extEndTangent = GetExitDirection(entities[k + 1]); if (extPoints.Count < 3) break;
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
if (!nc.IsValid()) break; if (!nc.IsValid()) break;
k++; k++;
@@ -407,37 +406,172 @@ public class GeometrySimplifier
return new ArcFitResult(center, radius, dev, points, k); return new ArcFitResult(center, radius, dev, points, k);
} }
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent) private (Vector center, double radius, double deviation) TryFit(
List<Vector> points, TangentEstimate start, TangentEstimate end)
{ {
// Try dual-tangent fit first (matches direction at both endpoints) foreach (var (center, radius, dev) in FitAttempts(points, start, end))
if (endTangent.IsValid())
{ {
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent); if (!center.IsValid() || dev > Tolerance)
if (dc.IsValid() && dd <= Tolerance) continue;
// 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)
continue;
return (center, radius, System.Math.Max(dev, arcDev));
}
return (Vector.Invalid, 0, 0);
}
/// <summary>
/// Yields fit attempts in preference order. A trusted tangent (chained from the
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
/// exactly on its side; otherwise the tangency error is balanced between both
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
/// passes exactly through both endpoints, so no gaps are introduced.
/// </summary>
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
List<Vector> points, TangentEstimate start, TangentEstimate end)
{
if (start.Trusted && !end.Trusted)
{
yield return ArcFit.FitWithStartTangent(points, start.Direction);
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return FitWithEndTangent(points, end.Direction);
}
else if (end.Trusted && !start.Trusted)
{
yield return FitWithEndTangent(points, end.Direction);
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return ArcFit.FitWithStartTangent(points, start.Direction);
}
else
{
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return ArcFit.FitWithStartTangent(points, start.Direction);
yield return FitWithEndTangent(points, end.Direction);
}
yield return FitMirrorAxis(points);
}
/// <summary>
/// Fits an arc through both endpoints with an exact tangent at the last point,
/// by running the start-tangent fit on the reversed point sequence.
/// </summary>
private static (Vector center, double radius, double deviation) FitWithEndTangent(
List<Vector> points, Vector endTangent)
{
var reversed = new List<Vector>(points);
reversed.Reverse();
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
}
/// <summary>
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
/// vertices and only guide the fit.
/// </summary>
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
/// <summary>Segment-length ratio above which a neighboring line counts as a true
/// straight edge (rather than another chord of the tessellated curve).</summary>
private const double NeighborEdgeFactor = 3.0;
private static TangentEstimate EstimateStartTangent(
List<Entity> entities, int start, List<Vector> points, Vector chainedTangent)
{
if (chainedTangent.IsValid())
return new TangentEstimate(chainedTangent, true);
if (entities[start] is Arc startArc)
return new TangentEstimate(GetEntryDirection(startArc), true);
var firstChordLen = points[0].DistanceTo(points[1]);
if (start > 0)
{
var prev = entities[start - 1];
var prevEnd = prev switch { Line l => l.EndPoint, Arc a => a.EndPoint(), _ => Vector.Invalid };
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
{ {
var isRev = SumSignedAngles(dc, points) < 0; if (prev is Arc)
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev); return new TangentEstimate(GetExitDirection(prev), true);
if (aDev <= Tolerance) if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
return (dc, dr, System.Math.Max(dd, aDev)); return new TangentEstimate(GetExitDirection(prevLine), true);
} }
} }
// Fall back to start-tangent-only, then mirror axis var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent); if (points.Count >= 3)
if (!center.IsValid() || dev > Tolerance) return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
(center, radius, dev) = FitMirrorAxis(points); return new TangentEstimate(chord, false);
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));
} }
private static TangentEstimate EstimateEndTangent(List<Entity> entities, int k, List<Vector> points)
{
if (entities[k] is Arc endArc)
return new TangentEstimate(GetExitDirection(endArc), true);
var lastChordLen = points[^1].DistanceTo(points[^2]);
if (k + 1 < entities.Count)
{
var next = entities[k + 1];
var nextStart = next switch { Line l => l.StartPoint, Arc a => a.StartPoint(), _ => Vector.Invalid };
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
{
if (next is Arc nextArc)
return new TangentEstimate(GetEntryDirection(nextArc), true);
if (next is Line nextLine && nextLine.StartPoint.DistanceTo(nextLine.EndPoint) >= NeighborEdgeFactor * lastChordLen)
return new TangentEstimate(GetExitDirection(nextLine), true);
}
}
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
if (points.Count >= 3)
return new TangentEstimate(EstimateVertexTangent(points[^1], points[^2], points[^3], chord), false);
return new TangentEstimate(chord, false);
}
/// <summary>
/// Estimates the curve tangent at a polyline vertex from the circle through it and
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
/// Falls back to the travel direction when the three points are collinear.
/// </summary>
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
{
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
if (System.Math.Abs(d) < 1e-14)
return travel;
var sqA = at.X * at.X + at.Y * at.Y;
var sqB = b.X * b.X + b.Y * b.Y;
var sqC = c.X * c.X + c.Y * c.Y;
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
var tangent = new Vector(-(at.Y - cy), at.X - cx);
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
tangent = new Vector(-tangent.X, -tangent.Y);
return tangent;
}
/// <summary>
/// Returns the entry direction (tangent at start point) of an entity.
/// </summary>
private static Vector GetEntryDirection(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.StartAngle), -System.Math.Cos(arc.StartAngle))
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
_ => Vector.Invalid,
};
/// <summary> /// <summary>
/// Computes the tangent direction at the last point of a fitted arc, /// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc. /// used to chain tangent continuity to the next arc.
+7
View File
@@ -257,6 +257,13 @@ namespace OpenNest.Geometry
} }
} }
public override Entity Clone()
{
var copy = new Line(pt1, pt2);
CopyBaseTo(copy);
return copy;
}
/// <summary> /// <summary>
/// Reversed the line. /// Reversed the line.
/// </summary> /// </summary>
+7
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>
+11 -2
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>
@@ -605,7 +614,7 @@ namespace OpenNest.Geometry
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer }); copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break; break;
case Circle c: case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer }); copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW });
break; break;
} }
} }
@@ -640,7 +649,7 @@ namespace OpenNest.Geometry
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer }); copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break; break;
case Circle c: case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer }); copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW });
break; break;
} }
} }
+92 -1
View File
@@ -1,12 +1,13 @@
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
public static class ShapeBuilder public static class ShapeBuilder
{ {
public static List<Shape> GetShapes(IEnumerable<Entity> entities) public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null)
{ {
var lines = new List<Line>(); var lines = new List<Line>();
var arcs = new List<Arc>(); var arcs = new List<Arc>();
@@ -57,6 +58,9 @@ namespace OpenNest.Geometry
entityList.AddRange(lines); entityList.AddRange(lines);
entityList.AddRange(arcs); entityList.AddRange(arcs);
if (weldTolerance.HasValue)
WeldEndpoints(entityList, weldTolerance.Value);
while (entityList.Count > 0) while (entityList.Count > 0)
{ {
var next = entityList[0]; var next = entityList[0];
@@ -107,6 +111,93 @@ namespace OpenNest.Geometry
return shapes; return shapes;
} }
public static void WeldEndpoints(List<Entity> entities, double tolerance)
{
var endpointGroups = new List<List<(Entity entity, bool isStart, Vector point)>>();
foreach (var entity in entities)
{
var (start, end) = GetEndpoints(entity);
if (!start.IsValid() || !end.IsValid())
continue;
AddToGroup(endpointGroups, entity, true, start, tolerance);
AddToGroup(endpointGroups, entity, false, end, tolerance);
}
foreach (var group in endpointGroups)
{
if (group.Count <= 1)
continue;
var avgX = group.Average(g => g.point.X);
var avgY = group.Average(g => g.point.Y);
var weldedPoint = new Vector(avgX, avgY);
foreach (var (entity, isStart, _) in group)
ApplyWeld(entity, isStart, weldedPoint);
}
}
private static void AddToGroup(
List<List<(Entity entity, bool isStart, Vector point)>> groups,
Entity entity, bool isStart, Vector point, double tolerance)
{
foreach (var group in groups)
{
if (group[0].point.DistanceTo(point) <= tolerance)
{
group.Add((entity, isStart, point));
return;
}
}
groups.Add(new List<(Entity, bool, Vector)> { (entity, isStart, point) });
}
private static (Vector start, Vector end) GetEndpoints(Entity entity)
{
switch (entity.Type)
{
case EntityType.Arc:
var arc = (Arc)entity;
return (arc.StartPoint(), arc.EndPoint());
case EntityType.Line:
var line = (Line)entity;
return (line.StartPoint, line.EndPoint);
default:
return (Vector.Invalid, Vector.Invalid);
}
}
private static void ApplyWeld(Entity entity, bool isStart, Vector weldedPoint)
{
switch (entity.Type)
{
case EntityType.Line:
var line = (Line)entity;
if (isStart)
line.StartPoint = weldedPoint;
else
line.EndPoint = weldedPoint;
break;
case EntityType.Arc:
var arc = (Arc)entity;
var deltaX = weldedPoint.X - arc.Center.X;
var deltaY = weldedPoint.Y - arc.Center.Y;
var angle = System.Math.Atan2(deltaY, deltaX);
if (isStart)
arc.StartAngle = angle;
else
arc.EndAngle = angle;
break;
}
}
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry) internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
{ {
var tol = Tolerance.ChainTolerance; var tol = Tolerance.ChainTolerance;
+2 -1
View File
@@ -75,7 +75,8 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities) public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
{ {
var profile = new ShapeProfile(entities.ToList()); var cloned = entities.CloneAll();
var profile = new ShapeProfile(cloned);
return profile.ToNormalizedEntities(); return profile.ToNormalizedEntities();
} }
+438 -141
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;
@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace OpenNest
{
public interface IMaterialProvidingPostProcessor
{
IEnumerable<string> GetMaterialNames();
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace OpenNest
{
public interface IPostProcessorNestAware
{
void PrepareForNest(Nest nest);
}
}
@@ -3,7 +3,7 @@ using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace OpenNest.IO.Bom namespace OpenNest.Math
{ {
public static class Fraction public static class Fraction
{ {
+14 -2
View File
@@ -62,10 +62,15 @@ namespace OpenNest
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; } public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint) 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; preLeadInRotation = Rotation;
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters }; var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
var result = strategy.Apply(Program, approachPoint); var result = strategy.Apply(Program, approachPoint, nextPartStart);
Program = result.Program; Program = result.Program;
CuttingParameters = parameters; CuttingParameters = parameters;
HasManualLeadIns = true; HasManualLeadIns = true;
@@ -190,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>
+105 -4
View File
@@ -39,7 +39,105 @@ 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 profile = new ShapeProfile( var profile = new ShapeProfile(
@@ -50,9 +148,12 @@ namespace OpenNest
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing), AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location); chordTolerance, part.Location);
foreach (var cutout in profile.Cutouts) if (!perimeterOnly)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing), {
chordTolerance, part.Location); foreach (var cutout in profile.Cutouts)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location);
}
return lines; return lines;
} }
+60
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;
@@ -548,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>
+7
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>
@@ -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;
+10
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;
@@ -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]));
} }
@@ -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);
} }
} }
+78
View File
@@ -0,0 +1,78 @@
using System.Collections.Generic;
namespace OpenNest.Shapes
{
public static class PipeSizes
{
public readonly record struct Entry(string Label, double OuterDiameter);
public static IReadOnlyList<Entry> All { get; } = new[]
{
new Entry("1/8", 0.405),
new Entry("1/4", 0.540),
new Entry("3/8", 0.675),
new Entry("1/2", 0.840),
new Entry("3/4", 1.050),
new Entry("1", 1.315),
new Entry("1 1/4", 1.660),
new Entry("1 1/2", 1.900),
new Entry("2", 2.375),
new Entry("2 1/2", 2.875),
new Entry("3", 3.500),
new Entry("3 1/2", 4.000),
new Entry("4", 4.500),
new Entry("4 1/2", 5.000),
new Entry("5", 5.563),
new Entry("6", 6.625),
new Entry("7", 7.625),
new Entry("8", 8.625),
new Entry("9", 9.625),
new Entry("10", 10.750),
new Entry("11", 11.750),
new Entry("12", 12.750),
new Entry("14", 14.000),
new Entry("16", 16.000),
new Entry("18", 18.000),
new Entry("20", 20.000),
new Entry("24", 24.000),
new Entry("26", 26.000),
new Entry("28", 28.000),
new Entry("30", 30.000),
new Entry("32", 32.000),
new Entry("34", 34.000),
new Entry("36", 36.000),
new Entry("42", 42.000),
new Entry("48", 48.000),
};
public static bool TryGetOD(string label, out double outerDiameter)
{
foreach (var entry in All)
{
if (entry.Label == label)
{
outerDiameter = entry.OuterDiameter;
return true;
}
}
outerDiameter = 0;
return false;
}
/// <summary>
/// Returns all pipe sizes whose outer diameter is less than or equal to <paramref name="maxOD"/>.
/// The bound is inclusive.
/// </summary>
public static IEnumerable<Entry> GetFittingSizes(double maxOD)
{
foreach (var entry in All)
{
if (entry.OuterDiameter <= maxOD)
{
yield return entry;
}
}
}
}
}
+255
View File
@@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
/// <summary>
/// Catalog of standard mill sheet sizes (inches) with helpers for matching
/// a bounding box to a recommended plate size. Uses the project-wide
/// (Width, Length) convention where Width is the short dimension and
/// Length is the long dimension.
/// </summary>
public static class PlateSizes
{
public readonly record struct Entry(string Label, double Width, double Length)
{
public double Area => Width * Length;
/// <summary>
/// Returns true if a part of the given dimensions fits within this entry
/// in either orientation.
/// </summary>
public bool Fits(double width, double length) =>
(width <= Width && length <= Length) || (width <= Length && length <= Width);
}
/// <summary>
/// Standard mill sheet sizes (inches), sorted by area ascending.
/// Canonical orientation: Width &lt;= Length.
/// </summary>
public static IReadOnlyList<Entry> All { get; } = new[]
{
new Entry("48x96", 48, 96), // 4608
new Entry("48x120", 48, 120), // 5760
new Entry("48x144", 48, 144), // 6912
new Entry("60x120", 60, 120), // 7200
new Entry("60x144", 60, 144), // 8640
new Entry("72x120", 72, 120), // 8640
new Entry("72x144", 72, 144), // 10368
new Entry("96x240", 96, 240), // 23040
};
/// <summary>
/// Looks up a standard size by label. Case-insensitive.
/// </summary>
public static bool TryGet(string label, out Entry entry)
{
if (!string.IsNullOrWhiteSpace(label))
{
foreach (var candidate in All)
{
if (string.Equals(candidate.Label, label, StringComparison.OrdinalIgnoreCase))
{
entry = candidate;
return true;
}
}
}
entry = default;
return false;
}
/// <summary>
/// Recommends a plate size for the given bounding box. The box's
/// spatial axes are normalized to (short, long) so neither the bbox
/// orientation nor Box's internal Length/Width naming matters.
/// </summary>
public static PlateSizeResult Recommend(Box bbox, PlateSizeOptions options = null)
{
var a = bbox.Width;
var b = bbox.Length;
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
}
/// <summary>
/// Recommends a plate size for the envelope of the given boxes.
/// </summary>
public static PlateSizeResult Recommend(IEnumerable<Box> boxes, PlateSizeOptions options = null)
{
if (boxes == null)
throw new ArgumentNullException(nameof(boxes));
var hasAny = false;
var minX = double.PositiveInfinity;
var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity;
var maxY = double.NegativeInfinity;
foreach (var box in boxes)
{
hasAny = true;
if (box.Left < minX) minX = box.Left;
if (box.Bottom < minY) minY = box.Bottom;
if (box.Right > maxX) maxX = box.Right;
if (box.Top > maxY) maxY = box.Top;
}
if (!hasAny)
throw new ArgumentException("At least one box is required.", nameof(boxes));
var b = maxX - minX;
var a = maxY - minY;
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
}
/// <summary>
/// Recommends a plate size for a (width, length) pair.
/// Inputs are treated as orientation-independent.
/// </summary>
public static PlateSizeResult Recommend(double width, double length, PlateSizeOptions options = null)
{
options ??= new PlateSizeOptions();
var w = width + 2 * options.Margin;
var l = length + 2 * options.Margin;
// Canonicalize (short, long) — Fits handles rotation anyway, but
// normalizing lets the below-min comparison use the narrower
// MinSheet dimensions consistently.
if (w > l)
(w, l) = (l, w);
// Below full-sheet threshold: snap each dimension up to the nearest increment.
if (w <= options.MinSheetWidth && l <= options.MinSheetLength)
return SnapResult(w, l, options.SnapIncrement);
var catalog = BuildCatalog(options.AllowedSizes);
var best = PickBest(catalog, w, l, options.Selection);
if (best.HasValue)
return new PlateSizeResult(best.Value.Width, best.Value.Length, best.Value.Label);
// Nothing in the catalog fits - fall back to snap-up (ad-hoc oversize sheet).
return SnapResult(w, l, options.SnapIncrement);
}
private static PlateSizeResult SnapResult(double width, double length, double increment)
{
if (increment <= 0)
return new PlateSizeResult(width, length, null);
return new PlateSizeResult(SnapUp(width, increment), SnapUp(length, increment), null);
}
private static double SnapUp(double value, double increment)
{
var steps = System.Math.Ceiling(value / increment);
return steps * increment;
}
private static IReadOnlyList<Entry> BuildCatalog(IReadOnlyList<string> allowedSizes)
{
if (allowedSizes == null || allowedSizes.Count == 0)
return All;
var result = new List<Entry>(allowedSizes.Count);
foreach (var label in allowedSizes)
{
if (TryParseEntry(label, out var entry))
result.Add(entry);
}
return result;
}
private static bool TryParseEntry(string label, out Entry entry)
{
if (TryGet(label, out entry))
return true;
// Accept ad-hoc "WxL" strings (e.g. "50x100", "50 x 100").
if (!string.IsNullOrWhiteSpace(label))
{
var parts = label.Split(new[] { 'x', 'X' }, 2);
if (parts.Length == 2
&& double.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var a)
&& double.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var b)
&& a > 0 && b > 0)
{
var width = System.Math.Min(a, b);
var length = System.Math.Max(a, b);
entry = new Entry(label.Trim(), width, length);
return true;
}
}
entry = default;
return false;
}
private static Entry? PickBest(IReadOnlyList<Entry> catalog, double width, double length, PlateSizeSelection selection)
{
var fitting = catalog.Where(e => e.Fits(width, length));
fitting = selection switch
{
PlateSizeSelection.NarrowestFirst => fitting.OrderBy(e => e.Width).ThenBy(e => e.Area),
_ => fitting.OrderBy(e => e.Area).ThenBy(e => e.Width),
};
foreach (var candidate in fitting)
return candidate;
return null;
}
}
public readonly record struct PlateSizeResult(double Width, double Length, string MatchedLabel)
{
public bool IsStandard => MatchedLabel != null;
}
public sealed class PlateSizeOptions
{
/// <summary>
/// If the margin-adjusted bounding box fits within MinSheetWidth x MinSheetLength
/// the result is snapped to <see cref="SnapIncrement"/> instead of routed to a
/// standard sheet. Default 48" x 48".
/// </summary>
public double MinSheetWidth { get; set; } = 48;
public double MinSheetLength { get; set; } = 48;
/// <summary>
/// Increment used for below-threshold rounding and oversize fallback. Default 1".
/// </summary>
public double SnapIncrement { get; set; } = 1.0;
/// <summary>
/// Extra clearance added to each side of the bounding box before matching.
/// </summary>
public double Margin { get; set; } = 0;
/// <summary>
/// Optional whitelist. When non-empty, only these sizes are considered.
/// Entries may be standard catalog labels (e.g. "48x96") or arbitrary
/// "WxL" strings (e.g. "50x100").
/// </summary>
public IReadOnlyList<string> AllowedSizes { get; set; }
/// <summary>
/// Tiebreaker when multiple sheets can contain the bounding box.
/// </summary>
public PlateSizeSelection Selection { get; set; } = PlateSizeSelection.SmallestArea;
}
public enum PlateSizeSelection
{
/// <summary>Pick the cheapest sheet that contains the bbox (smallest area).</summary>
SmallestArea,
/// <summary>Prefer narrower-width sheets (e.g. 48-wide before 60-wide).</summary>
NarrowestFirst,
}
}
+8
View File
@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
public double Length { get; set; } public double Length { get; set; }
public double Width { get; set; } public double Width { 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>
@@ -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>
+8
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>
@@ -10,6 +10,15 @@ namespace OpenNest.Shapes
public double Width { get; set; } public double Width { 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;
+12
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);
+10
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;
+9
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;
+342 -188
View File
@@ -32,12 +32,20 @@ public static class DrawingSplitter
var regions = BuildClipRegions(sortedLines, bounds); var regions = BuildClipRegions(sortedLines, bounds);
var feature = GetFeature(parameters.Type); 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 results = new List<Drawing>();
var pieceIndex = 1; var pieceIndex = 1;
foreach (var region in regions) foreach (var region in regions)
{ {
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters); var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons);
if (pieceEntities.Count == 0) if (pieceEntities.Count == 0)
continue; continue;
@@ -47,9 +55,16 @@ public static class DrawingSplitter
allEntities.AddRange(pieceEntities); allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities); allEntities.AddRange(cutoutEntities);
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region); // A single region may yield multiple physically-disjoint pieces when an
results.Add(piece); // interior cutout spans across it. Group the region's entities into
pieceIndex++; // 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; return results;
@@ -218,100 +233,108 @@ public static class DrawingSplitter
/// and stitching in feature edges. No polygon clipping library needed. /// and stitching in feature edges. No polygon clipping library needed.
/// </summary> /// </summary>
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region, private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters) List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
List<Polygon> cutoutPolygons)
{ {
var boundarySplitLines = GetBoundarySplitLines(region, splitLines); var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>(); var entities = new List<Entity>();
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
foreach (var entity in perimeter.Entities) foreach (var entity in perimeter.Entities)
{ ProcessEntity(entity, region, entities);
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
}
if (entities.Count == 0) if (entities.Count == 0)
return new List<Entity>(); return new List<Entity>();
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters); InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
EnsurePerimeterWinding(entities); // Winding is handled later in AssemblePieces, once connected components
// are known. At this stage the piece may still be multiple disjoint loops.
return entities; return entities;
} }
private static void ProcessEntity(Entity entity, Box region, private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
List<SplitLine> boundarySplitLines, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
{
// Find the first boundary split line this entity crosses
SplitLine crossedLine = null;
Vector? intersectionPt = null;
foreach (var sl in boundarySplitLines)
{
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
{
var pt = SplitLineIntersect.FindIntersection(entity, sl);
if (pt != null)
{
crossedLine = sl;
intersectionPt = pt;
break;
}
}
}
if (crossedLine != null)
{
// Entity crosses a split line — split it and keep the half inside the region
var regionSide = RegionSideOf(region, crossedLine);
var startPt = GetStartPoint(entity);
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
var startInRegion = startSide == regionSide || startSide == 0;
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
}
else
{
// Entity doesn't cross any boundary split line — check if it's inside the region
var mid = MidPoint(entity);
if (region.Contains(mid))
entities.Add(entity);
}
}
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
SplitLine crossedLine, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
{ {
if (entity is Line line) if (entity is Line line)
{ {
var (first, second) = line.SplitAt(point); var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
if (startInRegion) if (clipped == null) return;
{ if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
if (first != null) entities.Add(first); entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
splitPoints.Add((point, crossedLine, true)); return;
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
} }
else if (entity is Arc arc)
if (entity is Arc arc)
{ {
var (first, second) = arc.SplitAt(point); foreach (var sub in ClipArcToRegion(arc, region))
if (startInRegion) entities.Add(sub);
{ return;
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
} }
} }
/// <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> /// <summary>
/// Returns split lines whose position matches a boundary edge of the region. /// Returns split lines whose position matches a boundary edge of the region.
/// </summary> /// </summary>
@@ -365,104 +388,157 @@ public static class DrawingSplitter
} }
/// <summary> /// <summary>
/// Groups split points by split line, pairs exits with entries, and generates feature edges. /// For each boundary split line of the region, generates a feature edge that
/// spans the full region boundary along that split line and trims it against
/// interior cutouts. This produces one (or zero) feature edge per contiguous
/// material interval on the boundary, handling corner regions (one perimeter
/// crossing), spanning cutouts (two holes puncturing the line), and
/// normal mid-part splits uniformly.
/// </summary> /// </summary>
private static void InsertFeatureEdges(List<Entity> entities, private static void InsertFeatureEdges(List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
Box region, List<SplitLine> boundarySplitLines, Box region, List<SplitLine> boundarySplitLines,
ISplitFeature feature, SplitParameters parameters) ISplitFeature feature, SplitParameters parameters,
List<Polygon> cutoutPolygons)
{ {
// Group split points by their split line foreach (var sl in boundarySplitLines)
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
foreach (var sp in splitPoints)
{ {
if (!groups.ContainsKey(sp.Line)) var isVertical = sl.Axis == CutOffAxis.Vertical;
groups[sp.Line] = new List<(Vector, bool)>(); var extentStart = isVertical ? region.Bottom : region.Left;
groups[sp.Line].Add((sp.Point, sp.IsExit)); var extentEnd = isVertical ? region.Top : region.Right;
}
foreach (var kvp in groups) if (extentEnd - extentStart < Math.Tolerance.Epsilon)
{
var sl = kvp.Key;
var points = kvp.Value;
// Pair each exit with the next entry
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
if (exits.Count == 0 || entries.Count == 0)
continue; continue;
// For each exit, find the matching entry to form the feature edge span var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
// Sort exits and entries by their position along the split line var isNegativeSide = RegionSideOf(region, sl) < 0;
var isVertical = sl.Axis == CutOffAxis.Vertical; var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
// Pair them up: each exit with the next entry (or vice versa) // Trim any line segments that cross a cutout — cut lines must never
var pairCount = System.Math.Min(exits.Count, entries.Count); // travel through a hole.
for (var i = 0; i < pairCount; i++) featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
entities.AddRange(featureEdge);
}
}
/// <summary>
/// Subtracts any portions of line entities in <paramref name="featureEdge"/> that
/// lie inside any of the supplied cutout polygons. Non-line entities (arcs) are
/// passed through unchanged; a tighter fix for arcs in feature edges (weld-gap
/// tabs, spike-groove) can be added later if a test demands it.
/// </summary>
private static List<Entity> TrimFeatureEdgeAgainstCutouts(List<Entity> featureEdge, List<Polygon> cutoutPolygons)
{
if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
return featureEdge;
var result = new List<Entity>();
foreach (var entity in featureEdge)
{
if (entity is Line line)
result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons));
else
result.Add(entity);
}
return result;
}
/// <summary>
/// Returns the sub-segments of <paramref name="line"/> that lie outside every
/// cutout polygon. Handles the common axis-aligned feature-edge case exactly.
/// </summary>
private static List<Line> SubtractCutoutsFromLine(Line line, List<Polygon> cutoutPolygons)
{
// Collect parameter values t in [0,1] where the line crosses any cutout edge.
var ts = new List<double> { 0.0, 1.0 };
foreach (var poly in cutoutPolygons)
{
var polyLines = poly.ToLines();
foreach (var edge in polyLines)
{ {
var exitPt = exits[i]; if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
var entryPt = entries[i]; {
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
var extentStart = isVertical ts.Add(t);
? System.Math.Min(exitPt.Y, entryPt.Y) }
: System.Math.Min(exitPt.X, entryPt.X);
var extentEnd = isVertical
? System.Math.Max(exitPt.Y, entryPt.Y)
: System.Math.Max(exitPt.X, entryPt.X);
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
if (featureEdge.Count > 0)
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
entities.AddRange(featureEdge);
} }
} }
}
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis) ts.Sort();
{
var featureStart = GetStartPoint(featureEdge[0]);
var featureEnd = GetEndPoint(featureEdge[^1]);
var isVertical = axis == CutOffAxis.Vertical;
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X; var segments = new List<Line>();
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X; for (var i = 0; i < ts.Count - 1; i++)
if (edgeGoesForward != featureGoesForward)
{ {
featureEdge = new List<Entity>(featureEdge); var t0 = ts[i];
featureEdge.Reverse(); var t1 = ts[i + 1];
foreach (var e in featureEdge) if (t1 - t0 < Math.Tolerance.Epsilon) continue;
e.Reverse();
var tMid = (t0 + t1) * 0.5;
var mid = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid);
var insideCutout = false;
foreach (var poly in cutoutPolygons)
{
if (poly.ContainsPoint(mid))
{
insideCutout = true;
break;
}
}
if (insideCutout) continue;
var p0 = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0);
var p1 = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1);
segments.Add(new Line(p0, p1));
} }
return featureEdge; return segments;
} }
private static void EnsurePerimeterWinding(List<Entity> entities) /// <summary>
/// Segment-segment intersection. On hit, returns the parameter t along segment AB
/// (0 = a0, 1 = a1) via <paramref name="tOnA"/>.
/// </summary>
private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA)
{ {
var shape = new Shape(); tOnA = 0;
shape.Entities.AddRange(entities); var rx = a1.X - a0.X;
var poly = shape.ToPolygon(); var ry = a1.Y - a0.Y;
if (poly != null && poly.RotationDirection() != RotationType.CW) var sx = b1.X - b0.X;
shape.Reverse(); var sy = b1.Y - b0.Y;
entities.Clear(); var denom = rx * sy - ry * sx;
entities.AddRange(shape.Entities); 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) private static bool IsCutoutInRegion(Shape cutout, Box region)
{ {
if (cutout.Entities.Count == 0) return false; if (cutout.Entities.Count == 0) return false;
var pt = GetStartPoint(cutout.Entities[0]); var bb = cutout.BoundingBox;
return region.Contains(pt); // 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) private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
@@ -479,57 +555,135 @@ public static class DrawingSplitter
} }
/// <summary> /// <summary>
/// Clip a cutout shape to a region by walking entities, splitting at split line /// Clip a cutout shape to a region by walking entities and splitting at split-line
/// intersections, keeping portions inside the region, and closing gaps with /// crossings. Only returns the cutout-edge fragments that lie inside the region —
/// straight lines. No polygon clipping library needed. /// 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> /// </summary>
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines) private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
{ {
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>(); var entities = new List<Entity>();
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
foreach (var entity in cutout.Entities) foreach (var entity in cutout.Entities)
ProcessEntity(entity, region, entities);
return entities;
}
/// <summary>
/// Groups a region's entities into closed components and nests holes inside
/// outer loops by point-in-polygon containment. Returns one entity list per
/// output <see cref="Drawing"/> — outer loop first, then its contained holes.
/// Each outer loop is normalized to CW winding and each hole to CCW.
/// </summary>
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
{
var pieces = new List<List<Entity>>();
if (entities.Count == 0) return pieces;
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0) return pieces;
// Polygonize every shape once so we can run containment tests.
var polygons = new List<Polygon>(shapes.Count);
foreach (var s in shapes)
polygons.Add(s.ToPolygon());
// Classify each shape as outer or hole using nesting by containment.
// Shape A is contained in shape B iff A's bounding box is strictly inside
// B's bounding box AND a representative vertex of A lies inside B's polygon.
// The bbox pre-check avoids the ambiguity of bbox-center tests when two
// shapes share a center (e.g., an outer half and a centered cutout).
var isHole = new bool[shapes.Count];
for (var i = 0; i < shapes.Count; i++)
{ {
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints); var bbA = shapes[i].BoundingBox;
var repA = FirstVertexOf(shapes[i]);
for (var j = 0; j < shapes.Count; j++)
{
if (i == j) continue;
if (polygons[j] == null) continue;
if (polygons[j].Vertices.Count < 3) continue;
var bbB = shapes[j].BoundingBox;
if (!BoxContainsBox(bbB, bbA)) continue;
if (!polygons[j].ContainsPoint(repA)) continue;
isHole[i] = true;
break;
}
} }
if (entities.Count == 0) // For each outer, attach the holes that fall inside it.
return new List<Entity>(); for (var i = 0; i < shapes.Count; i++)
// Close gaps with straight lines (connect exit→entry pairs)
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
foreach (var sp in splitPoints)
{ {
if (!groups.ContainsKey(sp.Line)) if (isHole[i]) continue;
groups[sp.Line] = new List<(Vector, bool)>();
groups[sp.Line].Add((sp.Point, sp.IsExit)); var outer = shapes[i];
var outerPoly = polygons[i];
// Enforce perimeter winding = CW.
if (outerPoly != null && outerPoly.Vertices.Count >= 3
&& outerPoly.RotationDirection() != RotationType.CW)
outer.Reverse();
var piece = new List<Entity>();
piece.AddRange(outer.Entities);
for (var j = 0; j < shapes.Count; j++)
{
if (!isHole[j]) continue;
if (polygons[i] == null || polygons[i].Vertices.Count < 3) continue;
var bbJ = shapes[j].BoundingBox;
if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) continue;
var rep = FirstVertexOf(shapes[j]);
if (!polygons[i].ContainsPoint(rep)) continue;
var hole = shapes[j];
var holePoly = polygons[j];
if (holePoly != null && holePoly.Vertices.Count >= 3
&& holePoly.RotationDirection() != RotationType.CCW)
hole.Reverse();
piece.AddRange(hole.Entities);
}
pieces.Add(piece);
} }
foreach (var kvp in groups) return pieces;
{ }
var sl = kvp.Key;
var points = kvp.Value;
var isVertical = sl.Axis == CutOffAxis.Vertical;
var exits = points.Where(p => p.IsExit).Select(p => p.Point) /// <summary>
.OrderBy(p => isVertical ? p.Y : p.X).ToList(); /// Returns the first vertex of a shape (start point of its first entity). Used as
var entries = points.Where(p => !p.IsExit).Select(p => p.Point) /// a representative for containment testing: if bbox pre-check says the whole
.OrderBy(p => isVertical ? p.Y : p.X).ToList(); /// shape is inside another, testing one vertex is sufficient to confirm.
/// </summary>
private static Vector FirstVertexOf(Shape shape)
{
if (shape.Entities.Count == 0)
return new Vector(0, 0);
return GetStartPoint(shape.Entities[0]);
}
var pairCount = System.Math.Min(exits.Count, entries.Count); /// <summary>
for (var i = 0; i < pairCount; i++) /// True iff box <paramref name="inner"/> is entirely inside box
entities.Add(new Line(exits[i], entries[i])); /// <paramref name="outer"/> (tolerant comparison).
} /// </summary>
private static bool BoxContainsBox(Box outer, Box inner)
// Ensure CCW winding for cutouts {
var shape = new Shape(); var eps = Math.Tolerance.Epsilon;
shape.Entities.AddRange(entities); return inner.Left >= outer.Left - eps
var poly = shape.ToPolygon(); && inner.Right <= outer.Right + eps
if (poly != null && poly.RotationDirection() != RotationType.CCW) && inner.Bottom >= outer.Bottom - eps
shape.Reverse(); && inner.Top <= outer.Top + eps;
return shape.Entities;
} }
private static Vector GetStartPoint(Entity entity) private static Vector GetStartPoint(Entity entity)
+10 -4
View File
@@ -24,6 +24,9 @@ namespace OpenNest.Engine.BestFit
if (_cache.TryGetValue(key, out var cached)) if (_cache.TryGetValue(key, out var cached))
return cached; return cached;
// Operate on the canonical frame so cached pair positions are orientation-invariant.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
IPairEvaluator evaluator = null; IPairEvaluator evaluator = null;
ISlideComputer slideComputer = null; ISlideComputer slideComputer = null;
@@ -31,7 +34,7 @@ namespace OpenNest.Engine.BestFit
{ {
if (CreateEvaluator != null) if (CreateEvaluator != null)
{ {
try { evaluator = CreateEvaluator(drawing, spacing); } try { evaluator = CreateEvaluator(canonical, spacing); }
catch { /* fall back to default evaluator */ } catch { /* fall back to default evaluator */ }
} }
@@ -42,7 +45,7 @@ namespace OpenNest.Engine.BestFit
} }
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer); var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
var results = finder.FindBestFits(drawing, spacing, StepSize); var results = finder.FindBestFits(canonical, spacing, StepSize);
_cache.TryAdd(key, results); _cache.TryAdd(key, results);
return results; return results;
@@ -86,9 +89,12 @@ namespace OpenNest.Engine.BestFit
try try
{ {
// Operate on the canonical frame so cached pair positions are orientation-invariant.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
if (CreateEvaluator != null) if (CreateEvaluator != null)
{ {
try { evaluator = CreateEvaluator(drawing, spacing); } try { evaluator = CreateEvaluator(canonical, spacing); }
catch { /* fall back to default evaluator */ } catch { /* fall back to default evaluator */ }
} }
@@ -100,7 +106,7 @@ namespace OpenNest.Engine.BestFit
// Compute candidates and evaluate once with the largest plate. // Compute candidates and evaluate once with the largest plate.
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer); var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
var baseResults = finder.FindBestFits(drawing, spacing, StepSize); var baseResults = finder.FindBestFits(canonical, spacing, StepSize);
// Cache a filtered copy for each plate size. // Cache a filtered copy for each plate size.
foreach (var size in needed) foreach (var size in needed)
+2 -1
View File
@@ -17,7 +17,8 @@ namespace OpenNest.Engine.BestFit
if (!result.Keep) if (!result.Keep)
continue; continue;
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight)) if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight) ||
result.LongestSide > System.Math.Max(MaxPlateWidth, MaxPlateHeight))
{ {
result.Keep = false; result.Keep = false;
result.Reason = "Exceeds plate dimensions"; result.Reason = "Exceeds plate dimensions";
+3
View File
@@ -4,6 +4,7 @@ using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -49,6 +50,8 @@ namespace OpenNest.Engine.BestFit
var allCandidates = candidateBags.SelectMany(c => c).ToList(); var allCandidates = candidateBags.SelectMany(c => c).ToList();
Debug.WriteLine($"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates");
var results = _evaluator.EvaluateAll(allCandidates); var results = _evaluator.EvaluateAll(allCandidates);
_filter.Apply(results); _filter.Apply(results);
+65
View File
@@ -1,6 +1,9 @@
using OpenNest.Engine;
using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.BestFit namespace OpenNest.Engine.BestFit
{ {
@@ -54,6 +57,68 @@ namespace OpenNest.Engine.BestFit
return new List<Part> { part1, part2 }; return new List<Part> { part1, part2 };
} }
public List<Part> BuildCanonicalParts()
{
return NormalizeToCutOrigin(BuildParts(Candidate.Drawing));
}
public List<Part> BuildSourceParts(Drawing drawing)
{
var parts = BuildCanonicalParts();
var sourceAngle = drawing?.Source?.Angle ?? 0.0;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(drawing, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle));
}
public Box GetCutBounds(List<Part> parts)
{
return GetCutBoundingBox(parts);
}
private static List<Part> NormalizeToCutOrigin(List<Part> parts)
{
if (parts == null || parts.Count == 0)
return parts;
var bounds = GetCutBoundingBox(parts);
var offset = new Vector(-bounds.Left, -bounds.Bottom);
foreach (var part in parts)
part.Offset(offset);
return parts;
}
private static Box GetCutBoundingBox(List<Part> parts)
{
var entities = new List<IBoundable>();
foreach (var part in parts)
{
var partEntities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
foreach (var entity in partEntities)
{
entity.Offset(part.Location);
entities.Add(entity);
}
}
return entities.GetBoundingBox();
}
} }
public enum BestFitSortField public enum BestFitSortField
+248 -8
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -17,7 +18,6 @@ namespace OpenNest.Engine.BestFit
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines); var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
var allStationaryVerts = ExtractUniqueVertices(stationaryLines); var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>(); var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets) foreach (var offset in offsets)
@@ -43,7 +43,6 @@ namespace OpenNest.Engine.BestFit
var minDist = double.MaxValue; var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary edges
for (var v = 0; v < leadingMoving.Length; v++) for (var v = 0; v < leadingMoving.Length; v++)
{ {
var vx = leadingMoving[v].X + offset.Dx; var vx = leadingMoving[v].X + offset.Dx;
@@ -66,7 +65,6 @@ namespace OpenNest.Engine.BestFit
} }
} }
// Case 2: Facing stationary vertices → moving edges (opposite direction)
for (var v = 0; v < facingStationary.Length; v++) for (var v = 0; v < facingStationary.Length; v++)
{ {
var svx = facingStationary[v].X; var svx = facingStationary[v].X;
@@ -95,6 +93,253 @@ namespace OpenNest.Engine.BestFit
return results; return results;
} }
public double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets)
{
var count = offsets.Length;
var results = new double[count];
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
var movingCurves = ExtractCurveParams(movingEntities);
var stationaryCurves = ExtractCurveParams(stationaryEntities);
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var offset = offsets[i];
var dirX = offset.DirX;
var dirY = offset.DirY;
var oppX = -dirX;
var oppY = -dirY;
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary entities
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
var vy = leadingMoving[v].Y + offset.Dy;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], 0, 0, dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Case 2: Facing stationary vertices → moving entities (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
var svy = facingStationary[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(svx, svy, movingEntities[j], offset.Dx, offset.Dy, oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Phase 3: Curve-to-curve direct distance.
// Vertex sampling misses the true contact between two curved entities
// when the approach angle doesn't align with a sampled vertex.
for (var m = 0; m < movingCurves.Length; m++)
{
var mc = movingCurves[m];
var mcx = mc.Cx + offset.Dx;
var mcy = mc.Cy + offset.Dy;
for (var s = 0; s < stationaryCurves.Length; s++)
{
var sc = stationaryCurves[s];
var d = SpatialQuery.RayCircleDistance(
mcx, mcy, sc.Cx, sc.Cy, mc.Radius + sc.Radius, dirX, dirY);
if (d >= minDist || d == double.MaxValue)
continue;
if (mc.Entity is Arc || sc.Entity is Arc)
{
var mx = mcx + d * dirX;
var my = mcy + d * dirY;
var toCx = sc.Cx - mx;
var toCy = sc.Cy - my;
if (mc.Entity is Arc mArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
continue;
}
if (sc.Entity is Arc sArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
continue;
}
}
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
results[i] = minDist;
});
return results;
}
private readonly struct CurveParams
{
public readonly Entity Entity;
public readonly double Cx, Cy, Radius;
public CurveParams(Entity entity, double cx, double cy, double radius)
{
Entity = entity;
Cx = cx;
Cy = cy;
Radius = radius;
}
}
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
{
var curves = new List<CurveParams>();
for (var i = 0; i < entities.Count; i++)
{
if (entities[i] is Circle circle)
curves.Add(new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius));
else if (entities[i] is Arc arc)
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
}
return curves.ToArray();
}
private static double RayEntityDistance(
double vx, double vy, Entity entity,
double entityOffsetX, double entityOffsetY,
double dirX, double dirY)
{
if (entity is Line line)
{
return SpatialQuery.RayEdgeDistance(
vx, vy,
line.StartPoint.X + entityOffsetX, line.StartPoint.Y + entityOffsetY,
line.EndPoint.X + entityOffsetX, line.EndPoint.Y + entityOffsetY,
dirX, dirY);
}
if (entity is Arc arc)
{
return SpatialQuery.RayArcDistance(
vx, vy,
arc.Center.X + entityOffsetX, arc.Center.Y + entityOffsetY,
arc.Radius,
arc.StartAngle, arc.EndAngle, arc.IsReversed,
dirX, dirY);
}
if (entity is Circle circle)
{
return SpatialQuery.RayCircleDistance(
vx, vy,
circle.Center.X + entityOffsetX, circle.Center.Y + entityOffsetY,
circle.Radius,
dirX, dirY);
}
return double.MaxValue;
}
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity is Line line)
{
vertices.Add(line.StartPoint);
vertices.Add(line.EndPoint);
}
else if (entity is Arc arc)
{
vertices.Add(arc.StartPoint());
vertices.Add(arc.EndPoint());
AddArcExtremes(vertices, arc);
}
else if (entity is Circle circle)
{
// Four cardinal points
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
}
}
return vertices.ToArray();
}
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
var reversed = arc.IsReversed;
if (reversed)
Generic.Swap(ref a1, ref a2);
// Right (0°)
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
// Top (90°)
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
// Left (180°)
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
// Bottom (270°)
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
private static Vector[] ExtractUniqueVertices(List<Line> lines) private static Vector[] ExtractUniqueVertices(List<Line> lines)
{ {
var vertices = new HashSet<Vector>(); var vertices = new HashSet<Vector>();
@@ -106,11 +351,6 @@ namespace OpenNest.Engine.BestFit
return vertices.ToArray(); return vertices.ToArray();
} }
/// <summary>
/// Filters vertices by their projection onto the push direction.
/// keepHigh=true returns the leading half (front face, closest to target).
/// keepHigh=false returns the facing half (side facing the approaching part).
/// </summary>
private static Vector[] FilterVerticesByProjection( private static Vector[] FilterVerticesByProjection(
Vector[] vertices, double dirX, double dirY, bool keepHigh) Vector[] vertices, double dirX, double dirY, bool keepHigh)
{ {
@@ -36,6 +36,16 @@ namespace OpenNest.Engine.BestFit
flatOffsets, count, directions); flatOffsets, count, directions);
} }
public double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets)
{
// GPU path doesn't support native entities yet — fall back to CPU.
var cpu = new CpuDistanceComputer();
return cpu.ComputeDistances(stationaryEntities, movingEntities, offsets);
}
/// <summary> /// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface. /// Maps a unit direction vector to a PushDirection int for the GPU interface.
/// Left=0, Down=1, Right=2, Up=3. /// Left=0, Down=1, Right=2, Up=3.
@@ -9,5 +9,10 @@ namespace OpenNest.Engine.BestFit
List<Line> stationaryLines, List<Line> stationaryLines,
List<Line> movingTemplateLines, List<Line> movingTemplateLines,
SlideOffset[] offsets); SlideOffset[] offsets);
double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets);
} }
} }
@@ -1,18 +1,10 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
namespace OpenNest.Engine.BestFit namespace OpenNest.Engine.BestFit
{ {
public class NfpSlideStrategy : IBestFitStrategy public class NfpSlideStrategy : IBestFitStrategy
{ {
private static readonly string LogPath = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
"nfp-slide-debug.log");
private static readonly object LogLock = new object();
private readonly double _part2Rotation; private readonly double _part2Rotation;
private readonly Polygon _stationaryPerimeter; private readonly Polygon _stationaryPerimeter;
private readonly Polygon _stationaryHull; private readonly Polygon _stationaryHull;
@@ -46,12 +38,6 @@ namespace OpenNest.Engine.BestFit
var hull = ConvexHull.Compute(result.Polygon.Vertices); var hull = ConvexHull.Compute(result.Polygon.Vertices);
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
return new NfpSlideStrategy(part2Rotation, type, description, return new NfpSlideStrategy(part2Rotation, type, description,
result.Polygon, hull, result.Correction); result.Polygon, hull, result.Correction);
} }
@@ -63,40 +49,17 @@ namespace OpenNest.Engine.BestFit
if (stepSize <= 0) if (stepSize <= 0)
return candidates; return candidates;
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
// Orbiting polygon: same shape rotated to Part2's angle.
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true); var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices); var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly); var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
if (nfp == null || nfp.Vertices.Count < 3) if (nfp == null || nfp.Vertices.Count < 3)
{
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
return candidates; return candidates;
}
var verts = nfp.Vertices; var verts = nfp.Vertices;
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count; var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
// Log NFP vertices
for (var v = 0; v < vertCount; v++)
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
// Compare with what RotationSlideStrategy would produce
var part1 = Part.CreateAtOrigin(drawing);
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
var testNumber = 0; var testNumber = 0;
for (var i = 0; i < vertCount; i++) for (var i = 0; i < vertCount; i++)
@@ -125,20 +88,6 @@ namespace OpenNest.Engine.BestFit
} }
} }
// Log overlap check for vertex candidates (first few)
var checkCount = System.Math.Min(vertCount, 8);
for (var c = 0; c < checkCount; c++)
{
var cand = candidates[c];
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
p2.Location = cand.Part2Offset;
var overlaps = part1.Intersects(p2, out _);
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
}
Log($" Total candidates: {candidates.Count}");
Log("");
return candidates; return candidates;
} }
@@ -160,20 +109,5 @@ namespace OpenNest.Engine.BestFit
Spacing = spacing Spacing = spacing
}; };
} }
private static string FormatBounds(Polygon polygon)
{
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
}
private static void Log(string message)
{
lock (LogLock)
{
File.AppendAllText(LogPath, message + "\n");
}
}
} }
} }
+35 -33
View File
@@ -15,11 +15,18 @@ namespace OpenNest.Engine.BestFit
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates) public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{ {
if (candidates.Count == 0)
return new List<BestFitResult>();
// Build a perimeter-only drawing once — all candidates share the same drawing.
// This avoids cloning the full program (with all cutouts) for every candidate.
var perimeterDrawing = CreatePerimeterDrawing(candidates[0].Drawing);
var resultBag = new ConcurrentBag<BestFitResult>(); var resultBag = new ConcurrentBag<BestFitResult>();
Parallel.ForEach(candidates, c => Parallel.ForEach(candidates, c =>
{ {
resultBag.Add(Evaluate(c)); resultBag.Add(Evaluate(c, perimeterDrawing));
}); });
return resultBag.ToList(); return resultBag.ToList();
@@ -27,18 +34,24 @@ namespace OpenNest.Engine.BestFit
public BestFitResult Evaluate(PairCandidate candidate) public BestFitResult Evaluate(PairCandidate candidate)
{ {
var drawing = candidate.Drawing; var perimeterDrawing = CreatePerimeterDrawing(candidate.Drawing);
return Evaluate(candidate, perimeterDrawing);
}
var part1 = Part.CreateAtOrigin(drawing); private BestFitResult Evaluate(PairCandidate candidate, Drawing perimeterDrawing)
{
var part1 = Part.CreateAtOrigin(perimeterDrawing);
var part2 = Part.CreateAtOrigin(drawing, candidate.Part2Rotation); var part2 = Part.CreateAtOrigin(perimeterDrawing, candidate.Part2Rotation);
part2.Location = candidate.Part2Offset; part2.Location = candidate.Part2Offset;
part2.UpdateBounds(); part2.UpdateBounds();
// Check overlap via shape intersection // Overlap check — perimeter vs perimeter
var overlaps = CheckOverlap(part1, part2); var shape1 = GetPerimeterShape(part1);
var shape2 = GetPerimeterShape(part2);
var overlaps = shape1 != null && shape2 != null && shape1.Intersects(shape2, out _);
// Collect all polygon vertices for convex hull / optimal rotation // Convex hull vertices from perimeter polygons only
var allPoints = GetPartVertices(part1); var allPoints = GetPartVertices(part1);
allPoints.AddRange(GetPartVertices(part2)); allPoints.AddRange(GetPartVertices(part2));
@@ -66,7 +79,7 @@ namespace OpenNest.Engine.BestFit
hullAngles = new List<double> { 0 }; hullAngles = new List<double> { 0 };
} }
var trueArea = drawing.Area * 2; var trueArea = candidate.Drawing.Area * 2;
// Normalize to landscape (width >= height) for consistent display. // Normalize to landscape (width >= height) for consistent display.
if (bestHeight > bestWidth) if (bestHeight > bestWidth)
@@ -91,38 +104,29 @@ namespace OpenNest.Engine.BestFit
}; };
} }
private bool CheckOverlap(Part part1, Part part2) private static Drawing CreatePerimeterDrawing(Drawing source)
{ {
var shapes1 = GetPartShapes(part1); var entities = ConvertProgram.ToGeometry(source.Program)
var shapes2 = GetPartShapes(part2); .Where(e => e.Layer != SpecialLayers.Rapid).ToList();
var profile = new ShapeProfile(entities);
for (var i = 0; i < shapes1.Count; i++) var program = ConvertGeometry.ToProgram(profile.Perimeter);
{ return new Drawing(source.Name, program);
for (var j = 0; j < shapes2.Count; j++)
{
List<Vector> pts;
if (shapes1[i].Intersects(shapes2[j], out pts))
return true;
}
}
return false;
} }
private List<Shape> GetPartShapes(Part part) private static Shape GetPerimeterShape(Part part)
{ {
var entities = ConvertProgram.ToGeometry(part.Program) var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid).ToList();
var shapes = ShapeBuilder.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
shapes.ForEach(s => s.Offset(part.Location)); if (shapes.Count == 0) return null;
return shapes; shapes[0].Offset(part.Location);
return shapes[0];
} }
private List<Vector> GetPartVertices(Part part) private static List<Vector> GetPartVertices(Part part)
{ {
var entities = ConvertProgram.ToGeometry(part.Program) var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid).ToList();
var shapes = ShapeBuilder.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>(); var points = new List<Vector>();
@@ -130,9 +134,7 @@ namespace OpenNest.Engine.BestFit
{ {
var polygon = shape.ToPolygonWithTolerance(ChordTolerance); var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
polygon.Offset(part.Location); polygon.Offset(part.Location);
points.AddRange(polygon.Vertices);
foreach (var vertex in polygon.Vertices)
points.Add(vertex);
} }
return points; return points;
@@ -36,8 +36,8 @@ namespace OpenNest.Engine.BestFit
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation); var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
var halfSpacing = spacing / 2; var halfSpacing = spacing / 2;
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing); var part1Entities = PartGeometry.GetOffsetPerimeterEntities(part1, halfSpacing);
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing); var part2Entities = PartGeometry.GetOffsetPerimeterEntities(part2Template, halfSpacing);
var bbox1 = part1.BoundingBox; var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox; var bbox2 = part2Template.BoundingBox;
@@ -48,7 +48,7 @@ namespace OpenNest.Engine.BestFit
return candidates; return candidates;
var distances = _distanceComputer.ComputeDistances( var distances = _distanceComputer.ComputeDistances(
part1Lines, part2TemplateLines, offsets); part1Entities, part2Entities, offsets);
var testNumber = 0; var testNumber = 0;
@@ -90,15 +90,18 @@ namespace OpenNest.Engine.BestFit
if (isHorizontalPush) if (isHorizontalPush)
{ {
// Perpendicular sweep along Y → Width; push extent along X → Length // Perpendicular sweep along Y → Width; push extent along X → Length
perpMin = -(bbox2.Width + spacing); // Trim to offsets where the parts overlap by at least 50%.
perpMax = bbox1.Width + bbox2.Width + spacing; var halfOverlap = bbox2.Width * 0.5;
perpMin = -(halfOverlap - spacing);
perpMax = bbox1.Width + halfOverlap + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2; pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
} }
else else
{ {
// Perpendicular sweep along X → Length; push extent along Y → Width // Perpendicular sweep along X → Length; push extent along Y → Width
perpMin = -(bbox2.Length + spacing); var halfOverlap = bbox2.Length * 0.5;
perpMax = bbox1.Length + bbox2.Length + spacing; perpMin = -(halfOverlap - spacing);
perpMax = bbox1.Length + halfOverlap + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2; pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
} }
+76
View File
@@ -0,0 +1,76 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Engine
{
/// <summary>
/// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
/// and un-rotates placed parts back to the drawing's original frame.
/// </summary>
public static class CanonicalFrame
{
/// <summary>
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
/// The source drawing is not mutated.
/// </summary>
public static Drawing AsCanonicalCopy(Drawing drawing)
{
if (drawing == null)
return null;
var angle = drawing.Source?.Angle ?? 0.0;
// Clone program (never mutate the source).
var pgm = (drawing.Program.Clone() as OpenNest.CNC.Program)
?? new OpenNest.CNC.Program();
if (!Tolerance.IsEqualTo(angle, 0))
pgm.Rotate(angle, pgm.BoundingBox().Center);
var copy = new Drawing(drawing.Name ?? string.Empty, pgm)
{
Color = drawing.Color,
Constraints = drawing.Constraints,
Material = drawing.Material,
Priority = drawing.Priority,
Customer = drawing.Customer,
IsCutOff = drawing.IsCutOff,
Source = new SourceInfo
{
Path = drawing.Source?.Path,
Offset = drawing.Source?.Offset ?? new Vector(0, 0),
Angle = 0.0,
},
};
return copy;
}
/// <summary>
/// Composes the source drawing's canonical angle onto each placed part so the
/// returned list is in the drawing's original (visible) frame.
///
/// Derivation: let sourceAngle = S (rotation mapping source -> canonical).
/// Canonical part at rotation R shows visible orientation R.
/// Source part at rotation R' shows visible orientation R' + (-S), because the
/// source geometry is already rotated by -S relative to canonical.
/// Setting equal gives R' = R + S, so we ADD sourceAngle to each placed part.
///
/// Rotation is performed around the part's Location so its placement position is preserved;
/// only the orientation composes.
/// </summary>
public static List<Part> FromCanonical(List<Part> placed, double sourceAngle)
{
if (placed == null || placed.Count == 0)
return placed;
if (Tolerance.IsEqualTo(sourceAngle, 0))
return placed;
foreach (var p in placed)
p.Rotate(sourceAngle, p.Location);
return placed;
}
}
}
+95 -33
View File
@@ -47,14 +47,29 @@ namespace OpenNest
PhaseResults.Clear(); PhaseResults.Clear();
AngleResults.Clear(); AngleResults.Clear();
// Fast path: for very small quantities, skip the full strategy pipeline. // Replace the item's Drawing with a canonical copy for the duration of this fill.
if (item.Quantity > 0 && item.Quantity <= 2) // All internal methods see canonical geometry; this wrapper un-canonicalizes the final result.
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
var originalDrawing = item.Drawing;
var canonicalItem = new NestItem
{ {
var fast = TryFillSmallQuantity(item, workArea); Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing),
if (fast != null && fast.Count >= item.Quantity) Quantity = item.Quantity,
Priority = item.Priority,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
StepAngle = item.StepAngle,
};
// Fast path for qty 1-2.
if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2)
{
var fast = TryFillSmallQuantity(canonicalItem, workArea);
if (fast != null && fast.Count >= canonicalItem.Quantity)
{ {
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}"); Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}");
WinnerPhase = NestPhase.Pairs; WinnerPhase = NestPhase.Pairs;
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport ReportProgress(progress, new ProgressReport
{ {
Phase = WinnerPhase, Phase = WinnerPhase,
@@ -68,32 +83,30 @@ namespace OpenNest
} }
} }
// For low quantities, shrink the work area in both dimensions to avoid
// running expensive strategies against the full plate.
var effectiveWorkArea = workArea; var effectiveWorkArea = workArea;
if (item.Quantity > 0) if (canonicalItem.Quantity > 0)
{ {
effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing); effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing);
if (effectiveWorkArea != workArea) if (effectiveWorkArea != workArea)
Debug.WriteLine($"[Fill] Low-qty shrink: {item.Quantity} requested, " + Debug.WriteLine($"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " +
$"from {workArea.Width:F1}x{workArea.Length:F1} " + $"from {workArea.Width:F1}x{workArea.Length:F1} " +
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}"); $"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
} }
var best = RunFillPipeline(item, effectiveWorkArea, progress, token); var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token);
// Fallback: if the reduced area didn't yield enough, retry with full area. if (canonicalItem.Quantity > 0 && best.Count < canonicalItem.Quantity && effectiveWorkArea != workArea)
if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea)
{ {
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area"); Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area");
PhaseResults.Clear(); PhaseResults.Clear();
AngleResults.Clear(); AngleResults.Clear();
best = RunFillPipeline(item, workArea, progress, token); best = RunFillPipeline(canonicalItem, workArea, progress, token);
} }
if (item.Quantity > 0 && best.Count > item.Quantity) if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis); best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport ReportProgress(progress, new ProgressReport
{ {
@@ -108,6 +121,31 @@ namespace OpenNest
return best; return best;
} }
/// <summary>
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
/// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy)
/// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical.
/// </summary>
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original, double sourceAngle)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
// Rebind to `original` while preserving world pose. CreateAtOrigin rotates
// at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox.
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
/// <summary> /// <summary>
/// Fast path for qty 1-2: place a single part or a best-fit pair /// Fast path for qty 1-2: place a single part or a best-fit pair
/// without running the full strategy pipeline. /// without running the full strategy pipeline.
@@ -139,24 +177,48 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute( var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing); drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
var best = SelectBestFitPair(bestFits); // Build pair candidates with a canonical drawing so their geometry matches
if (best == null) // the coordinate frame of the cached fit results.
return null; var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing);
// BuildParts produces landscape orientation (Width >= Height). List<Part> bestPlacement = null;
// Try both landscape and portrait (90° rotated) and let the
// engine's comparer pick the better orientation.
var landscape = best.BuildParts(drawing);
var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea); foreach (var fit in bestFits)
var pFits = TryOffsetToWorkArea(portrait, workArea); {
if (!fit.Keep)
continue;
if (!lFits && !pFits) // Skip pairs that can't possibly fit the work area in either orientation.
return null; if (fit.ShortestSide > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon)
if (lFits && pFits) continue;
return IsBetterFill(portrait, landscape, workArea) ? portrait : landscape; if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
return lFits ? landscape : portrait; continue;
var landscape = fit.BuildParts(canonicalDrawing);
var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea);
var pFits = TryOffsetToWorkArea(portrait, workArea);
// Pick the better orientation for this pair.
List<Part> candidate = null;
if (lFits && pFits)
candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
else if (lFits)
candidate = landscape;
else if (pFits)
candidate = portrait;
if (candidate == null)
continue;
if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea))
bestPlacement = candidate;
}
// Parts are returned in canonical frame, bound to the canonical drawing.
// The outer Fill wrapper (Task 7) rebinds to `drawing` and composes sourceAngle onto rotation.
return bestPlacement;
} }
private static List<Part> RotatePair90(List<Part> parts) private static List<Part> RotatePair90(List<Part> parts)
+62 -15
View File
@@ -1,6 +1,7 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Math;
namespace OpenNest.Engine.Fill namespace OpenNest.Engine.Fill
{ {
@@ -11,12 +12,10 @@ namespace OpenNest.Engine.Fill
/// </summary> /// </summary>
public static class Compactor public static class Compactor
{ {
private const double ChordTolerance = 0.001;
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction) public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
@@ -28,7 +27,7 @@ namespace OpenNest.Engine.Fill
public static double Push(List<Part> movingParts, Plate plate, double angle) public static double Push(List<Part> movingParts, Plate plate, double angle)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
@@ -44,7 +43,7 @@ namespace OpenNest.Engine.Fill
var opposite = -direction; var opposite = -direction;
var obstacleBoxes = new Box[obstacleParts.Count]; var obstacleBoxes = new Box[obstacleParts.Count];
var obstacleLines = new List<Line>[obstacleParts.Count]; var obstacleEntities = new List<Entity>[obstacleParts.Count];
for (var i = 0; i < obstacleParts.Count; i++) for (var i = 0; i < obstacleParts.Count; i++)
obstacleBoxes[i] = obstacleParts[i].BoundingBox; obstacleBoxes[i] = obstacleParts[i].BoundingBox;
@@ -61,7 +60,19 @@ namespace OpenNest.Engine.Fill
distance = edgeDist; distance = edgeDist;
var movingBox = moving.BoundingBox; var movingBox = moving.BoundingBox;
List<Line> movingLines = null; List<Entity> movingEntities = null;
// Check if any obstacle is inside the moving part — only then
// do we need cutout entities on the moving part.
var needCutouts = false;
for (var i = 0; i < obstacleBoxes.Length; i++)
{
if (movingBox.Contains(obstacleBoxes[i]))
{
needCutouts = true;
break;
}
}
for (var i = 0; i < obstacleBoxes.Length; i++) for (var i = 0; i < obstacleBoxes.Length; i++)
{ {
@@ -76,15 +87,26 @@ namespace OpenNest.Engine.Fill
if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction)) if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
continue; continue;
movingLines ??= halfSpacing > 0 movingEntities ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance) ? (needCutouts
: PartGeometry.GetPartLines(moving, direction, ChordTolerance); ? PartGeometry.GetOffsetPartEntities(moving, halfSpacing)
: PartGeometry.GetOffsetPerimeterEntities(moving, halfSpacing))
: (needCutouts
? PartGeometry.GetPartEntities(moving)
: PartGeometry.GetPerimeterEntities(moving));
obstacleLines[i] ??= halfSpacing > 0 obstacleEntities[i] ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance) ? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance); : PartGeometry.GetPerimeterEntities(obstacleParts[i]);
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
if (d <= Tolerance.Epsilon
&& partSpacing <= Tolerance.Epsilon
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction))
{
continue;
}
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
if (d < distance) if (d < distance)
distance = d; distance = d;
} }
@@ -101,6 +123,31 @@ namespace OpenNest.Engine.Fill
return 0; return 0;
} }
private static bool IntersectsAny(Part candidate, List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
if (candidate.Intersects(parts[i], out _))
return true;
}
return false;
}
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
{
var nudge = direction * (Tolerance.Epsilon * 10);
moving.Offset(nudge);
try
{
return !moving.Intersects(obstacle, out _);
}
finally
{
moving.Offset(-nudge);
}
}
public static double Push(List<Part> movingParts, List<Part> obstacleParts, public static double Push(List<Part> movingParts, List<Part> obstacleParts,
Box workArea, double partSpacing, PushDirection direction) Box workArea, double partSpacing, PushDirection direction)
{ {
@@ -116,7 +163,7 @@ namespace OpenNest.Engine.Fill
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction) public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
@@ -157,7 +204,7 @@ namespace OpenNest.Engine.Fill
continue; continue;
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction); var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
var d = gap - partSpacing - 2 * ChordTolerance; var d = gap - partSpacing - 0.002;
if (d < 0) d = 0; if (d < 0) d = 0;
if (d < distance) if (d < distance)
distance = d; distance = d;
+55 -129
View File
@@ -61,91 +61,91 @@ namespace OpenNest.Engine.Fill
: NestDirection.Horizontal; : NestDirection.Horizontal;
} }
/// <summary>
/// Computes the slide distance for the push algorithm, returning the
/// geometry-aware copy distance along the given axis.
/// </summary>
private double ComputeCopyDistance(double bboxDim, double slideDistance)
{
if (slideDistance >= double.MaxValue || slideDistance < 0)
return bboxDim + PartSpacing;
// The geometry-aware slide can produce a copy distance smaller than
// the part itself when inflated corner/arc vertices interact spuriously.
// Clamp to bboxDim + PartSpacing to prevent bounding box overlap.
return System.Math.Max(bboxDim - slideDistance, bboxDim + PartSpacing);
}
/// <summary> /// <summary>
/// Finds the geometry-aware copy distance between two identical parts along an axis. /// Finds the geometry-aware copy distance between two identical parts along an axis.
/// Both parts are inflated by half-spacing for symmetric spacing. /// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled
/// exactly without polygon sampling error.
/// </summary> /// </summary>
private double FindCopyDistance(Part partA, NestDirection direction, PartBoundary boundary) private double FindCopyDistance(Part partA, NestDirection direction)
{ {
var bboxDim = GetDimension(partA.BoundingBox, direction); var bboxDim = GetDimension(partA.BoundingBox, direction);
var pushDir = GetPushDirection(direction); var pushDir = GetPushDirection(direction);
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset);
var locationBOffset = MakeOffset(direction, bboxDim); var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing);
var movingEntities = PartGeometry.GetOffsetPerimeterEntities(
partA.CloneAtOffset(offset), HalfSpacing);
// Use the most efficient array-based overload to avoid all allocations.
var slideDistance = SpatialQuery.DirectionalDistance( var slideDistance = SpatialQuery.DirectionalDistance(
boundary.GetEdges(pushDir), partA.Location + locationBOffset, movingEntities, stationaryEntities, pushDir);
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
pushDir);
return ComputeCopyDistance(bboxDim, slideDistance); if (slideDistance >= double.MaxValue || slideDistance < 0)
return bboxDim + PartSpacing;
return startOffset - slideDistance;
} }
/// <summary> /// <summary>
/// Finds the geometry-aware copy distance between two identical patterns along an axis. /// Finds the geometry-aware copy distance between two identical patterns along an axis.
/// Checks every pair of parts across adjacent patterns so that multi-part /// Checks every pair of parts across adjacent pattern copies so multi-part patterns
/// patterns (e.g. interlocking pairs) maintain spacing between ALL parts. /// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity
/// Both sides are inflated by half-spacing for symmetric spacing. /// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs
/// are exact and no bbox clamp is needed.
/// </summary> /// </summary>
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary[] boundaries) private double FindPatternCopyDistance(Pattern patternA, NestDirection direction)
{ {
if (patternA.Parts.Count <= 1) if (patternA.Parts.Count == 1)
return FindSinglePartPatternCopyDistance(patternA, direction, boundaries[0]); return FindCopyDistance(patternA.Parts[0], direction);
var bboxDim = GetDimension(patternA.BoundingBox, direction); var bboxDim = GetDimension(patternA.BoundingBox, direction);
var pushDir = GetPushDirection(direction); var pushDir = GetPushDirection(direction);
var opposite = SpatialQuery.OppositeDirection(pushDir); var opposite = SpatialQuery.OppositeDirection(pushDir);
var dirVec = SpatialQuery.DirectionToOffset(pushDir, 1.0);
// bboxDim already spans max(upper) - min(lower) across all parts, // bboxDim already spans max(upper) - min(lower) across all parts,
// so the start offset just needs to push beyond that plus spacing. // so the start offset just needs to push beyond that plus spacing.
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon; var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset); var offset = MakeOffset(direction, startOffset);
var maxCopyDistance = FindMaxPairDistance( var parts = patternA.Parts;
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset); var stationaryBoxes = new Box[parts.Count];
var movingBoxes = new Box[parts.Count];
var stationaryEntities = new List<Entity>[parts.Count];
var movingEntities = new List<Entity>[parts.Count];
if (maxCopyDistance < Tolerance.Epsilon) for (var i = 0; i < parts.Count; i++)
return bboxDim + PartSpacing; {
stationaryBoxes[i] = parts[i].BoundingBox;
movingBoxes[i] = stationaryBoxes[i].Translate(offset);
}
return maxCopyDistance;
}
/// <summary>
/// Tests every pair of parts across adjacent pattern copies and returns the
/// maximum copy distance found. Returns 0 if no valid slide was found.
/// </summary>
private static double FindMaxPairDistance(
List<Part> parts, PartBoundary[] boundaries, Vector offset,
PushDirection pushDir, PushDirection opposite, double startOffset)
{
var maxCopyDistance = 0.0; var maxCopyDistance = 0.0;
for (var j = 0; j < parts.Count; j++) for (var j = 0; j < parts.Count; j++)
{ {
var movingEdges = boundaries[j].GetEdges(pushDir); var movingBox = movingBoxes[j];
var locationB = parts[j].Location + offset;
for (var i = 0; i < parts.Count; i++) for (var i = 0; i < parts.Count; i++)
{ {
var stationaryBox = stationaryBoxes[i];
// Skip if stationary is already ahead of moving in the push direction
// (sliding forward would take them further apart).
if (SpatialQuery.DirectionalGap(movingBox, stationaryBox, opposite) > 0)
continue;
// Skip if bboxes can't overlap along the axis perpendicular to the push.
if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec))
continue;
stationaryEntities[i] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[i], HalfSpacing);
movingEntities[j] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[j].CloneAtOffset(offset), HalfSpacing);
var slideDistance = SpatialQuery.DirectionalDistance( var slideDistance = SpatialQuery.DirectionalDistance(
movingEdges, locationB, movingEntities[j], stationaryEntities[i], pushDir);
boundaries[i].GetEdges(opposite), parts[i].Location,
pushDir);
if (slideDistance >= double.MaxValue || slideDistance < 0) if (slideDistance >= double.MaxValue || slideDistance < 0)
continue; continue;
@@ -160,86 +160,15 @@ namespace OpenNest.Engine.Fill
return maxCopyDistance; return maxCopyDistance;
} }
/// <summary>
/// Fast path for single-part patterns — no cross-part conflicts possible.
/// </summary>
private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary)
{
var template = patternA.Parts[0];
return FindCopyDistance(template, direction, boundary);
}
/// <summary>
/// Gets offset boundary lines for all parts in a pattern using a shared boundary.
/// </summary>
private static List<Line> GetPatternLines(Pattern pattern, PartBoundary boundary, PushDirection direction)
{
var lines = new List<Line>();
foreach (var part in pattern.Parts)
lines.AddRange(boundary.GetLines(part.Location, direction));
return lines;
}
/// <summary>
/// Gets boundary lines for all parts in a pattern, with an additional
/// location offset applied. Avoids cloning the pattern.
/// </summary>
private static List<Line> GetOffsetPatternLines(Pattern pattern, Vector offset, PartBoundary boundary, PushDirection direction)
{
var lines = new List<Line>();
foreach (var part in pattern.Parts)
lines.AddRange(boundary.GetLines(part.Location + offset, direction));
return lines;
}
/// <summary>
/// Creates boundaries for all parts in a pattern. Parts that share the same
/// program geometry (same drawing and rotation) reuse the same boundary instance.
/// </summary>
private PartBoundary[] CreateBoundaries(Pattern pattern)
{
var boundaries = new PartBoundary[pattern.Parts.Count];
var cache = new List<(Drawing drawing, double rotation, PartBoundary boundary)>();
for (var i = 0; i < pattern.Parts.Count; i++)
{
var part = pattern.Parts[i];
PartBoundary found = null;
foreach (var entry in cache)
{
if (entry.drawing == part.BaseDrawing && entry.rotation.IsEqualTo(part.Rotation))
{
found = entry.boundary;
break;
}
}
if (found == null)
{
found = new PartBoundary(part, HalfSpacing);
cache.Add((part.BaseDrawing, part.Rotation, found));
}
boundaries[i] = found;
}
return boundaries;
}
/// <summary> /// <summary>
/// Tiles a pattern along the given axis, returning the cloned parts /// Tiles a pattern along the given axis, returning the cloned parts
/// (does not include the original pattern's parts). For multi-part /// (does not include the original pattern's parts). For multi-part
/// patterns, also adds individual parts from the next incomplete copy /// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area. /// that still fit within the work area.
/// </summary> /// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries) private List<Part> TilePattern(Pattern basePattern, NestDirection direction)
{ {
var copyDistance = FindPatternCopyDistance(basePattern, direction, boundaries); var copyDistance = FindPatternCopyDistance(basePattern, direction);
if (copyDistance <= 0) if (copyDistance <= 0)
return new List<Part>(); return new List<Part>();
@@ -393,11 +322,10 @@ namespace OpenNest.Engine.Fill
private List<Part> FillGrid(Pattern pattern, NestDirection direction) private List<Part> FillGrid(Pattern pattern, NestDirection direction)
{ {
var perpAxis = PerpendicularAxis(direction); var perpAxis = PerpendicularAxis(direction);
var boundaries = CreateBoundaries(pattern);
// Step 1: Tile along primary axis // Step 1: Tile along primary axis
var row = new List<Part>(pattern.Parts); var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction, boundaries)); row.AddRange(TilePattern(pattern, direction));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1)) if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
{ {
@@ -409,7 +337,7 @@ namespace OpenNest.Engine.Fill
// If primary tiling didn't produce copies, just tile along perpendicular // If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count) if (row.Count <= pattern.Parts.Count)
{ {
row.AddRange(TilePattern(pattern, perpAxis, boundaries)); row.AddRange(TilePattern(pattern, perpAxis));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2)) if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
{ {
@@ -426,9 +354,8 @@ namespace OpenNest.Engine.Fill
rowPattern.Parts.AddRange(row); rowPattern.Parts.AddRange(row);
rowPattern.UpdateBounds(); rowPattern.UpdateBounds();
var rowBoundaries = CreateBoundaries(rowPattern);
var gridResult = new List<Part>(rowPattern.Parts); var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries)); gridResult.AddRange(TilePattern(rowPattern, perpAxis));
if (HasOverlappingParts(gridResult, out var a3, out var b3)) if (HasOverlappingParts(gridResult, out var a3, out var b3))
{ {
@@ -480,9 +407,8 @@ namespace OpenNest.Engine.Fill
return seed; return seed;
var template = seed.Parts[0]; var template = seed.Parts[0];
var boundary = new PartBoundary(template, HalfSpacing);
var copyDistance = FindCopyDistance(template, direction, boundary); var copyDistance = FindCopyDistance(template, direction);
if (copyDistance <= 0) if (copyDistance <= 0)
return seed; return seed;
@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
@@ -27,20 +26,6 @@ namespace OpenNest
public override ShrinkAxis TrimAxis => ShrinkAxis.Length; public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
protected override BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
BestFitResult best = null;
foreach (var r in results)
{
if (!r.Keep) continue;
if (best == null || r.BoundingHeight < best.BoundingHeight)
best = r;
}
return best;
}
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea) public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{ {
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI }; var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
+43 -4
View File
@@ -1,5 +1,7 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Sequencing; using OpenNest.Engine.Sequencing;
using OpenNest.Geometry; using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace OpenNest.Engine namespace OpenNest.Engine
@@ -15,14 +17,28 @@ namespace OpenNest.Engine
return; return;
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate); var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
var currentPoint = PlateHelper.GetExitPoint(plate); var exitPoint = PlateHelper.GetExitPoint(plate);
foreach (var sp in sequenced) // Pass 1: assign lead-ins to establish pierce points
var piercePoints = AssignPass(sequenced, parameters, exitPoint, nextPiercePoints: null);
// Pass 2: re-assign with knowledge of next part's start point
AssignPass(sequenced, parameters, exitPoint, nextPiercePoints: piercePoints);
}
private Vector[] AssignPass(List<SequencedPart> sequenced, CuttingParameters parameters,
Vector exitPoint, Vector[] nextPiercePoints)
{
var piercePoints = new Vector[sequenced.Count];
var currentPoint = exitPoint;
for (var i = 0; i < sequenced.Count; i++)
{ {
var part = sp.Part; var part = sequenced[i].Part;
if (part.LeadInsLocked) if (part.LeadInsLocked)
{ {
piercePoints[i] = GetPiercePoint(part);
currentPoint = part.Location; currentPoint = part.Location;
continue; continue;
} }
@@ -31,10 +47,33 @@ namespace OpenNest.Engine
part.RemoveLeadIns(); part.RemoveLeadIns();
var localApproach = currentPoint - part.Location; var localApproach = currentPoint - part.Location;
part.ApplyLeadIns(parameters, localApproach);
if (nextPiercePoints != null && i + 1 < sequenced.Count)
{
var nextStart = nextPiercePoints[i + 1] - part.Location;
part.ApplyLeadIns(parameters, localApproach, nextStart);
}
else
{
part.ApplyLeadIns(parameters, localApproach);
}
piercePoints[i] = GetPiercePoint(part);
currentPoint = part.Location; currentPoint = part.Location;
} }
return piercePoints;
}
private static Vector GetPiercePoint(Part part)
{
foreach (var code in part.Program.Codes)
{
if (code is CNC.Motion motion)
return motion.EndPoint + part.Location;
}
return part.Location;
} }
} }
} }
+9 -6
View File
@@ -27,7 +27,10 @@ namespace OpenNest.Engine.ML
{ {
public static PartFeatures Extract(Drawing drawing) public static PartFeatures Extract(Drawing drawing)
{ {
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program) // Normalize to canonical frame so features are invariant to import orientation.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(canonical.Program)
.Where(e => e.Layer != SpecialLayers.Rapid) .Where(e => e.Layer != SpecialLayers.Rapid)
.ToList(); .ToList();
@@ -45,18 +48,18 @@ namespace OpenNest.Engine.ML
var features = new PartFeatures var features = new PartFeatures
{ {
Area = drawing.Area, Area = canonical.Area,
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0), Convexity = canonical.Area / (hullArea > 0 ? hullArea : 1.0),
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0), AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0), BoundingBoxFill = canonical.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
VertexCount = polygon.Vertices.Count, VertexCount = polygon.Vertices.Count,
Bitmask = GenerateBitmask(polygon, 32) Bitmask = GenerateBitmask(polygon, 32)
}; };
// Circularity = 4 * PI * Area / Perimeter^2 // Circularity = 4 * PI * Area / Perimeter^2
var perimeterLen = polygon.Perimeter(); var perimeterLen = polygon.Perimeter();
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen); features.Circularity = (4 * System.Math.PI * canonical.Area) / (perimeterLen * perimeterLen);
features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0; features.PerimeterToAreaRatio = canonical.Area > 0 ? perimeterLen / canonical.Area : 0;
return features; return features;
} }
+661
View File
@@ -0,0 +1,661 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
public enum PartClass
{
Large,
Medium,
Small,
}
public class MultiPlateNester
{
private readonly Plate _template;
private readonly List<PlateOption> _plateOptions;
private readonly List<PlateOption> _sortedOptions;
private readonly double _salvageRate;
private readonly double _minRemnantSize;
private readonly List<PlateResult> _platePool;
private readonly IProgress<NestProgress> _progress;
private readonly CancellationToken _token;
private readonly MultiPlateNestOptions _options;
private bool HasPlateOptions => _plateOptions != null && _plateOptions.Count > 0;
private MultiPlateNester(
MultiPlateNestOptions options,
List<Plate> existingPlates,
IProgress<NestProgress> progress, CancellationToken token)
{
_options = options;
_template = options.Template;
_plateOptions = options.PlateOptions;
_sortedOptions = options.PlateOptions?.OrderBy(o => o.Cost).ToList();
_salvageRate = options.SalvageRate;
_minRemnantSize = options.MinRemnantSize;
_platePool = InitializePlatePool(existingPlates);
_progress = progress;
_token = token;
}
// --- Static Utility Methods ---
public static bool FitsBounds(Box container, Box part)
{
var fitsNormal = container.Width >= part.Width - Tolerance.Epsilon
&& container.Length >= part.Length - Tolerance.Epsilon;
var fitsRotated = container.Width >= part.Length - Tolerance.Epsilon
&& container.Length >= part.Width - Tolerance.Epsilon;
return fitsNormal || fitsRotated;
}
public static List<NestItem> SortItems(List<NestItem> items, PartSortOrder sortOrder)
{
var withBounds = items.Select(i => (Item: i, Bounds: i.Drawing.Program.BoundingBox())).ToList();
switch (sortOrder)
{
case PartSortOrder.BoundingBoxArea:
return withBounds
.OrderByDescending(x => x.Bounds.Width * x.Bounds.Length)
.Select(x => x.Item)
.ToList();
case PartSortOrder.Size:
return withBounds
.OrderByDescending(x => System.Math.Max(x.Bounds.Width, x.Bounds.Length))
.Select(x => x.Item)
.ToList();
default:
return items.ToList();
}
}
public static PartClass Classify(Box partBounds, Box workArea)
{
var halfWidth = workArea.Width / 2.0;
var halfLength = workArea.Length / 2.0;
if (partBounds.Width > halfWidth || partBounds.Length > halfLength)
return PartClass.Large;
var workAreaArea = workArea.Width * workArea.Length;
var partArea = partBounds.Width * partBounds.Length;
if (partArea > workAreaArea / 9.0)
return PartClass.Medium;
return PartClass.Small;
}
public static bool IsScrapRemnant(Box remnant, double minRemnantSize)
{
return remnant.Width < minRemnantSize && remnant.Length < minRemnantSize;
}
public static List<Box> FindRemnants(Plate plate, double minRemnantSize, bool scrapOnly)
{
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
return remnants.Where(r => IsScrapRemnant(r, minRemnantSize) == scrapOnly).ToList();
}
public struct UpgradeDecision
{
public bool ShouldUpgrade;
public double UpgradeCost;
public double NewPlateCost;
}
public static Plate CreatePlate(Plate template, List<PlateOption> options, Box minBounds)
{
var plate = new Plate(template.Size)
{
PartSpacing = template.PartSpacing,
Quadrant = template.Quadrant,
};
plate.EdgeSpacing = new Spacing
{
Left = template.EdgeSpacing.Left,
Right = template.EdgeSpacing.Right,
Top = template.EdgeSpacing.Top,
Bottom = template.EdgeSpacing.Bottom,
};
if (options == null || options.Count == 0 || minBounds == null)
return plate;
var sorted = options.OrderBy(o => o.Cost).ToList();
foreach (var option in sorted)
{
if (FitsBounds(OptionWorkArea(option, template), minBounds))
{
plate.Size = new Size(option.Width, option.Length);
return plate;
}
}
return plate;
}
public static UpgradeDecision EvaluateUpgradeVsNew(
PlateOption currentSize,
PlateOption upgradeSize,
PlateOption newPlateSize,
double salvageRate,
double estimatedNewPlateUtilization)
{
var upgradeCost = upgradeSize.Cost - currentSize.Cost;
var newPlateCost = newPlateSize.Cost;
var remnantFraction = 1.0 - estimatedNewPlateUtilization;
var salvageCredit = remnantFraction * newPlateSize.Cost * salvageRate;
var netNewCost = newPlateCost - salvageCredit;
return new UpgradeDecision
{
ShouldUpgrade = upgradeCost <= netNewCost,
UpgradeCost = upgradeCost,
NewPlateCost = netNewCost,
};
}
// --- Main Entry Point ---
public static MultiPlateResult Nest(
List<NestItem> items,
MultiPlateNestOptions options,
List<Plate> existingPlates = null,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
var nester = new MultiPlateNester(options, existingPlates, progress, token);
return nester.Run(items, options.SortOrder, options.AllowPlateCreation);
}
// --- Private Helpers ---
private static Box OptionWorkArea(PlateOption option, Plate template)
{
var w = option.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right;
var h = option.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom;
return new Box(0, 0, w, h);
}
private static double ScoreZone(Box zone, Box partBounds)
{
if (!FitsBounds(zone, partBounds))
return -1;
var cols = (int)(zone.Width / partBounds.Width);
var rows = (int)(zone.Length / partBounds.Length);
var colsR = (int)(zone.Width / partBounds.Length);
var rowsR = (int)(zone.Length / partBounds.Width);
var estimatedCount = System.Math.Max(cols * rows, colsR * rowsR);
var utilization = (estimatedCount * partBounds.Width * partBounds.Length) / zone.Area();
var zoneAspect = zone.Width / zone.Length;
var partAspect = partBounds.Width / partBounds.Length;
var aspectMatch = System.Math.Min(zoneAspect, partAspect) / System.Math.Max(zoneAspect, partAspect);
return utilization * 0.7 + aspectMatch * 0.3;
}
private static void DecrementQuantity(NestItem item, int placed)
{
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
private int FillAndPlace(PlateResult pr, Box zone, NestItem item)
{
var engine = NestEngineRegistry.Create(pr.Plate);
var clonedItem = CloneItem(item);
var parts = engine.Fill(clonedItem, zone, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
DecrementQuantity(item, parts.Count);
}
return parts.Count;
}
private PlateResult CreateNewPlateResult(Plate plate)
{
var pr = new PlateResult { Plate = plate, IsNew = true };
if (HasPlateOptions)
{
pr.ChosenSize = _plateOptions.FirstOrDefault(o =>
o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length));
}
return pr;
}
private static NestItem CloneItem(NestItem item)
{
return new NestItem
{
Drawing = item.Drawing,
Priority = item.Priority,
Quantity = item.Quantity,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
};
}
private static List<PlateResult> InitializePlatePool(List<Plate> existingPlates)
{
var pool = new List<PlateResult>();
if (existingPlates != null)
{
foreach (var plate in existingPlates)
pool.Add(new PlateResult { Plate = plate, IsNew = false });
}
return pool;
}
private bool TryWithUpgradedSize(PlateResult pr, PlateOption upgradeOption, Func<List<Box>, bool> tryFill)
{
var oldSize = pr.Plate.Size;
var oldChosenSize = pr.ChosenSize;
pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
pr.ChosenSize = upgradeOption;
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
if (remnants.Count > 0 && tryFill(remnants))
return true;
pr.Plate.Size = oldSize;
pr.ChosenSize = oldChosenSize;
return false;
}
private PlateOption FindSmallestFittingOption(Box partBounds)
{
return _sortedOptions?.FirstOrDefault(o => FitsBounds(OptionWorkArea(o, _template), partBounds));
}
// --- Orchestration ---
private MultiPlateResult Run(List<NestItem> items, PartSortOrder sortOrder, bool allowPlateCreation)
{
var result = new MultiPlateResult();
if (items == null || items.Count == 0)
return result;
var sorted = SortItems(items.Where(i => i.Quantity > 0).ToList(), sortOrder);
foreach (var item in sorted)
{
if (_token.IsCancellationRequested || item.Quantity <= 0)
continue;
var bb = item.Drawing.Program.BoundingBox();
TryPlaceOnExistingPlates(item, bb);
var templateClass = Classify(bb, _template.WorkArea());
if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small)
{
PlaceOnNewPlates(item, bb);
if (item.Quantity > 0 && HasPlateOptions)
TryUpgradeOrNewPlate(item, bb);
}
}
var leftovers = sorted.Where(i => i.Quantity > 0).ToList();
if (leftovers.Count > 0 && allowPlateCreation && !_token.IsCancellationRequested)
{
PackIntoExistingRemnants(leftovers);
CreateSharedPlates(leftovers);
}
if (HasPlateOptions && !_token.IsCancellationRequested)
TryConsolidateTailPlates();
foreach (var item in sorted.Where(i => i.Quantity > 0))
result.UnplacedItems.Add(item);
result.Plates.AddRange(_platePool.Where(p => p.Parts.Count > 0 || p.IsNew));
return result;
}
private void PackIntoExistingRemnants(List<NestItem> leftovers)
{
foreach (var pr in _platePool)
{
if (_token.IsCancellationRequested)
break;
var anyPlaced = true;
while (anyPlaced && !_token.IsCancellationRequested)
{
anyPlaced = false;
var remaining = leftovers.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
if (remnants.Count == 0)
break;
var engine = NestEngineRegistry.Create(pr.Plate);
foreach (var remnant in remnants)
{
remaining = leftovers.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var cloned = remaining.Select(CloneItem).ToList();
var parts = engine.PackArea(remnant, cloned, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
anyPlaced = true;
foreach (var item in remaining)
{
var placed = parts.Count(p => p.BaseDrawing == item.Drawing);
DecrementQuantity(item, placed);
}
}
}
}
}
}
private void CreateSharedPlates(List<NestItem> leftovers)
{
leftovers.RemoveAll(i => i.Quantity <= 0);
while (leftovers.Count > 0 && !_token.IsCancellationRequested)
{
var plate = CreatePlate(_template, _plateOptions, null);
var pr = CreateNewPlateResult(plate);
var placedAny = false;
foreach (var item in leftovers)
{
if (item.Quantity <= 0 || _token.IsCancellationRequested)
continue;
var remnants = !placedAny
? new List<Box> { plate.WorkArea() }
: RemnantFinder.FromPlate(plate).FindRemnants();
if (remnants.Count == 0)
break;
var engine = NestEngineRegistry.Create(plate);
foreach (var remnant in remnants)
{
if (item.Quantity <= 0)
break;
var clonedItem = CloneItem(item);
var parts = engine.Fill(clonedItem, remnant, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
DecrementQuantity(item, parts.Count);
placedAny = true;
}
}
}
if (!placedAny)
break;
_platePool.Add(pr);
leftovers.RemoveAll(i => i.Quantity <= 0);
}
}
private bool TryPlaceOnExistingPlates(NestItem item, Box partBounds)
{
var anyPlaced = false;
var remnantCache = new Dictionary<PlateResult, List<Box>>();
PlateResult lastModified = null;
while (item.Quantity > 0 && !_token.IsCancellationRequested)
{
PlateResult bestPlate = null;
Box bestZone = null;
var bestScore = double.MinValue;
foreach (var pr in _platePool)
{
if (_token.IsCancellationRequested)
break;
if (pr == lastModified || !remnantCache.ContainsKey(pr))
{
var workArea = pr.Plate.WorkArea();
var classification = Classify(partBounds, workArea);
remnantCache[pr] = classification == PartClass.Small
? FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: true)
: FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: false);
}
foreach (var zone in remnantCache[pr])
{
var score = ScoreZone(zone, partBounds);
if (score > bestScore)
{
bestPlate = pr;
bestZone = zone;
bestScore = score;
}
}
}
if (bestPlate == null || bestZone == null)
break;
if (FillAndPlace(bestPlate, bestZone, item) == 0)
break;
lastModified = bestPlate;
anyPlaced = true;
}
return anyPlaced;
}
private bool PlaceOnNewPlates(NestItem item, Box partBounds)
{
var anyPlaced = false;
while (item.Quantity > 0 && !_token.IsCancellationRequested)
{
var plate = CreatePlate(_template, _plateOptions, partBounds);
var workArea = plate.WorkArea();
if (!FitsBounds(workArea, partBounds))
break;
var pr = CreateNewPlateResult(plate);
if (FillAndPlace(pr, workArea, item) == 0)
break;
_platePool.Add(pr);
anyPlaced = true;
}
return anyPlaced;
}
private bool TryUpgradeOrNewPlate(NestItem item, Box partBounds)
{
if (!HasPlateOptions)
return false;
foreach (var pr in _platePool.Where(p => p.IsNew && p.ChosenSize != null))
{
var currentOption = pr.ChosenSize;
var currentIdx = _sortedOptions.FindIndex(o =>
o.Width.IsEqualTo(currentOption.Width) && o.Length.IsEqualTo(currentOption.Length));
if (currentIdx < 0 || currentIdx >= _sortedOptions.Count - 1)
continue;
for (var i = currentIdx + 1; i < _sortedOptions.Count; i++)
{
var upgradeOption = _sortedOptions[i];
if (upgradeOption.Width < currentOption.Width - Tolerance.Epsilon
|| upgradeOption.Length < currentOption.Length - Tolerance.Epsilon)
continue;
var smallestNew = FindSmallestFittingOption(partBounds);
if (smallestNew == null)
continue;
var utilEst = pr.Plate.Utilization();
var decision = EvaluateUpgradeVsNew(currentOption, upgradeOption, smallestNew,
_salvageRate, utilEst);
if (decision.ShouldUpgrade)
{
var placed = TryWithUpgradedSize(pr, upgradeOption, remnants =>
{
foreach (var remnant in remnants)
{
if (FillAndPlace(pr, remnant, item) > 0)
return true;
}
return false;
});
if (placed)
return true;
}
}
}
return false;
}
private void TryConsolidateTailPlates()
{
var consolidated = true;
while (consolidated)
{
consolidated = false;
var activePlates = _platePool.Where(p => p.Parts.Count > 0 && p.IsNew).ToList();
if (activePlates.Count < 2)
return;
var donors = activePlates.OrderBy(p => p.Plate.Utilization()).ToList();
foreach (var donor in donors)
{
if (donor.Parts.Count == 0)
continue;
var donorParts = donor.Parts.ToList();
var absorbed = false;
foreach (var target in activePlates)
{
if (target == donor || target.ChosenSize == null || target.Parts.Count == 0)
continue;
var currentOption = target.ChosenSize;
foreach (var upgradeOption in _sortedOptions.Where(o =>
o.Width >= currentOption.Width - Tolerance.Epsilon
&& o.Length >= currentOption.Length - Tolerance.Epsilon
&& (o.Width > currentOption.Width + Tolerance.Epsilon
|| o.Length > currentOption.Length + Tolerance.Epsilon)))
{
absorbed = TryWithUpgradedSize(target, upgradeOption, remnants =>
{
var engine = NestEngineRegistry.Create(target.Plate);
var tempItems = donorParts
.GroupBy(p => p.BaseDrawing)
.Select(g => new NestItem
{
Drawing = g.Key,
Quantity = g.Count(),
})
.ToList();
var totalPlaced = new List<Part>();
foreach (var remnant in remnants)
{
var placed = engine.PackArea(remnant, tempItems, _progress, _token);
totalPlaced.AddRange(placed);
foreach (var ti in tempItems)
{
var count = placed.Count(p => p.BaseDrawing == ti.Drawing);
ti.Quantity = System.Math.Max(0, ti.Quantity - count);
}
if (tempItems.All(ti => ti.Quantity <= 0))
break;
}
if (totalPlaced.Count >= donorParts.Count)
{
target.AddParts(totalPlaced);
foreach (var p in donorParts)
donor.Plate.Parts.Remove(p);
donor.Parts.Clear();
_platePool.Remove(donor);
return true;
}
return false;
});
if (absorbed)
break;
}
if (absorbed)
break;
}
if (absorbed)
{
consolidated = true;
break;
}
}
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace OpenNest
{
public class MultiPlateNestOptions
{
public Plate Template { get; set; }
public List<PlateOption> PlateOptions { get; set; }
public double SalvageRate { get; set; } = 0.5;
public PartSortOrder SortOrder { get; set; } = PartSortOrder.BoundingBoxArea;
public double MinRemnantSize { get; set; } = 12.0;
public bool AllowPlateCreation { get; set; } = true;
}
public class MultiPlateResult
{
public List<PlateResult> Plates { get; set; } = new();
public List<NestItem> UnplacedItems { get; set; } = new();
}
public class PlateResult
{
public Plate Plate { get; set; }
public List<Part> Parts { get; set; } = new();
public PlateOption ChosenSize { get; set; }
public bool IsNew { get; set; }
public void AddParts(IList<Part> parts)
{
Plate.Parts.AddRange(parts);
Parts.AddRange(parts);
}
}
}
+69 -29
View File
@@ -56,11 +56,6 @@ namespace OpenNest
protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection); protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection);
protected virtual BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
return results.FirstOrDefault(r => r.Keep);
}
// --- Virtual methods (side-effect-free, return parts) --- // --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> Fill(NestItem item, Box workArea, public virtual List<Part> Fill(NestItem item, Box workArea,
@@ -338,50 +333,95 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute( var bestFits = BestFitCache.GetOrCompute(
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing); item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
var bestFit = SelectBestFitPair(bestFits);
if (bestFit == null) continue;
var parts = bestFit.BuildParts(item.Drawing); // BestFitCache stores pair coordinates in canonical frame. Build candidates
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox(); // from a canonical drawing copy so geometry and coords share a frame; rebind
var pairW = pairBbox.Width; // + un-rotate winning pair to the original drawing's frame before returning.
var pairL = pairBbox.Length; var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
var minDim = System.Math.Min(pairW, pairL); var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
var remnants = finder.FindRemnants(minDim); List<Part> bestPlacement = null;
Box target = null; Box bestTarget = null;
foreach (var r in remnants) foreach (var fit in bestFits)
{ {
if (pairW <= r.Width + Tolerance.Epsilon && if (!fit.Keep)
pairL <= r.Length + Tolerance.Epsilon) continue;
var parts = fit.BuildParts(canonicalDrawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width;
var pairL = pairBbox.Length;
var minDim = System.Math.Min(pairW, pairL);
var remnants = finder.FindRemnants(minDim);
foreach (var r in remnants)
{ {
target = r; if (pairW <= r.Width + Tolerance.Epsilon &&
break; pairL <= r.Length + Tolerance.Epsilon)
{
var offset = r.Location - pairBbox.Location;
foreach (var p in parts)
{
p.Offset(offset);
p.UpdateBounds();
}
if (bestPlacement == null || IsBetterFill(parts, bestPlacement, r))
{
bestPlacement = parts;
bestTarget = r;
}
break;
}
} }
} }
if (target == null) continue; if (bestPlacement == null) continue;
var offset = target.Location - pairBbox.Location; // Rebind to the original drawing and compose sourceAngle onto rotation so the
foreach (var p in parts) // final placed parts sit in the user's visible frame.
{ bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
p.Offset(offset);
p.UpdateBounds();
}
result.AddRange(parts); result.AddRange(bestPlacement);
item.Quantity = 0; item.Quantity = 0;
var envelope = ((IEnumerable<IBoundable>)parts).GetBoundingBox(); var envelope = ((IEnumerable<IBoundable>)bestPlacement).GetBoundingBox();
finder.AddObstacle(envelope.Offset(Plate.PartSpacing)); finder.AddObstacle(envelope.Offset(Plate.PartSpacing));
Debug.WriteLine($"[Nest] Placed best-fit pair for {item.Drawing.Name} " + Debug.WriteLine($"[Nest] Placed best-fit pair for {item.Drawing.Name} " +
$"at ({target.X:F1},{target.Y:F1}), size {pairW:F1}x{pairL:F1}"); $"at ({bestTarget.X:F1},{bestTarget.Y:F1}), " +
$"size {envelope.Width:F1}x{envelope.Length:F1}");
} }
return result; return result;
} }
/// <summary>
/// Rebinds each canonical-frame Part in the pair to the original Drawing at its current
/// world pose, then composes sourceAngle onto each via CanonicalFrame.FromCanonical so
/// the returned list is in the original drawing's visible frame. Mirrors
/// DefaultNestEngine.RebindAndUnCanonicalize.
/// </summary>
private static List<Part> RebindPairToOriginal(List<Part> parts, Drawing original, double sourceAngle)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
/// <summary> /// <summary>
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false). /// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
/// Low-quantity items whose total area is a small fraction of the plate are /// Low-quantity items whose total area is a small fraction of the plate are
+2 -2
View File
@@ -64,8 +64,8 @@ namespace OpenNest.Engine
var mbrArea = mbr.Area; var mbrArea = mbr.Area;
var mbrPerimeter = 2 * (mbr.Width + mbr.Height); var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
// Store primary angle (negated to align MBR with axes, same as RotationAnalysis). // Share the single angle formula with CanonicalAngle (no duplicate MBR compute).
result.PrimaryAngle = -mbr.Angle; result.PrimaryAngle = CanonicalAngle.FromMbr(mbr);
// Drawing perimeter for circularity and perimeter ratio. // Drawing perimeter for circularity and perimeter ratio.
var drawingPerimeter = polygon.Perimeter(); var drawingPerimeter = polygon.Perimeter();
+8
View File
@@ -0,0 +1,8 @@
namespace OpenNest
{
public enum PartSortOrder
{
BoundingBoxArea,
Size,
}
}
+30 -6
View File
@@ -1,4 +1,5 @@
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System; using System;
@@ -22,7 +23,8 @@ namespace OpenNest
if (items == null || items.Count == 0 || plateOptions == null || plateOptions.Count == 0) if (items == null || items.Count == 0 || plateOptions == null || plateOptions.Count == 0)
return null; return null;
// Find the minimum dimension needed to fit the largest part. // Find the minimum dimension needed to fit the largest part,
// skipping items that are too large for every plate option.
var minPartWidth = 0.0; var minPartWidth = 0.0;
var minPartLength = 0.0; var minPartLength = 0.0;
foreach (var item in items) foreach (var item in items)
@@ -31,6 +33,14 @@ namespace OpenNest
var bb = item.Drawing.Program.BoundingBox(); var bb = item.Drawing.Program.BoundingBox();
var shortSide = System.Math.Min(bb.Width, bb.Length); var shortSide = System.Math.Min(bb.Width, bb.Length);
var longSide = System.Math.Max(bb.Width, bb.Length); var longSide = System.Math.Max(bb.Width, bb.Length);
if (!plateOptions.Any(o => FitsPart(o, shortSide, longSide, templatePlate.EdgeSpacing)))
{
Debug.WriteLine($"[PlateOptimizer] Skipping oversized item '{item.Drawing.Name}' " +
$"({shortSide:F1}x{longSide:F1}) — does not fit any plate option");
continue;
}
if (shortSide > minPartWidth) minPartWidth = shortSide; if (shortSide > minPartWidth) minPartWidth = shortSide;
if (longSide > minPartLength) minPartLength = longSide; if (longSide > minPartLength) minPartLength = longSide;
} }
@@ -44,6 +54,19 @@ namespace OpenNest
if (candidates.Count == 0) if (candidates.Count == 0)
return null; return null;
// Pre-compute best fits for all candidate plate sizes at once.
// This runs the expensive GPU evaluation once on the largest plate
// and filters the results for each smaller size.
var plateSizes = candidates
.Select(o => (Width: o.Length, Height: o.Width))
.ToList();
foreach (var item in items)
{
if (item.Quantity <= 0) continue;
BestFitCache.ComputeForSizes(item.Drawing, templatePlate.PartSpacing, plateSizes);
}
PlateOptimizerResult best = null; PlateOptimizerResult best = null;
foreach (var option in candidates) foreach (var option in candidates)
@@ -58,9 +81,10 @@ namespace OpenNest
if (IsBetter(result, best)) if (IsBetter(result, best))
best = result; best = result;
// Early exit: when salvage is zero, cheapest plate that fits everything wins. // Early exit: when all items fit, larger plates can only have
// With salvage > 0, larger plates may have lower net cost, so keep searching. // worse utilization and higher cost. With salvage < 100%, the
if (salvageRate <= 0) // remnant credit never offsets the extra plate cost, so skip.
if (salvageRate < 1.0)
{ {
var allPlaced = items.All(i => i.Quantity <= 0 || var allPlaced = items.All(i => i.Quantity <= 0 ||
result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) >= i.Quantity); result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) >= i.Quantity);
@@ -158,8 +182,8 @@ namespace OpenNest
if (!candidate.NetCost.IsEqualTo(current.NetCost)) if (!candidate.NetCost.IsEqualTo(current.NetCost))
return candidate.NetCost < current.NetCost; return candidate.NetCost < current.NetCost;
// 3. Smaller plate area as tiebreak. // 3. Higher utilization (tighter density) as tiebreak.
return candidate.ChosenSize.Area < current.ChosenSize.Area; return candidate.Utilization > current.Utilization;
} }
} }
} }
@@ -4,7 +4,7 @@ using System.Collections.Generic;
namespace OpenNest.Engine namespace OpenNest.Engine
{ {
public class PlateResult public class PlateProcessingResult
{ {
public List<ProcessedPart> Parts { get; init; } public List<ProcessedPart> Parts { get; init; }
} }
+42 -12
View File
@@ -14,18 +14,41 @@ namespace OpenNest.Engine
public ContourCuttingStrategy CuttingStrategy { get; set; } public ContourCuttingStrategy CuttingStrategy { get; set; }
public IRapidPlanner RapidPlanner { get; set; } public IRapidPlanner RapidPlanner { get; set; }
public PlateResult Process(Plate plate) public PlateProcessingResult Process(Plate plate)
{ {
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate); var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
var exitPoint = PlateHelper.GetExitPoint(plate);
// Pass 1: process each part to collect pierce points
var piercePoints = new Vector[sequenced.Count];
var currentPoint = exitPoint;
for (var i = 0; i < sequenced.Count; i++)
{
var part = sequenced[i].Part;
if (!part.HasManualLeadIns && CuttingStrategy != null)
{
var localApproach = ToPartLocal(currentPoint, part);
var result = CuttingStrategy.Apply(part.Program, localApproach);
piercePoints[i] = ToPlateSpace(GetProgramStartPoint(result.Program), part);
currentPoint = ToPlateSpace(result.LastCutPoint, part);
}
else
{
piercePoints[i] = ToPlateSpace(GetProgramStartPoint(part.Program), part);
currentPoint = ToPlateSpace(GetProgramEndPoint(part.Program), part);
}
}
// Pass 2: re-process with next part's start point for perimeter lead-in refinement
var results = new List<ProcessedPart>(sequenced.Count); var results = new List<ProcessedPart>(sequenced.Count);
var cutAreas = new List<Shape>(); var cutAreas = new List<Shape>();
var currentPoint = PlateHelper.GetExitPoint(plate); currentPoint = exitPoint;
foreach (var sp in sequenced) for (var i = 0; i < sequenced.Count; i++)
{ {
var part = sp.Part; var part = sequenced[i].Part;
// Compute approach point in part-local space
var localApproach = ToPartLocal(currentPoint, part); var localApproach = ToPartLocal(currentPoint, part);
Program processedProgram; Program processedProgram;
@@ -33,7 +56,18 @@ namespace OpenNest.Engine
if (!part.HasManualLeadIns && CuttingStrategy != null) if (!part.HasManualLeadIns && CuttingStrategy != null)
{ {
var cuttingResult = CuttingStrategy.Apply(part.Program, localApproach); CuttingResult cuttingResult;
if (i + 1 < sequenced.Count)
{
var nextStart = ToPartLocal(piercePoints[i + 1], part);
cuttingResult = CuttingStrategy.Apply(part.Program, localApproach, nextStart);
}
else
{
cuttingResult = CuttingStrategy.Apply(part.Program, localApproach);
}
processedProgram = cuttingResult.Program; processedProgram = cuttingResult.Program;
lastCutLocal = cuttingResult.LastCutPoint; lastCutLocal = cuttingResult.LastCutPoint;
} }
@@ -43,11 +77,9 @@ namespace OpenNest.Engine
lastCutLocal = GetProgramEndPoint(part.Program); lastCutLocal = GetProgramEndPoint(part.Program);
} }
// Pierce point: program start point in plate space
var pierceLocal = GetProgramStartPoint(processedProgram); var pierceLocal = GetProgramStartPoint(processedProgram);
var piercePoint = ToPlateSpace(pierceLocal, part); var piercePoint = ToPlateSpace(pierceLocal, part);
// Plan rapid from currentPoint to pierce point
var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas); var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
results.Add(new ProcessedPart results.Add(new ProcessedPart
@@ -57,16 +89,14 @@ namespace OpenNest.Engine
RapidPath = rapidPath RapidPath = rapidPath
}); });
// Update cut areas with part perimeter
var perimeter = GetPartPerimeter(part); var perimeter = GetPartPerimeter(part);
if (perimeter != null) if (perimeter != null)
cutAreas.Add(perimeter); cutAreas.Add(perimeter);
// Update current point to last cut point in plate space
currentPoint = ToPlateSpace(lastCutLocal, part); currentPoint = ToPlateSpace(lastCutLocal, part);
} }
return new PlateResult { Parts = results }; return new PlateProcessingResult { Parts = results };
} }
private static Vector ToPartLocal(Vector platePoint, Part part) private static Vector ToPartLocal(Vector platePoint, Part part)
+1 -1
View File
@@ -15,7 +15,7 @@ namespace OpenNest.Engine.Strategies
public int PlateNumber { get; init; } public int PlateNumber { get; init; }
public CancellationToken Token { get; init; } public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; } public IProgress<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; } public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
public int MaxQuantity { get; init; } public int MaxQuantity { get; init; }
public PartType PartType { get; set; } public PartType PartType { get; set; }
-15
View File
@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
@@ -25,20 +24,6 @@ namespace OpenNest
public override NestDirection? PreferredDirection => NestDirection.Horizontal; public override NestDirection? PreferredDirection => NestDirection.Horizontal;
protected override BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
BestFitResult best = null;
foreach (var r in results)
{
if (!r.Keep) continue;
if (best == null || r.BoundingHeight < best.BoundingHeight)
best = r;
}
return best;
}
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea) public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{ {
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI }; var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
@@ -133,7 +133,7 @@ namespace OpenNest.IO.Bending
{ {
return document.Entities return document.Entities
.OfType<ACadSharp.Entities.Line>() .OfType<ACadSharp.Entities.Line>()
.Where(l => l.Layer?.Name == "BEND" .Where(l => (l.Layer?.Name == "BEND" || l.Layer?.Name == "0")
&& (l.LineType?.Name?.Contains("CENTER") == true && (l.LineType?.Name?.Contains("CENTER") == true
|| l.LineType?.Name == "CENTERX2")) || l.LineType?.Name == "CENTERX2"))
.ToList(); .ToList();
+7 -2
View File
@@ -42,6 +42,11 @@ namespace OpenNest.IO.Bom
var nameWithoutExt = Path.GetFileNameWithoutExtension(file); var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles[nameWithoutExt] = file; dxfFiles[nameWithoutExt] = file;
} }
foreach (var file in Directory.GetFiles(dxfFolder, "*.dwg"))
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles.TryAdd(nameWithoutExt, file);
}
} }
// Partition items into: skipped, unmatched, or matched (grouped) // Partition items into: skipped, unmatched, or matched (grouped)
@@ -57,8 +62,8 @@ namespace OpenNest.IO.Bom
var lookupName = item.FileName; var lookupName = item.FileName;
// Strip .dxf extension if the BOM includes it if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)) || lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName); lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (!folderExists) if (!folderExists)
+39
View File
@@ -0,0 +1,39 @@
namespace OpenNest.IO
{
/// <summary>
/// Options controlling how <see cref="CadImporter"/> loads a CAD file
/// and builds a <see cref="Drawing"/>.
/// </summary>
public class CadImportOptions
{
/// <summary>
/// Detector name to use for bend detection. Null = auto-detect.
/// </summary>
public string BendDetectorName { get; set; }
/// <summary>
/// When false, skips bend detection entirely. Default true.
/// </summary>
public bool DetectBends { get; set; } = true;
/// <summary>
/// Override the drawing name. Null = filename without extension.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Required quantity on the produced drawing. Default 1.
/// </summary>
public int Quantity { get; set; } = 1;
/// <summary>
/// Customer name on the produced drawing. Default null.
/// </summary>
public string Customer { get; set; }
/// <summary>
/// Returns a default options instance.
/// </summary>
public static CadImportOptions Default => new CadImportOptions();
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using ACadSharp;
using OpenNest.Bending;
using OpenNest.Geometry;
namespace OpenNest.IO
{
/// <summary>
/// Intermediate result of <see cref="CadImporter.Import"/>. Holds raw loaded
/// geometry and detected bends. Callers may mutate <see cref="Entities"/> and
/// <see cref="Bends"/> before passing to <see cref="CadImporter.BuildDrawing"/>.
/// </summary>
public class CadImportResult
{
/// <summary>
/// All entities loaded from the source file, including promoted bend
/// source entities. Mutable.
/// </summary>
public List<Entity> Entities { get; set; } = new List<Entity>();
/// <summary>
/// Bends detected during import. Mutable — callers may add, remove,
/// or replace entries before building the drawing.
/// </summary>
public List<Bend> Bends { get; set; } = new List<Bend>();
/// <summary>
/// Bounding box of <see cref="Entities"/> at import time. May be stale
/// if callers mutate <see cref="Entities"/>; recompute if needed.
/// </summary>
public Box Bounds { get; set; }
/// <summary>
/// Absolute path to the source file.
/// </summary>
public string SourcePath { get; set; }
/// <summary>
/// Default drawing name (filename without extension, unless overridden).
/// </summary>
public string Name { get; set; }
/// <summary>
/// The raw CAD document from the source file. Available for callers
/// that need access to non-geometry entities (e.g., text annotations).
/// </summary>
public CadDocument Document { get; set; }
}
}
+179
View File
@@ -0,0 +1,179 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Bending;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
using OpenNest.Math;
namespace OpenNest.IO
{
/// <summary>
/// Shared service that converts a CAD source file into a fully-populated
/// <see cref="Drawing"/>. Used by the UI, console, MCP, API, and training
/// tools so all code paths produce identical drawings.
/// </summary>
public static class CadImporter
{
/// <summary>
/// Load a DXF file, run bend detection, and return a mutable result
/// ready for interactive editing or direct conversion to a Drawing.
/// </summary>
public static CadImportResult Import(string path, CadImportOptions options = null)
{
options ??= CadImportOptions.Default;
var dxf = Dxf.Import(path);
RemoveDuplicateArcs(dxf.Entities);
RemoveZeroSweepArcs(dxf.Entities);
var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null)
{
bends = options.BendDetectorName == null
? BendDetectorRegistry.AutoDetect(dxf.Document)
: BendDetectorRegistry.GetByName(options.BendDetectorName)
?.DetectBends(dxf.Document)
?? new List<Bend>();
}
Bend.UpdateEtchEntities(dxf.Entities, bends);
return new CadImportResult
{
Entities = dxf.Entities,
Bends = bends,
Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document,
};
}
/// <summary>
/// Convenience for headless callers: Import a file and build a Drawing
/// in a single call, using all loaded entities and detected bends.
/// </summary>
public static Drawing ImportDrawing(string path, CadImportOptions options = null)
{
options ??= CadImportOptions.Default;
var result = Import(path, options);
return BuildDrawing(
result,
result.Entities,
result.Bends,
options.Quantity,
options.Customer,
editedProgram: null);
}
/// <summary>
/// Build a fully-populated <see cref="Drawing"/> from an import result plus
/// the caller's current entity and bend state. UI callers pass the currently
/// visible subset; headless callers pass the full lists.
///
/// The produced drawing has:
/// - Program generated from the visible entities, with its first rapid moved
/// to the origin and the pierce location stored in Source.Offset
/// - SourceEntities containing all non-bend-source entities from the result
/// - SuppressedEntityIds containing entities whose layer or IsVisible is false
/// - Bends copied from the provided list
/// - Customer, Quantity, Source.Path from options / result
/// </summary>
/// <param name="result">Import result from <see cref="Import"/>.</param>
/// <param name="entities">
/// Entities to build the program from. Typically the currently visible subset.
/// </param>
/// <param name="bends">Bends to attach to the drawing.</param>
/// <param name="quantity">Required quantity.</param>
/// <param name="customer">Customer name, or null.</param>
/// <param name="editedProgram">
/// When non-null, replaces the generated program (used by the UI to honor
/// in-place G-code edits). Source.Offset is still populated from the
/// generated program so round-trips stay consistent.
/// </param>
public static Drawing BuildDrawing(
CadImportResult result,
IEnumerable<Entity> entities,
IEnumerable<Bend> bends,
int quantity,
string customer,
OpenNest.CNC.Program editedProgram)
{
var visible = entities as IList<Entity> ?? new List<Entity>(entities);
var bendList = bends as IList<Bend> ?? new List<Bend>(bends);
var normalized = ShapeProfile.NormalizeEntities(visible);
var pgm = ConvertGeometry.ToProgram(normalized);
var offset = Vector.Zero;
if (pgm != null && pgm.Codes.Count > 0 && pgm[0].Type == OpenNest.CNC.CodeType.RapidMove)
{
var rapid = (OpenNest.CNC.RapidMove)pgm[0];
offset = rapid.EndPoint;
pgm.Offset(-offset);
}
var drawing = new Drawing(result.Name)
{
Color = Drawing.GetNextColor(),
Customer = customer,
};
drawing.Source.Path = result.SourcePath;
drawing.Source.Offset = offset;
drawing.Quantity.Required = quantity;
drawing.Bends.AddRange(bendList);
drawing.Program = editedProgram ?? pgm;
var bendSources = new HashSet<Entity>(
bendList.Where(b => b.SourceEntity != null).Select(b => b.SourceEntity));
drawing.SourceEntities = result.Entities
.Where(e => !bendSources.Contains(e))
.ToList();
drawing.SuppressedEntityIds = new HashSet<System.Guid>(
drawing.SourceEntities
.Where(e => !(e.Layer != null && e.Layer.IsVisible && e.IsVisible))
.Select(e => e.Id));
return drawing;
}
internal static void RemoveZeroSweepArcs(List<Entity> entities)
{
entities.RemoveAll(e =>
e is Arc arc && arc.StartAngle.IsEqualTo(arc.EndAngle, Tolerance.ChainTolerance));
}
internal static void RemoveDuplicateArcs(List<Entity> entities)
{
var circles = entities.OfType<Circle>().ToList();
var arcs = entities.OfType<Arc>().ToList();
var arcsToRemove = new List<Arc>();
foreach (var arc in arcs)
{
foreach (var circle in circles)
{
if (arc.Layer?.Name != circle.Layer?.Name)
continue;
if (!arc.Center.DistanceTo(circle.Center).IsEqualTo(0))
continue;
if (!arc.Radius.IsEqualTo(circle.Radius))
continue;
arcsToRemove.Add(arc);
break;
}
}
foreach (var arc in arcsToRemove)
entities.Remove(arc);
}
}
}
+369
View File
@@ -0,0 +1,369 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.IO
{
public class ChrFont
{
private readonly Dictionary<int, ChrGlyph> glyphs = new();
public string Name { get; internal set; }
public string Version { get; internal set; }
public double CapHeight { get; internal set; } = 5000;
internal void AddGlyph(int charCode, ChrGlyph glyph)
{
glyphs[charCode] = glyph;
}
public bool HasGlyph(int charCode) => glyphs.ContainsKey(charCode);
public ChrGlyph GetGlyph(int charCode) =>
glyphs.TryGetValue(charCode, out var g) ? g : null;
public double MeasureTextWidth(string text, double height)
{
var scale = height / CapHeight;
double width = 0;
foreach (var ch in text)
{
var glyph = GetGlyph(ch);
if (glyph == null)
{
var space = GetGlyph(' ');
width += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
continue;
}
width += glyph.AdvanceWidth * scale;
}
return width;
}
public List<Entity> RenderText(string text, double height, Vector position, Layer layer = null)
{
var scale = height / CapHeight;
var entities = new List<Entity>();
var cursorX = position.X;
foreach (var ch in text)
{
var glyph = GetGlyph(ch);
if (glyph == null)
{
var space = GetGlyph(' ');
cursorX += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
continue;
}
var glyphEntities = glyph.ToEntities(scale, cursorX, position.Y, layer);
entities.AddRange(glyphEntities);
cursorX += glyph.AdvanceWidth * scale;
}
return entities;
}
public static ChrFont Read(string path, byte? xorKey = null)
{
var raw = File.ReadAllBytes(path);
// The whole file is obfuscated with a single-byte XOR. Different
// GravoStyle versions use different keys (0x2F in older releases,
// 0xCF in 7000-series). The font name at offset 0 is ASCII stored
// as UTF-16LE, so the high byte of its first character is 0x00 in
// plaintext — which means raw[1] is exactly the XOR key. Detect it
// from the file unless the caller forces a specific key.
var key = xorKey ?? (raw.Length > 1 ? raw[1] : (byte)0x2F);
var data = new byte[raw.Length];
for (var i = 0; i < raw.Length; i++)
data[i] = (byte)(raw[i] ^ key);
return Parse(data);
}
private static ChrFont Parse(byte[] data)
{
var font = new ChrFont();
font.Name = Encoding.Unicode.GetString(data, 0, 26).TrimEnd('\0').Trim();
font.Version = Encoding.ASCII.GetString(data, 26, 12).TrimEnd('\0').Trim();
var charTable = new List<(int charCode, int offset)>();
var i = 0x40;
while (i + 5 < data.Length)
{
var charCode = data[i] | (data[i + 1] << 8);
var offset = data[i + 2] | (data[i + 3] << 8) | (data[i + 4] << 16) | (data[i + 5] << 24);
if (charCode < 0x20 || offset == 0 || offset >= data.Length)
break;
charTable.Add((charCode, offset));
i += 6;
}
for (var c = 0; c < charTable.Count; c++)
{
var (charCode, offset) = charTable[c];
var nextOffset = c + 1 < charTable.Count
? FindNextOffset(charTable, offset, data.Length)
: data.Length;
var glyph = ParseGlyph(data, offset, nextOffset);
if (glyph != null)
font.AddGlyph(charCode, glyph);
}
if (font.glyphs.Count > 0)
{
foreach (var g in font.glyphs.Values)
{
if (g.CapHeight > 0)
{
font.CapHeight = g.CapHeight;
break;
}
}
}
return font;
}
private static int FindNextOffset(List<(int charCode, int offset)> table, int currentOffset, int fileLength)
{
var best = fileLength;
foreach (var (_, off) in table)
{
if (off > currentOffset && off < best)
best = off;
}
return best;
}
private static ChrGlyph ParseGlyph(byte[] data, int offset, int endOffset)
{
if (offset + 92 > data.Length)
return null;
var glyph = new ChrGlyph();
glyph.CapHeight = ReadBE16(data, offset + 15 * 2);
var bearing = System.Math.Abs(ReadBE16(data, offset + 18 * 2));
glyph.AdvanceWidth = ReadBE16(data, offset + 22 * 2) + bearing;
var strokeStart = offset + 92;
var pos = strokeStart;
var currentStroke = new List<ChrStrokePoint>();
while (pos + 5 < endOffset)
{
var cmd = ReadBE16(data, pos);
var x = ReadBE16(data, pos + 2);
var y = ReadBE16(data, pos + 4);
pos += 6;
if (System.Math.Abs(x) > 15000 || System.Math.Abs(y) > 15000)
break;
if (cmd < -1000)
break;
var type = cmd switch
{
1 => ChrPointType.Vertex,
4 => ChrPointType.Control,
5 => ChrPointType.EndPoint,
_ => ChrPointType.Vertex,
};
currentStroke.Add(new ChrStrokePoint(type, x, y));
if (type == ChrPointType.EndPoint)
{
if (currentStroke.Count > 0)
glyph.Strokes.Add(currentStroke);
currentStroke = new List<ChrStrokePoint>();
}
}
if (currentStroke.Count > 0)
glyph.Strokes.Add(currentStroke);
return glyph;
}
private static int ReadBE16(byte[] data, int offset)
{
var val = (data[offset] << 8) | data[offset + 1];
if (val > 32767) val -= 65536;
return val;
}
}
internal enum ChrPointType
{
Vertex,
Control,
EndPoint,
}
internal struct ChrStrokePoint
{
public ChrPointType Type;
public double X;
public double Y;
public ChrStrokePoint(ChrPointType type, double x, double y)
{
Type = type;
X = x;
Y = y;
}
}
public class ChrGlyph
{
internal readonly List<List<ChrStrokePoint>> Strokes = new();
public double AdvanceWidth { get; internal set; }
public double CapHeight { get; internal set; }
private const int ArcSamples = 16;
public List<Entity> ToEntities(double scale, double offsetX, double offsetY, Layer layer = null)
{
var entities = new List<Entity>();
layer ??= Layer.Default;
foreach (var stroke in Strokes)
{
if (stroke.Count < 2) continue;
var segments = BuildSegments(stroke);
foreach (var seg in segments)
{
if (seg.Points.Count < 2) continue;
var scaled = new List<Vector>(seg.Points.Count);
foreach (var pt in seg.Points)
scaled.Add(new Vector(pt.X * scale + offsetX, pt.Y * scale + offsetY));
var converted = PointsToLines(scaled);
foreach (var e in converted)
{
e.Layer = layer;
entities.Add(e);
}
}
}
return entities;
}
private static List<Entity> PointsToLines(List<Vector> points)
{
var entities = new List<Entity>();
for (var i = 0; i < points.Count - 1; i++)
{
if (points[i].DistanceTo(points[i + 1]) < 0.001)
continue;
entities.Add(new Line(points[i], points[i + 1]));
}
return entities;
}
private static List<StrokeSegment> BuildSegments(List<ChrStrokePoint> stroke)
{
var segments = new List<StrokeSegment>();
var current = new StrokeSegment();
var i = 0;
while (i < stroke.Count)
{
var pt = stroke[i];
if (pt.Type == ChrPointType.Vertex || pt.Type == ChrPointType.EndPoint)
{
if (i + 1 < stroke.Count && stroke[i + 1].Type == ChrPointType.Control)
{
var p0 = new Vector(pt.X, pt.Y);
var pMid = new Vector(stroke[i + 1].X, stroke[i + 1].Y);
var p2End = i + 2 < stroke.Count ? stroke[i + 2] : stroke[i + 1];
var p1 = new Vector(p2End.X, p2End.Y);
SampleCircularArc(current.Points, p0, pMid, p1, ArcSamples);
current.HasCurves = true;
i += 2;
}
else
{
current.Points.Add(new Vector(pt.X, pt.Y));
i++;
}
}
else
{
i++;
}
}
if (current.Points.Count >= 2)
segments.Add(current);
return segments;
}
private class StrokeSegment
{
public readonly List<Vector> Points = new();
public bool HasCurves;
}
private static void SampleCircularArc(List<Vector> output, Vector p0, Vector pMid, Vector p1, int samples)
{
if (output.Count == 0 || output[^1].DistanceTo(p0) > 0.01)
output.Add(p0);
double ax = p0.X, ay = p0.Y;
double bx = pMid.X, by = pMid.Y;
double cx = p1.X, cy = p1.Y;
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if (System.Math.Abs(d) < 1e-6)
{
output.Add(pMid);
output.Add(p1);
return;
}
var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
var radius = System.Math.Sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy));
var a0 = System.Math.Atan2(ay - uy, ax - ux);
var am = System.Math.Atan2(by - uy, bx - ux);
var a1 = System.Math.Atan2(cy - uy, cx - ux);
var ccwSweep = a1 - a0;
while (ccwSweep <= 0) ccwSweep += 2 * System.Math.PI;
var midRel = am - a0;
while (midRel < 0) midRel += 2 * System.Math.PI;
var sweep = midRel < ccwSweep ? ccwSweep : ccwSweep - 2 * System.Math.PI;
for (var i = 1; i <= samples; i++)
{
var t = (double)i / samples;
var angle = a0 + sweep * t;
output.Add(new Vector(ux + radius * System.Math.Cos(angle), uy + radius * System.Math.Sin(angle)));
}
}
}
}
+58 -7
View File
@@ -27,8 +27,7 @@ namespace OpenNest.IO
/// </summary> /// </summary>
public static DxfImportResult Import(string path) public static DxfImportResult Import(string path)
{ {
using var reader = new DxfReader(path); var doc = ReadDocument(path);
var doc = reader.Read();
return new DxfImportResult return new DxfImportResult
{ {
@@ -41,8 +40,7 @@ namespace OpenNest.IO
{ {
try try
{ {
using var reader = new DxfReader(path); var doc = ReadDocument(path);
var doc = reader.Read();
return ConvertEntities(doc); return ConvertEntities(doc);
} }
catch (Exception ex) catch (Exception ex)
@@ -67,6 +65,36 @@ namespace OpenNest.IO
} }
} }
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(stream);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
#endregion #endregion
#region Export #region Export
@@ -113,15 +141,34 @@ namespace OpenNest.IO
#region Private #region Private
private static List<Entity> ConvertEntities(CadDocument doc) private static bool IsDwg(string path) =>
Path.GetExtension(path).Equals(".dwg", StringComparison.OrdinalIgnoreCase);
private static CadDocument ReadDocument(string path)
{
if (IsDwg(path))
{
using var reader = new DwgReader(path);
return reader.Read();
}
else
{
using var reader = new DxfReader(path);
return reader.Read();
}
}
private static List<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null)
{ {
var entities = new List<Entity>(); var entities = new List<Entity>();
var lines = new List<Line>(); var lines = new List<Line>();
var arcs = new List<Arc>(); var arcs = new List<Arc>();
var circles = new List<Circle>();
var filter = layerFilter ?? IsNonCutLayer;
foreach (var entity in doc.Entities) foreach (var entity in doc.Entities)
{ {
if (IsNonCutLayer(entity.Layer?.Name)) if (filter(entity.Layer?.Name))
continue; continue;
switch (entity) switch (entity)
@@ -135,7 +182,7 @@ namespace OpenNest.IO
break; break;
case ACadSharp.Entities.Circle circle: case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest()); circles.Add(circle.ToOpenNest());
break; break;
case ACadSharp.Entities.Spline spline: case ACadSharp.Entities.Spline spline:
@@ -166,7 +213,10 @@ namespace OpenNest.IO
GeometryOptimizer.Optimize(lines); GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs); GeometryOptimizer.Optimize(arcs);
GeometryOptimizer.Deduplicate(circles);
GeometryOptimizer.Deduplicate(circles, arcs);
entities.AddRange(circles);
entities.AddRange(lines); entities.AddRange(lines);
entities.AddRange(arcs); entities.AddRange(arcs);
@@ -192,6 +242,7 @@ namespace OpenNest.IO
public ExportContext() public ExportContext()
{ {
Document = new CadDocument(); Document = new CadDocument();
Document.Header.Version = ACadVersion.AC1018;
CutLayer = new Layer("Cut") { Color = new Color(1) }; CutLayer = new Layer("Cut") { Color = new Color(1) };
RapidLayer = new Layer("Rapid") { Color = new Color(5) }; RapidLayer = new Layer("Rapid") { Color = new Color(5) };
+139
View File
@@ -0,0 +1,139 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Linq;
using static OpenNest.IO.NestFormat;
namespace OpenNest.IO
{
public static class EntitySerializer
{
public static EntitySetDto ToDto(List<Entity> entities, HashSet<Guid> suppressed)
{
return new EntitySetDto
{
Entities = entities.Select(ToEntityDto).ToList(),
Suppressed = suppressed.Select(id => id.ToString()).ToList()
};
}
public static (List<Entity> entities, HashSet<Guid> suppressed) FromDto(EntitySetDto dto)
{
var entities = dto.Entities.Select(FromEntityDto).ToList();
var suppressed = new HashSet<Guid>(dto.Suppressed.Select(Guid.Parse));
return (entities, suppressed);
}
private static EntityDto ToEntityDto(Entity entity)
{
switch (entity.Type)
{
case EntityType.Line:
var line = (Line)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "line",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
X1 = line.StartPoint.X,
Y1 = line.StartPoint.Y,
X2 = line.EndPoint.X,
Y2 = line.EndPoint.Y
};
case EntityType.Arc:
var arc = (Arc)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "arc",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
CX = arc.Center.X,
CY = arc.Center.Y,
R = arc.Radius,
StartAngle = arc.StartAngle,
EndAngle = arc.EndAngle,
Reversed = arc.IsReversed
};
case EntityType.Circle:
var circle = (Circle)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "circle",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
CX = circle.Center.X,
CY = circle.Center.Y,
R = circle.Radius,
Rotation = circle.Rotation == RotationType.CW ? "CW" : "CCW"
};
default:
throw new NotSupportedException($"Entity type {entity.Type} is not supported for serialization.");
}
}
private static Entity FromEntityDto(EntityDto dto)
{
Entity entity;
switch (dto.Type)
{
case "line":
entity = new Line(
new Vector(dto.X1, dto.Y1),
new Vector(dto.X2, dto.Y2));
break;
case "arc":
entity = new Arc(
new Vector(dto.CX, dto.CY),
dto.R,
dto.StartAngle,
dto.EndAngle,
dto.Reversed);
break;
case "circle":
var circle = new Circle(new Vector(dto.CX, dto.CY), dto.R);
circle.Rotation = dto.Rotation == "CW" ? RotationType.CW : RotationType.CCW;
entity = circle;
break;
default:
throw new NotSupportedException($"Entity type '{dto.Type}' is not supported for deserialization.");
}
entity.Id = Guid.Parse(dto.Id);
entity.Layer = ResolveLayer(dto.Layer);
entity.LineTypeName = dto.LineType;
return entity;
}
private static Layer ResolveLayer(string name)
{
if (string.IsNullOrEmpty(name) || name == "0")
return Layer.Default;
if (string.Equals(name, SpecialLayers.Cut.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Cut;
if (string.Equals(name, SpecialLayers.Rapid.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Rapid;
if (string.Equals(name, SpecialLayers.Display.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Display;
if (string.Equals(name, SpecialLayers.Leadin.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Leadin;
if (string.Equals(name, SpecialLayers.Leadout.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Leadout;
if (string.Equals(name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Scribe;
return new Layer(name);
}
}
}
+12 -3
View File
@@ -181,13 +181,22 @@ namespace OpenNest.IO
{ {
var center = new Vector(ellipse.Center.X, ellipse.Center.Y); var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y); var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var startParam = ellipse.StartParameter; var startParam = ellipse.StartParameter;
var endParam = ellipse.EndParameter; var endParam = ellipse.EndParameter;
if (ellipse.Normal.Z < 0)
{
var newStart = OpenNest.Math.Angle.TwoPI - endParam;
var newEnd = OpenNest.Math.Angle.TwoPI - startParam;
startParam = newStart;
endParam = newEnd;
}
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var layer = ellipse.Layer.ToOpenNest(); var layer = ellipse.Layer.ToOpenNest();
var color = ellipse.ResolveColor(); var color = ellipse.ResolveColor();
var lineTypeName = ellipse.ResolveLineTypeName(); var lineTypeName = ellipse.ResolveLineTypeName();
+29
View File
@@ -162,6 +162,35 @@ namespace OpenNest.IO
public double Cost { get; init; } public double Cost { get; init; }
} }
public record EntitySetDto
{
public List<EntityDto> Entities { get; init; } = new();
public List<string> Suppressed { get; init; } = new();
}
public record EntityDto
{
public string Id { get; init; } = "";
public string Type { get; init; } = "";
public string Layer { get; init; } = "";
public string LineType { get; init; } = "";
// Line
public double X1 { get; init; }
public double Y1 { get; init; }
public double X2 { get; init; }
public double Y2 { get; init; }
// Arc / Circle
public double CX { get; init; }
public double CY { get; init; }
public double R { get; init; }
public double StartAngle { get; init; }
public double EndAngle { get; init; }
public bool Reversed { get; init; }
public string Rotation { get; init; } = "";
}
public record BestFitSetDto public record BestFitSetDto
{ {
public double PlateWidth { get; init; } public double PlateWidth { get; init; }
+85 -2
View File
@@ -36,7 +36,8 @@ namespace OpenNest.IO
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions); var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
var programs = ReadPrograms(dto.Drawings.Count); var programs = ReadPrograms(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs); var entitySets = ReadEntitySets(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs, entitySets);
ReadBestFits(drawingMap); ReadBestFits(drawingMap);
var nest = BuildNest(dto, drawingMap); var nest = BuildNest(dto, drawingMap);
@@ -70,11 +71,87 @@ namespace OpenNest.IO
var reader = new ProgramReader(memStream); var reader = new ProgramReader(memStream);
programs[i] = reader.Read(); programs[i] = reader.Read();
// Read sub-programs if present
var subsEntry = zipArchive.GetEntry($"programs/program-{i}-subs");
if (subsEntry != null)
{
using var subsStream = subsEntry.Open();
ReadSubPrograms(programs[i], subsStream);
}
} }
return programs; return programs;
} }
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs) private static void ReadSubPrograms(Program parent, Stream stream)
{
using var reader = new StreamReader(stream);
var currentId = -1;
var lines = new List<string>();
string line;
while ((line = reader.ReadLine()) != null)
{
var trimmed = line.Trim();
if (trimmed.StartsWith(":") && int.TryParse(trimmed.Substring(1), out var id))
{
// Flush previous sub-program
if (currentId >= 0 && lines.Count > 0)
parent.SubPrograms[currentId] = ParseSubProgram(lines);
currentId = id;
lines.Clear();
}
else if (trimmed == "M99")
{
if (currentId >= 0 && lines.Count > 0)
parent.SubPrograms[currentId] = ParseSubProgram(lines);
currentId = -1;
lines.Clear();
}
else
{
lines.Add(trimmed);
}
}
// Wire up SubProgramCall.Program references
foreach (var code in parent.Codes)
{
if (code is SubProgramCall call && parent.SubPrograms.TryGetValue(call.Id, out var sub))
call.Program = sub;
}
}
private static Program ParseSubProgram(List<string> lines)
{
var text = string.Join("\n", lines);
var memStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
var reader = new ProgramReader(memStream);
return reader.Read();
}
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(int count)
{
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
for (var i = 1; i <= count; i++)
{
var entry = zipArchive.GetEntry($"entities/entities-{i}");
if (entry == null) continue;
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
var json = reader.ReadToEnd();
var dto = JsonSerializer.Deserialize<EntitySetDto>(json, JsonOptions);
result[i] = EntitySerializer.FromDto(dto);
}
return result;
}
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs,
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> entitySets)
{ {
var map = new Dictionary<int, Drawing>(); var map = new Dictionary<int, Drawing>();
foreach (var d in dto.Drawings) foreach (var d in dto.Drawings)
@@ -112,6 +189,12 @@ namespace OpenNest.IO
if (programs.TryGetValue(d.Id, out var pgm)) if (programs.TryGetValue(d.Id, out var pgm))
drawing.Program = pgm; drawing.Program = pgm;
if (entitySets.TryGetValue(d.Id, out var entitySet))
{
drawing.SourceEntities = entitySet.entities;
drawing.SuppressedEntityIds = entitySet.suppressed;
}
map[d.Id] = drawing; map[d.Id] = drawing;
} }
return map; return map;
+48 -3
View File
@@ -41,6 +41,7 @@ namespace OpenNest.IO
WriteNestJson(zipArchive); WriteNestJson(zipArchive);
WritePrograms(zipArchive); WritePrograms(zipArchive);
WriteEntities(zipArchive);
WriteBestFits(zipArchive); WriteBestFits(zipArchive);
return true; return true;
@@ -307,8 +308,50 @@ namespace OpenNest.IO
WriteDrawing(stream, kvp.Value); WriteDrawing(stream, kvp.Value);
var entry = zipArchive.CreateEntry(name); var entry = zipArchive.CreateEntry(name);
using var entryStream = entry.Open(); using (var entryStream = entry.Open())
stream.CopyTo(entryStream); {
stream.CopyTo(entryStream);
}
// Write sub-programs if present
if (kvp.Value.Program.SubPrograms.Count > 0)
WriteSubPrograms(zipArchive, kvp.Key, kvp.Value.Program.SubPrograms);
}
}
private void WriteSubPrograms(ZipArchive zipArchive, int drawingId, Dictionary<int, Program> subPrograms)
{
var entry = zipArchive.CreateEntry($"programs/program-{drawingId}-subs");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
foreach (var kvp in subPrograms.OrderBy(k => k.Key))
{
writer.WriteLine($":{kvp.Key}");
writer.WriteLine(kvp.Value.Mode == Mode.Absolute ? "G90" : "G91");
foreach (var code in kvp.Value.Codes)
writer.WriteLine(GetCodeString(code));
writer.WriteLine("M99");
}
}
private void WriteEntities(ZipArchive zipArchive)
{
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{
var drawing = kvp.Value;
if (drawing.SourceEntities == null || drawing.SourceEntities.Count == 0)
continue;
var dto = EntitySerializer.ToDto(drawing.SourceEntities, drawing.SuppressedEntityIds);
var json = JsonSerializer.Serialize(dto, JsonOptions);
var entry = zipArchive.CreateEntry($"entities/entities-{kvp.Key}");
using var stream = entry.Open();
using var writer = new StreamWriter(stream, Encoding.UTF8);
writer.Write(json);
} }
} }
@@ -429,7 +472,9 @@ namespace OpenNest.IO
case CodeType.SubProgramCall: case CodeType.SubProgramCall:
{ {
var subProgramCall = (SubProgramCall)code; var subProgramCall = (SubProgramCall)code;
break; var x = System.Math.Round(subProgramCall.Offset.X, OutputPrecision).ToString(CoordinateFormat);
var y = System.Math.Round(subProgramCall.Offset.Y, OutputPrecision).ToString(CoordinateFormat);
return $"G65P{subProgramCall.Id}X{x}Y{y}";
} }
} }
+3
View File
@@ -4,6 +4,9 @@
<RootNamespace>OpenNest.IO</RootNamespace> <RootNamespace>OpenNest.IO</RootNamespace>
<AssemblyName>OpenNest.IO</AssemblyName> <AssemblyName>OpenNest.IO</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" /> <ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" /> <ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
+16 -1
View File
@@ -374,6 +374,8 @@ namespace OpenNest.IO
{ {
var p = 0; var p = 0;
var r = 0.0; var r = 0.0;
var x = 0.0;
var y = 0.0;
while (section == CodeSection.SubProgram) while (section == CodeSection.SubProgram)
{ {
@@ -395,13 +397,26 @@ namespace OpenNest.IO
r = double.Parse(code.Value); r = double.Parse(code.Value);
break; break;
case 'X':
x = double.Parse(code.Value);
break;
case 'Y':
y = double.Parse(code.Value);
break;
default: default:
section = CodeSection.Unknown; section = CodeSection.Unknown;
break; break;
} }
} }
program.Codes.Add(new SubProgramCall() { Id = p, Rotation = r }); program.Codes.Add(new SubProgramCall
{
Id = p,
Rotation = r,
Offset = new Geometry.Vector(x, y)
});
} }
private Code GetNextCode() private Code GetNextCode()
+11 -19
View File
@@ -1,6 +1,4 @@
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
using OpenNest.Shapes; using OpenNest.Shapes;
using System.ComponentModel; using System.ComponentModel;
@@ -96,24 +94,18 @@ namespace OpenNest.Mcp.Tools
if (!File.Exists(path)) if (!File.Exists(path))
return $"Error: file not found: {path}"; return $"Error: file not found: {path}";
var geometry = Dxf.GetGeometry(path); try
{
var drawing = CadImporter.ImportDrawing(path, new CadImportOptions { Name = name });
_session.Drawings.Add(drawing);
if (geometry.Count == 0) var bbox = drawing.Program.BoundingBox();
return "Error: failed to read DXF file or no geometry found"; return $"Imported drawing '{drawing.Name}': bbox={bbox.Width:F2} x {bbox.Length:F2}";
}
var normalized = ShapeProfile.NormalizeEntities(geometry); catch (System.Exception ex)
var pgm = ConvertGeometry.ToProgram(normalized); {
return $"Error: failed to import '{path}': {ex.Message}";
if (pgm == null) }
return "Error: failed to convert geometry to program";
var drawingName = name ?? Path.GetFileNameWithoutExtension(path);
var drawing = new Drawing(drawingName, pgm);
drawing.Color = Drawing.GetNextColor();
_session.Drawings.Add(drawing);
var bbox = pgm.BoundingBox();
return $"Imported drawing '{drawingName}': bbox={bbox.Width:F2} x {bbox.Length:F2}";
} }
[McpServerTool(Name = "create_drawing")] [McpServerTool(Name = "create_drawing")]
@@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -15,11 +16,16 @@ public sealed class CincinnatiPartSubprogramWriter
{ {
private readonly CincinnatiPostConfig _config; private readonly CincinnatiPostConfig _config;
private readonly CincinnatiFeatureWriter _featureWriter; private readonly CincinnatiFeatureWriter _featureWriter;
private readonly CoordinateFormatter _fmt;
private readonly Dictionary<int, int> _holeSubprograms;
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config) public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config,
Dictionary<int, int> holeSubprograms = null)
{ {
_config = config; _config = config;
_featureWriter = new CincinnatiFeatureWriter(config); _featureWriter = new CincinnatiFeatureWriter(config);
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_holeSubprograms = holeSubprograms;
} }
/// <summary> /// <summary>
@@ -43,6 +49,15 @@ public sealed class CincinnatiPartSubprogramWriter
for (var i = 0; i < ordered.Count; i++) for (var i = 0; i < ordered.Count; i++)
{ {
var (codes, isEtch) = ordered[i]; var (codes, isEtch) = ordered[i];
var isLastFeature = i == ordered.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
continue;
}
var featureNumber = i == 0 var featureNumber = i == 0
? _config.FeatureLineNumberStart ? _config.FeatureLineNumberStart
: 1000 + i + 1; : 1000 + i + 1;
@@ -54,7 +69,7 @@ public sealed class CincinnatiPartSubprogramWriter
FeatureNumber = featureNumber, FeatureNumber = featureNumber,
PartName = drawingName, PartName = drawingName,
IsFirstFeatureOfPart = false, IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = i == ordered.Count - 1, IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = false, IsSafetyHeadraise = false,
IsExteriorFeature = false, IsExteriorFeature = false,
IsEtch = isEtch, IsEtch = isEtch,
@@ -69,6 +84,30 @@ public sealed class CincinnatiPartSubprogramWriter
w.WriteLine($"M99 (END OF {drawingName})"); w.WriteLine($"M99 (END OF {drawingName})");
} }
private void WriteHoleSubprogramCall(TextWriter w, SubProgramCall call,
int featureIndex, bool isLastFeature)
{
var postSubNum = _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
? num : call.Id;
var featureNumber = featureIndex == 0
? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1;
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
w.WriteLine(sb.ToString());
w.WriteLine($"M98 P{postSubNum}");
w.WriteLine("G52 X0 Y0");
if (!isLastFeature)
w.WriteLine("M47");
}
/// <summary> /// <summary>
/// If the program has no leading rapid, inserts a synthetic rapid at the /// If the program has no leading rapid, inserts a synthetic rapid at the
/// last motion endpoint (the contour return point). This ensures the feature /// last motion endpoint (the contour return point). This ensures the feature
@@ -136,4 +175,61 @@ public sealed class CincinnatiPartSubprogramWriter
return (mapping, entries); return (mapping, entries);
} }
/// <summary>
/// Scans all parts across all plates and builds a nest-level registry of unique
/// hole sub-programs. Deduplicates by comparing sub-program code content.
/// </summary>
internal static (Dictionary<int, int> modelToPostMapping, List<(int subNum, Program program)> entries)
BuildHoleRegistry(IEnumerable<Plate> plates, int startNumber)
{
var mapping = new Dictionary<int, int>();
var entries = new List<(int, Program)>();
var contentIndex = new Dictionary<string, int>();
var nextSubNum = startNumber;
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff) continue;
foreach (var code in part.Program.Codes)
{
if (code is not SubProgramCall call) continue;
if (mapping.ContainsKey(call.Id)) continue;
var canonical = ProgramToCanonical(call.Program);
if (contentIndex.TryGetValue(canonical, out var existingNum))
{
mapping[call.Id] = existingNum;
}
else
{
var subNum = nextSubNum++;
mapping[call.Id] = subNum;
contentIndex[canonical] = subNum;
entries.Add((subNum, call.Program));
}
}
}
}
return (mapping, entries);
}
private static string ProgramToCanonical(Program pgm)
{
var sb = new StringBuilder();
sb.Append(pgm.Mode == Mode.Absolute ? "A" : "I");
foreach (var code in pgm.Codes)
{
if (code is LinearMove lm)
sb.Append($"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}");
else if (code is ArcMove am)
sb.Append($"A{am.EndPoint.X:F6},{am.EndPoint.Y:F6},{am.CenterPoint.X:F6},{am.CenterPoint.Y:F6},{(int)am.Rotation},{(int)am.Layer}");
else if (code is RapidMove rm)
sb.Append($"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
}
return sb.ToString();
}
} }
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq;
namespace OpenNest.Posts.Cincinnati namespace OpenNest.Posts.Cincinnati
{ {
@@ -277,6 +279,24 @@ namespace OpenNest.Posts.Cincinnati
[DisplayName("Etch Libraries")] [DisplayName("Etch Libraries")]
[Description("Gas-to-library mapping for etch operations.")] [Description("Gas-to-library mapping for etch operations.")]
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new(); public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
[Category("B. Libraries")]
[DisplayName("Selected Library")]
[Description("Overrides Material/Thickness/Gas auto-resolution. Pick an existing entry from Material Libraries, or leave blank to auto-resolve.")]
[TypeConverter(typeof(MaterialLibraryNameConverter))]
public string SelectedLibrary { get; set; } = "";
public string FindBestLibrary(string materialName, double thickness)
{
if (MaterialLibraries == null || string.IsNullOrEmpty(materialName))
return "";
return MaterialLibraries
.Where(e => string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase))
.OrderBy(e => System.Math.Abs(e.Thickness - thickness))
.Select(e => e.Library)
.FirstOrDefault() ?? "";
}
} }
public class MaterialLibraryEntry public class MaterialLibraryEntry
@@ -9,7 +9,7 @@ using OpenNest.CNC;
namespace OpenNest.Posts.Cincinnati namespace OpenNest.Posts.Cincinnati
{ {
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor, IPostProcessorNestAware, IMaterialProvidingPostProcessor
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@@ -25,6 +25,23 @@ namespace OpenNest.Posts.Cincinnati
object IConfigurablePostProcessor.Config => Config; object IConfigurablePostProcessor.Config => Config;
public IEnumerable<string> GetMaterialNames()
{
if (Config?.MaterialLibraries == null)
return System.Array.Empty<string>();
return Config.MaterialLibraries
.Select(e => e.Material)
.Where(s => !string.IsNullOrWhiteSpace(s));
}
public void PrepareForNest(Nest nest)
{
var materialName = nest?.Material?.Name ?? "";
var thickness = nest?.Thickness ?? 0.0;
Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness);
}
public CincinnatiPostProcessor() public CincinnatiPostProcessor()
{ {
var configPath = GetConfigPath(); var configPath = GetConfigPath();
@@ -89,9 +106,15 @@ namespace OpenNest.Posts.Cincinnati
if (Config.UsePartSubprograms) if (Config.UsePartSubprograms)
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart); (partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
// 5b. Build hole sub-program registry (SubProgramCalls across all parts)
var holeStartNumber = Config.PartSubprogramStart
+ (subprogramEntries?.Count ?? 0);
var (holeMapping, holeEntries) = CincinnatiPartSubprogramWriter.BuildHoleRegistry(plates, holeStartNumber);
// 6. Create writers // 6. Create writers
var preamble = new CincinnatiPreambleWriter(Config); var preamble = new CincinnatiPreambleWriter(Config);
var sheetWriter = new CincinnatiSheetWriter(Config, vars); var sheetWriter = new CincinnatiSheetWriter(Config, vars,
holeMapping.Count > 0 ? holeMapping : null);
// 7. Build material description from nest // 7. Build material description from nest
var material = nest.Material; var material = nest.Material;
@@ -122,7 +145,8 @@ namespace OpenNest.Posts.Cincinnati
// Part sub-programs (if enabled) // Part sub-programs (if enabled)
if (subprogramEntries != null) if (subprogramEntries != null)
{ {
var partSubWriter = new CincinnatiPartSubprogramWriter(Config); var partSubWriter = new CincinnatiPartSubprogramWriter(Config,
holeMapping.Count > 0 ? holeMapping : null);
var sheetDiagonal = firstPlate != null var sheetDiagonal = firstPlate != null
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width ? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length) + firstPlate.Size.Length * firstPlate.Size.Length)
@@ -135,6 +159,23 @@ namespace OpenNest.Posts.Cincinnati
} }
} }
// Hole sub-programs (SubProgramCall definitions)
if (holeEntries.Count > 0)
{
var holeSubWriter = new CincinnatiPartSubprogramWriter(Config);
var sheetDiagonal = firstPlate != null
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length)
: 100.0;
foreach (var (subNum, pgm) in holeEntries)
{
CincinnatiPartSubprogramWriter.EnsureLeadingRapid(pgm);
holeSubWriter.Write(writer, pgm, "HOLE", subNum,
initialCutLibrary, etchLibrary, sheetDiagonal);
}
}
writer.Flush(); writer.Flush();
} }
@@ -17,13 +17,16 @@ public sealed class CincinnatiSheetWriter
private readonly ProgramVariableManager _vars; private readonly ProgramVariableManager _vars;
private readonly CoordinateFormatter _fmt; private readonly CoordinateFormatter _fmt;
private readonly CincinnatiFeatureWriter _featureWriter; private readonly CincinnatiFeatureWriter _featureWriter;
private readonly Dictionary<int, int> _holeSubprograms;
public CincinnatiSheetWriter(CincinnatiPostConfig config, ProgramVariableManager vars) public CincinnatiSheetWriter(CincinnatiPostConfig config, ProgramVariableManager vars,
Dictionary<int, int> holeSubprograms = null)
{ {
_config = config; _config = config;
_vars = vars; _vars = vars;
_fmt = new CoordinateFormatter(config.PostedAccuracy); _fmt = new CoordinateFormatter(config.PostedAccuracy);
_featureWriter = new CincinnatiFeatureWriter(config); _featureWriter = new CincinnatiFeatureWriter(config);
_holeSubprograms = holeSubprograms;
} }
/// <summary> /// <summary>
@@ -132,11 +135,21 @@ public sealed class CincinnatiSheetWriter
for (var f = 0; f < features.Count; f++) for (var f = 0; f < features.Count; f++)
{ {
var (codes, isEtch) = features[f]; var (codes, isEtch) = features[f];
var isLastFeature = isLastPart && f == features.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, featureIndex, isLastFeature);
featureIndex++;
lastPartName = partName;
continue;
}
var featureNumber = featureIndex == 0 var featureNumber = featureIndex == 0
? _config.FeatureLineNumberStart ? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1; : 1000 + featureIndex + 1;
var isLastFeature = isLastPart && f == features.Count - 1;
var cutDistance = FeatureUtils.ComputeCutDistance(codes); var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext var ctx = new FeatureContext
@@ -204,6 +217,36 @@ public sealed class CincinnatiSheetWriter
w.WriteLine("M47"); w.WriteLine("M47");
} }
private void WriteHoleSubprogramCall(TextWriter w, SubProgramCall call, int featureIndex, bool isLastFeature)
{
var postSubNum = _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
? num : call.Id;
var featureNumber = featureIndex == 0
? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1;
// Shift the local origin to the hole center via G52 (manual §1.52).
// G52 does not move the nozzle, so the sub-program's first rapid
// (the lead-in to the pierce point) takes the tool straight from the
// previous feature's end to pierce. The hole sub-program is authored
// in hole-local coordinates and resolves to `hole + local` under the
// shift. See docs/cincinnati-post-output.md for the full bracket.
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
w.WriteLine(sb.ToString());
w.WriteLine($"M98 P{postSubNum}");
// Cancel the local shift (manual §1.52).
w.WriteLine("G52 X0 Y0");
if (!isLastFeature)
w.WriteLine("M47");
}
private void WritePartsInline(TextWriter w, List<Part> allParts, private void WritePartsInline(TextWriter w, List<Part> allParts,
string cutLibrary, string etchLibrary, double sheetDiagonal, string cutLibrary, string etchLibrary, double sheetDiagonal,
double plateWidth, double plateLength, double plateWidth, double plateLength,
@@ -228,6 +271,14 @@ public sealed class CincinnatiSheetWriter
var isSafetyHeadraise = partName != lastPartName && lastPartName != ""; var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
var isLastFeature = i == features.Count - 1; var isLastFeature = i == features.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
lastPartName = partName;
continue;
}
var featureNumber = i == 0 var featureNumber = i == 0
? _config.FeatureLineNumberStart ? _config.FeatureLineNumberStart
: 1000 + i + 1; : 1000 + i + 1;
+10 -1
View File
@@ -21,7 +21,16 @@ public static class FeatureUtils
foreach (var code in codes) foreach (var code in codes)
{ {
if (code is RapidMove) if (code is SubProgramCall)
{
// Flush any pending feature
if (current != null)
features.Add(current);
// SubProgramCall is its own feature
features.Add(new List<ICode> { code });
current = null;
}
else if (code is RapidMove)
{ {
if (current != null) if (current != null)
features.Add(current); features.Add(current);
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
public sealed class MaterialLibraryNameConverter : StringConverter
{
public override bool GetStandardValuesSupported(ITypeDescriptorContext context) => true;
public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) => false;
public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
{
var config = context?.Instance as CincinnatiPostConfig;
var names = new List<string> { "" };
if (config?.MaterialLibraries != null)
{
names.AddRange(config.MaterialLibraries
.Select(e => e.Library)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
}
return new StandardValuesCollection(names);
}
}
}
@@ -10,15 +10,20 @@ public sealed class MaterialLibraryResolver
private readonly List<MaterialLibraryEntry> _materialLibraries; private readonly List<MaterialLibraryEntry> _materialLibraries;
private readonly List<EtchLibraryEntry> _etchLibraries; private readonly List<EtchLibraryEntry> _etchLibraries;
private readonly string _selectedLibrary;
public MaterialLibraryResolver(CincinnatiPostConfig config) public MaterialLibraryResolver(CincinnatiPostConfig config)
{ {
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>(); _materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>(); _etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
_selectedLibrary = config.SelectedLibrary ?? "";
} }
public string ResolveCutLibrary(string materialName, double thickness, string gas) public string ResolveCutLibrary(string materialName, double thickness, string gas)
{ {
if (!string.IsNullOrEmpty(_selectedLibrary))
return EnsureLibExtension(_selectedLibrary);
var entry = _materialLibraries.FirstOrDefault(e => var entry = _materialLibraries.FirstOrDefault(e =>
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) && string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) &&
System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance && System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance &&
@@ -6,11 +6,19 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" /> <ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="OpenNest.Posts.Cincinnati.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="CopyToPostsDir" AfterTargets="Build"> <Target Name="CopyToPostsDir" AfterTargets="Build">
<PropertyGroup> <PropertyGroup>
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir> <PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
<ConfigJson>$(MSBuildProjectDirectory)\OpenNest.Posts.Cincinnati.json</ConfigJson>
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.Cincinnati.json</DeployedConfigJson>
</PropertyGroup> </PropertyGroup>
<MakeDir Directories="$(PostsDir)" /> <MakeDir Directories="$(PostsDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" /> <Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
<Copy SourceFiles="$(ConfigJson)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" Condition="!Exists('$(DeployedConfigJson)')" />
</Target> </Target>
</Project> </Project>

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