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 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
136 changed files with 11207 additions and 1224 deletions
+3
View File
@@ -213,3 +213,6 @@ docs/superpowers/
# 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
+1
View File
@@ -120,3 +120,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- **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. - **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).
+4 -28
View File
@@ -41,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)
@@ -68,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);
@@ -112,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;
@@ -153,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)
@@ -210,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;
} }
@@ -484,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)");
@@ -503,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/)");
@@ -522,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;
@@ -69,8 +69,16 @@ namespace OpenNest.CNC.CuttingStrategy
EmitScribeContours(result, scribeEntities); EmitScribeContours(result, scribeEntities);
foreach (var entry in cutoutEntries) foreach (var entry in cutoutEntries)
{
if (!entry.Shape.IsClosed())
EmitRawContour(result, entry.Shape);
else
EmitContour(result, entry.Shape, entry.Point, entry.Entity); EmitContour(result, entry.Shape, entry.Point, entry.Entity);
}
if (!profile.Perimeter.IsClosed())
EmitRawContour(result, profile.Perimeter);
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;
@@ -99,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);
@@ -114,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);
} }
@@ -289,9 +305,6 @@ namespace OpenNest.CNC.CuttingStrategy
subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding)); subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding));
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle); var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
reindexed = TrimShapeForTab(reindexed, relativePoint, Parameters.TabConfig.Size);
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint)); subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding)); subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
subPgm.Mode = Mode.Incremental; subPgm.Mode = Mode.Incremental;
@@ -315,7 +328,7 @@ namespace OpenNest.CNC.CuttingStrategy
var reindexedShape = 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)
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size); reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point)); program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
+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;
}
}
}
}
}
+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, circle.Rotation); 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;
}
} }
} }
+32 -2
View File
@@ -16,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
@@ -54,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; }
@@ -78,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; }
@@ -163,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; }
} }
} }
+14 -1
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,10 +407,12 @@ namespace OpenNest.Geometry
maxY = startpt.Y; maxY = startpt.Y;
} }
var sweep = SweepAngle();
if (sweep > Tolerance.Epsilon)
{
var angle1 = StartAngle; var angle1 = StartAngle;
var angle2 = EndAngle; 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);
@@ -417,6 +429,7 @@ namespace OpenNest.Geometry
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;
} }
} }
+7
View File
@@ -165,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
{ {
+25
View File
@@ -251,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>
@@ -259,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,
+160 -26
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 (dc.IsValid() && dd <= Tolerance)
{
var isRev = SumSignedAngles(dc, points) < 0;
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
if (aDev <= Tolerance)
return (dc, dr, System.Math.Max(dd, aDev));
}
}
// Fall back to start-tangent-only, then mirror axis
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
if (!center.IsValid() || dev > Tolerance) if (!center.IsValid() || dev > Tolerance)
(center, radius, dev) = FitMirrorAxis(points); continue;
if (!center.IsValid() || dev > Tolerance)
return (Vector.Invalid, 0, 0);
// Check that the arc doesn't bulge away from the original line segments // Check that the arc doesn't bulge away from the original line segments
var isReversed = SumSignedAngles(center, points) < 0; var isReversed = SumSignedAngles(center, points) < 0;
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed); var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
if (arcDev > Tolerance) if (arcDev > Tolerance)
return (Vector.Invalid, 0, 0); continue;
return (center, radius, System.Math.Max(dev, arcDev)); 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)
{
if (prev is Arc)
return new TangentEstimate(GetExitDirection(prev), true);
if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
return new TangentEstimate(GetExitDirection(prevLine), true);
}
}
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
if (points.Count >= 3)
return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
return new TangentEstimate(chord, false);
}
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>
+9
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>
+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();
} }
+54 -38
View File
@@ -306,50 +306,39 @@ 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 e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset; var e2 = edges[i].end + edgeOffset;
var minY = e1.Y < e2.Y ? e1.Y : e2.Y; double perpValue, edgeMin, edgeMax;
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y; if (horizontal)
{
perpValue = vy;
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
}
else
{
perpValue = vx;
edgeMin = e1.X < e2.X ? e1.X : e2.X;
edgeMax = e1.X > e2.X ? e1.X : e2.X;
}
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY. // Since edges are sorted by edgeMin, if perpValue < edgeMin, all subsequent edges are also past.
if (vy < minY - Tolerance.Epsilon) if (perpValue < edgeMin - Tolerance.Epsilon)
break; break;
if (vy > maxY + Tolerance.Epsilon) if (perpValue > edgeMax + Tolerance.Epsilon)
continue; continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d; if (d < minDist) minDist = d;
} }
}
else // Up/Down
{
for (var i = 0; i < edges.Length; i++)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minX = e1.X < e2.X ? e1.X : e2.X;
var maxX = e1.X > e2.X ? e1.X : e2.X;
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
if (vx < minX - Tolerance.Epsilon)
break;
if (vx > maxX + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
}
}
return minDist; return minDist;
} }
@@ -642,22 +631,49 @@ namespace OpenNest.Geometry
{ {
for (var i = 0; i < arcEntities.Count; i++) for (var i = 0; i < arcEntities.Count; i++)
{ {
if (arcEntities[i] is Arc arc) if (arcEntities[i] is not Arc arc)
{ continue;
var cx = arc.Center.X;
var cy = arc.Center.Y;
var r = arc.Radius;
for (var j = 0; j < lineEntities.Count; j++) for (var j = 0; j < lineEntities.Count; j++)
{ {
if (lineEntities[j] is Line line) 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 linePt = line.ClosestPointTo(arc.Center); var theta = k == 0 ? theta1 : theta2;
var arcPt = arc.ClosestPointTo(linePt);
var d = RayEdgeDistance(arcPt.X, arcPt.Y, if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, 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); dirX, dirY);
if (d < minDist) { minDist = d; if (d <= 0) return 0; } if (d < minDist) { minDist = d; if (d <= 0) return 0; }
} }
} }
} }
}
return minDist; return minDist;
} }
@@ -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
{ {
+3 -13
View File
@@ -126,20 +126,10 @@ namespace OpenNest
{ {
var result = new List<Entity>(source.Count); var result = new List<Entity>(source.Count);
for (var i = 0; i < source.Count; i++) foreach (var entity in source)
{ {
var entity = source[i]; var copy = entity.Clone();
Entity copy; copy.Offset(location);
if (entity is Line line)
copy = new Line(line.StartPoint + location, line.EndPoint + location);
else if (entity is Arc arc)
copy = new Arc(arc.Center + location, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed);
else if (entity is Circle circle)
copy = new Circle(circle.Center + location, circle.Radius);
else
continue;
result.Add(copy); result.Add(copy);
} }
+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>
+2
View File
@@ -7,6 +7,8 @@ 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() public override void SetPreviewDefaults()
{ {
Diameter = 8; Diameter = 8;
@@ -8,6 +8,8 @@ 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() public override void SetPreviewDefaults()
{ {
Base = 8; Base = 8;
+2
View File
@@ -10,6 +10,8 @@ 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() public override void SetPreviewDefaults()
{ {
Width = 8; Width = 8;
@@ -3,33 +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() public override void SetPreviewDefaults()
{ {
Sides = 8;
Width = 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,31 +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() public override void SetPreviewDefaults()
{ {
NominalPipeSize = 2;
OD = 7.5; OD = 7.5;
HoleDiameter = 0.875; HoleDiameter = 0.875;
HolePatternDiameter = 5.5; HolePatternDiameter = 5.5;
HoleCount = 8; 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;
@@ -40,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,
}
}
+2
View File
@@ -8,6 +8,8 @@ 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() public override void SetPreviewDefaults()
{ {
Length = 12; Length = 12;
@@ -8,6 +8,8 @@ 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() public override void SetPreviewDefaults()
{ {
Width = 8; Width = 8;
+2
View File
@@ -8,6 +8,8 @@ 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() public override void SetPreviewDefaults()
{ {
OuterDiameter = 10; OuterDiameter = 10;
@@ -10,6 +10,8 @@ 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() public override void SetPreviewDefaults()
{ {
Length = 12; Length = 12;
+10
View File
@@ -26,6 +26,14 @@ 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 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
@@ -34,6 +42,8 @@ namespace OpenNest.Shapes
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);
+2
View File
@@ -10,6 +10,8 @@ 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() public override void SetPreviewDefaults()
{ {
Width = 10; Width = 10;
+2
View File
@@ -9,6 +9,8 @@ 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() public override void SetPreviewDefaults()
{ {
TopWidth = 6; TopWidth = 6;
+330 -176
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,10 +55,17 @@ 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
// interior cutout spans across it. Group the region's entities into
// connected closed loops, nest holes by containment, and emit one
// Drawing per outer loop (with its contained holes).
foreach (var pieceOfRegion in AssemblePieces(allEntities))
{
var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region);
results.Add(piece); results.Add(piece);
pieceIndex++; pieceIndex++;
} }
}
return results; return results;
} }
@@ -218,98 +233,106 @@ 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;
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
return;
}
if (entity is Arc arc)
{ {
if (first != null) entities.Add(first); foreach (var sub in ClipArcToRegion(arc, region))
splitPoints.Add((point, crossedLine, true)); entities.Add(sub);
return;
} }
else }
/// <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)
{ {
splitPoints.Add((point, crossedLine, false)); var edges = new[]
if (second != null) entities.Add(second);
}
}
else if (entity is Arc arc)
{ {
var (first, second) = arc.SplitAt(point); new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)),
if (startInRegion) 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)
{ {
if (first != null) entities.Add(first); var next = new List<Arc>();
splitPoints.Add((point, crossedLine, true)); foreach (var a in arcs)
}
else
{ {
splitPoints.Add((point, crossedLine, false)); if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0)
if (second != null) entities.Add(second); {
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>
@@ -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
// Sort exits and entries by their position along the split line
var isVertical = sl.Axis == CutOffAxis.Vertical;
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
// Pair them up: each exit with the next entry (or vice versa)
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
{
var exitPt = exits[i];
var entryPt = entries[i];
var extentStart = isVertical
? System.Math.Min(exitPt.Y, entryPt.Y)
: System.Math.Min(exitPt.X, entryPt.X);
var extentEnd = isVertical
? System.Math.Max(exitPt.Y, entryPt.Y)
: System.Math.Max(exitPt.X, entryPt.X);
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters); var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0; var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge; var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
if (featureEdge.Count > 0) // Trim any line segments that cross a cutout — cut lines must never
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis); // travel through a hole.
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
entities.AddRange(featureEdge); entities.AddRange(featureEdge);
} }
} }
}
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis) /// <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)
{ {
var featureStart = GetStartPoint(featureEdge[0]); if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
var featureEnd = GetEndPoint(featureEdge[^1]);
var isVertical = axis == CutOffAxis.Vertical;
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
if (edgeGoesForward != featureGoesForward)
{
featureEdge = new List<Entity>(featureEdge);
featureEdge.Reverse();
foreach (var e in featureEdge)
e.Reverse();
}
return featureEdge; 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;
} }
private static void EnsurePerimeterWinding(List<Entity> entities) /// <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)
{ {
var shape = new Shape(); // Collect parameter values t in [0,1] where the line crosses any cutout edge.
shape.Entities.AddRange(entities); var ts = new List<double> { 0.0, 1.0 };
var poly = shape.ToPolygon(); foreach (var poly in cutoutPolygons)
if (poly != null && poly.RotationDirection() != RotationType.CW) {
shape.Reverse(); var polyLines = poly.ToLines();
foreach (var edge in polyLines)
{
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
{
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
ts.Add(t);
}
}
}
entities.Clear(); ts.Sort();
entities.AddRange(shape.Entities);
var segments = new List<Line>();
for (var i = 0; i < ts.Count - 1; i++)
{
var t0 = ts[i];
var t1 = ts[i + 1];
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
var tMid = (t0 + t1) * 0.5;
var mid = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid);
var insideCutout = false;
foreach (var poly in cutoutPolygons)
{
if (poly.ContainsPoint(mid))
{
insideCutout = true;
break;
}
}
if (insideCutout) continue;
var p0 = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0);
var p1 = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1);
segments.Add(new Line(p0, p1));
}
return segments;
}
/// <summary>
/// Segment-segment intersection. On hit, returns the parameter t along segment AB
/// (0 = a0, 1 = a1) via <paramref name="tOnA"/>.
/// </summary>
private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA)
{
tOnA = 0;
var rx = a1.X - a0.X;
var ry = a1.Y - a0.Y;
var sx = b1.X - b0.X;
var sy = b1.Y - b0.Y;
var denom = rx * sy - ry * sx;
if (System.Math.Abs(denom) < Math.Tolerance.Epsilon)
return false;
var dx = b0.X - a0.X;
var dy = b0.Y - a0.Y;
var t = (dx * sy - dy * sx) / denom;
var u = (dx * ry - dy * rx) / denom;
if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false;
if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false;
tOnA = t;
return true;
} }
private static bool IsCutoutInRegion(Shape cutout, Box region) 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);
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints); return entities;
} }
if (entities.Count == 0) /// <summary>
return new List<Entity>(); /// Groups a region's entities into closed components and nests holes inside
/// outer loops by point-in-polygon containment. Returns one entity list per
// Close gaps with straight lines (connect exit→entry pairs) /// output <see cref="Drawing"/> — outer loop first, then its contained holes.
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>(); /// Each outer loop is normalized to CW winding and each hole to CCW.
foreach (var sp in splitPoints) /// </summary>
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
{ {
if (!groups.ContainsKey(sp.Line)) var pieces = new List<List<Entity>>();
groups[sp.Line] = new List<(Vector, bool)>(); if (entities.Count == 0) return pieces;
groups[sp.Line].Add((sp.Point, sp.IsExit));
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0) return pieces;
// Polygonize every shape once so we can run containment tests.
var polygons = new List<Polygon>(shapes.Count);
foreach (var s in shapes)
polygons.Add(s.ToPolygon());
// Classify each shape as outer or hole using nesting by containment.
// Shape A is contained in shape B iff A's bounding box is strictly inside
// B's bounding box AND a representative vertex of A lies inside B's polygon.
// The bbox pre-check avoids the ambiguity of bbox-center tests when two
// shapes share a center (e.g., an outer half and a centered cutout).
var isHole = new bool[shapes.Count];
for (var i = 0; i < shapes.Count; i++)
{
var bbA = shapes[i].BoundingBox;
var repA = FirstVertexOf(shapes[i]);
for (var j = 0; j < shapes.Count; j++)
{
if (i == j) continue;
if (polygons[j] == null) continue;
if (polygons[j].Vertices.Count < 3) continue;
var bbB = shapes[j].BoundingBox;
if (!BoxContainsBox(bbB, bbA)) continue;
if (!polygons[j].ContainsPoint(repA)) continue;
isHole[i] = true;
break;
}
} }
foreach (var kvp in groups) // For each outer, attach the holes that fall inside it.
for (var i = 0; i < shapes.Count; i++)
{ {
var sl = kvp.Key; if (isHole[i]) continue;
var points = kvp.Value;
var isVertical = sl.Axis == CutOffAxis.Vertical;
var exits = points.Where(p => p.IsExit).Select(p => p.Point) var outer = shapes[i];
.OrderBy(p => isVertical ? p.Y : p.X).ToList(); var outerPoly = polygons[i];
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
var pairCount = System.Math.Min(exits.Count, entries.Count); // Enforce perimeter winding = CW.
for (var i = 0; i < pairCount; i++) if (outerPoly != null && outerPoly.Vertices.Count >= 3
entities.Add(new Line(exits[i], entries[i])); && 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);
} }
// Ensure CCW winding for cutouts pieces.Add(piece);
var shape = new Shape(); }
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CCW)
shape.Reverse();
return shape.Entities; return pieces;
}
/// <summary>
/// Returns the first vertex of a shape (start point of its first entity). Used as
/// a representative for containment testing: if bbox pre-check says the whole
/// shape is inside another, testing one vertex is sufficient to confirm.
/// </summary>
private static Vector FirstVertexOf(Shape shape)
{
if (shape.Entities.Count == 0)
return new Vector(0, 0);
return GetStartPoint(shape.Entities[0]);
}
/// <summary>
/// True iff box <paramref name="inner"/> is entirely inside box
/// <paramref name="outer"/> (tolerant comparison).
/// </summary>
private static bool BoxContainsBox(Box outer, Box inner)
{
var eps = Math.Tolerance.Epsilon;
return inner.Left >= outer.Left - eps
&& inner.Right <= outer.Right + eps
&& inner.Bottom >= outer.Bottom - eps
&& inner.Top <= outer.Top + eps;
} }
private static Vector GetStartPoint(Entity entity) 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)
+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
@@ -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");
}
}
} }
} }
+76
View File
@@ -0,0 +1,76 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Engine
{
/// <summary>
/// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
/// and un-rotates placed parts back to the drawing's original frame.
/// </summary>
public static class CanonicalFrame
{
/// <summary>
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
/// The source drawing is not mutated.
/// </summary>
public static Drawing AsCanonicalCopy(Drawing drawing)
{
if (drawing == null)
return null;
var angle = drawing.Source?.Angle ?? 0.0;
// Clone program (never mutate the source).
var pgm = (drawing.Program.Clone() as OpenNest.CNC.Program)
?? new OpenNest.CNC.Program();
if (!Tolerance.IsEqualTo(angle, 0))
pgm.Rotate(angle, pgm.BoundingBox().Center);
var copy = new Drawing(drawing.Name ?? string.Empty, pgm)
{
Color = drawing.Color,
Constraints = drawing.Constraints,
Material = drawing.Material,
Priority = drawing.Priority,
Customer = drawing.Customer,
IsCutOff = drawing.IsCutOff,
Source = new SourceInfo
{
Path = drawing.Source?.Path,
Offset = drawing.Source?.Offset ?? new Vector(0, 0),
Angle = 0.0,
},
};
return copy;
}
/// <summary>
/// Composes the source drawing's canonical angle onto each placed part so the
/// returned list is in the drawing's original (visible) frame.
///
/// Derivation: let sourceAngle = S (rotation mapping source -> canonical).
/// Canonical part at rotation R shows visible orientation R.
/// Source part at rotation R' shows visible orientation R' + (-S), because the
/// source geometry is already rotated by -S relative to canonical.
/// Setting equal gives R' = R + S, so we ADD sourceAngle to each placed part.
///
/// Rotation is performed around the part's Location so its placement position is preserved;
/// only the orientation composes.
/// </summary>
public static List<Part> FromCanonical(List<Part> placed, double sourceAngle)
{
if (placed == null || placed.Count == 0)
return placed;
if (Tolerance.IsEqualTo(sourceAngle, 0))
return placed;
foreach (var p in placed)
p.Rotate(sourceAngle, p.Location);
return placed;
}
}
}
+63 -19
View File
@@ -47,14 +47,29 @@ namespace OpenNest
PhaseResults.Clear(); 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)
{ {
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}"); var fast = TryFillSmallQuantity(canonicalItem, workArea);
if (fast != null && fast.Count >= canonicalItem.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,6 +177,10 @@ 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);
// Build pair candidates with a canonical drawing so their geometry matches
// the coordinate frame of the cached fit results.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing);
List<Part> bestPlacement = null; List<Part> bestPlacement = null;
foreach (var fit in bestFits) foreach (var fit in bestFits)
@@ -152,7 +194,7 @@ namespace OpenNest
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon) if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue; continue;
var landscape = fit.BuildParts(drawing); var landscape = fit.BuildParts(canonicalDrawing);
var portrait = RotatePair90(landscape); var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea); var lFits = TryOffsetToWorkArea(landscape, workArea);
@@ -174,6 +216,8 @@ namespace OpenNest
bestPlacement = candidate; 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; return bestPlacement;
} }
+36 -3
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
{ {
@@ -14,7 +15,7 @@ namespace OpenNest.Engine.Fill
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);
@@ -26,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));
@@ -99,6 +100,13 @@ namespace OpenNest.Engine.Fill
: PartGeometry.GetPerimeterEntities(obstacleParts[i]); : PartGeometry.GetPerimeterEntities(obstacleParts[i]);
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction); var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
if (d <= Tolerance.Epsilon
&& partSpacing <= Tolerance.Epsilon
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction))
{
continue;
}
if (d < distance) if (d < distance)
distance = d; distance = d;
} }
@@ -115,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)
{ {
@@ -130,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);
+54 -129
View File
@@ -61,92 +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];
// The copy distance must be at least bboxDim + PartSpacing to prevent for (var i = 0; i < parts.Count; i++)
// bounding box overlap. Cross-pair slides can underestimate when the {
// circumscribed polygon boundary overshoots the true arc, creating stationaryBoxes[i] = parts[i].BoundingBox;
// spurious contacts between diagonal parts in adjacent copies. movingBoxes[i] = stationaryBoxes[i].Translate(offset);
return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing);
} }
/// <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;
@@ -161,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>();
@@ -394,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))
{ {
@@ -410,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))
{ {
@@ -427,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))
{ {
@@ -481,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;
+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;
} }
+35 -1
View File
@@ -334,6 +334,12 @@ 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);
// BestFitCache stores pair coordinates in canonical frame. Build candidates
// from a canonical drawing copy so geometry and coords share a frame; rebind
// + un-rotate winning pair to the original drawing's frame before returning.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
List<Part> bestPlacement = null; List<Part> bestPlacement = null;
Box bestTarget = null; Box bestTarget = null;
@@ -342,7 +348,7 @@ namespace OpenNest
if (!fit.Keep) if (!fit.Keep)
continue; continue;
var parts = fit.BuildParts(item.Drawing); var parts = fit.BuildParts(canonicalDrawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox(); var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width; var pairW = pairBbox.Width;
var pairL = pairBbox.Length; var pairL = pairBbox.Length;
@@ -374,6 +380,10 @@ namespace OpenNest
if (bestPlacement == null) continue; if (bestPlacement == null) continue;
// Rebind to the original drawing and compose sourceAngle onto rotation so the
// final placed parts sit in the user's visible frame.
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
result.AddRange(bestPlacement); result.AddRange(bestPlacement);
item.Quantity = 0; item.Quantity = 0;
@@ -388,6 +398,30 @@ namespace OpenNest
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();
@@ -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)
+7
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using ACadSharp;
using OpenNest.Bending; using OpenNest.Bending;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -38,5 +39,11 @@ namespace OpenNest.IO
/// Default drawing name (filename without extension, unless overridden). /// Default drawing name (filename without extension, unless overridden).
/// </summary> /// </summary>
public string Name { get; set; } 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; }
} }
} }
+39
View File
@@ -5,6 +5,7 @@ using OpenNest.Bending;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO.Bending; using OpenNest.IO.Bending;
using OpenNest.Math;
namespace OpenNest.IO namespace OpenNest.IO
{ {
@@ -25,6 +26,9 @@ namespace OpenNest.IO
var dxf = Dxf.Import(path); var dxf = Dxf.Import(path);
RemoveDuplicateArcs(dxf.Entities);
RemoveZeroSweepArcs(dxf.Entities);
var bends = new List<Bend>(); var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null) if (options.DetectBends && dxf.Document != null)
{ {
@@ -44,6 +48,7 @@ namespace OpenNest.IO
Bounds = dxf.Entities.GetBoundingBox(), Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path, SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path), Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document,
}; };
} }
@@ -136,5 +141,39 @@ namespace OpenNest.IO
return drawing; 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) };
+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();
+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" />
@@ -0,0 +1,99 @@
using System;
using System.IO.Ports;
using System.Threading;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// Serial streamer for the Gravograph IS8000. 9600 8-N-1; flow control is
/// configurable and defaults to RTS/CTS (the controller is buffered and drops
/// CTS to apply backpressure). The job is sent in modest chunks rather than as
/// one giant write so the handshake can pause the write mid-stream.
/// </summary>
public sealed class GravographISPort : IDisposable
{
private SerialPort port;
public const int DefaultBaudRate = 9600;
public const int DefaultChunkSize = 256;
public const int DefaultWriteTimeoutMs = 30000;
public int ChunkSize { get; set; } = DefaultChunkSize;
public int WriteTimeoutMs { get; set; } = DefaultWriteTimeoutMs;
public bool IsOpen => port != null && port.IsOpen;
/// <summary>
/// Opens the port at the controller's required line settings (9600 8-N-1)
/// with the given <paramref name="handshake"/>. Throws if the port is
/// already open or if opening fails.
/// </summary>
public void Open(string portName, Handshake handshake = Handshake.RequestToSend)
{
if (string.IsNullOrWhiteSpace(portName))
throw new ArgumentException("Port name is required.", nameof(portName));
if (port != null)
throw new InvalidOperationException("Port is already open.");
port = new SerialPort(portName, DefaultBaudRate, Parity.None, 8, StopBits.One)
{
Handshake = handshake,
WriteTimeout = WriteTimeoutMs,
ReadTimeout = WriteTimeoutMs,
// DTR/RTS are needed for some USB-serial bridges and for RTS/CTS flow:
DtrEnable = true,
RtsEnable = handshake != Handshake.RequestToSend &&
handshake != Handshake.RequestToSendXOnXOff,
};
port.Open();
}
/// <summary>
/// Streams the encoded job to the port in chunks. Cancellable. The chunked
/// write is intentional — Write() blocks until the OS accepts the bytes,
/// which with RTS/CTS or XOn/XOff yields cleanly when the controller's
/// buffer is full.
/// </summary>
public void StreamJob(byte[] data, CancellationToken cancellationToken = default)
{
if (data == null) throw new ArgumentNullException(nameof(data));
if (port == null || !port.IsOpen)
throw new InvalidOperationException("Port is not open.");
var chunk = ChunkSize > 0 ? ChunkSize : DefaultChunkSize;
var offset = 0;
while (offset < data.Length)
{
cancellationToken.ThrowIfCancellationRequested();
var count = System.Math.Min(chunk, data.Length - offset);
port.Write(data, offset, count);
offset += count;
}
// Block until the OS has handed the last bytes to the line. SerialPort
// doesn't expose flush-and-drain directly; BaseStream.Flush is a no-op
// on Windows, so this is best-effort.
try { port.BaseStream.Flush(); }
catch { /* ignored — Flush is advisory on SerialPort */ }
}
public void Close()
{
if (port == null) return;
try
{
if (port.IsOpen) port.Close();
}
finally
{
port.Dispose();
port = null;
}
}
public void Dispose() => Close();
}
}
@@ -0,0 +1,81 @@
using System.ComponentModel;
using OpenNest.CNC;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// Cut parameters for one kind of pass (engrave or cut). Edited in the post
/// configuration PropertyGrid and persisted to JSON.
/// </summary>
[TypeConverter(typeof(ExpandableObjectConverter))]
public sealed class LayerCutConfig
{
[DisplayName("Feed (mm/sec)")]
[Description("XY and Z feed for this pass. Patches the VS and VZ wire commands.")]
public int FeedMmPerSec { get; set; } = 10;
[DisplayName("Depth (inches)")]
[Description("Programmed Z plunge (DZ). Note: the spring-floated spindle means this does not set actual cut depth — tool protrusion does.")]
public double Depth { get; set; } = 0.25;
[DisplayName("Pause Before")]
[Description("Stop the spindle and prompt the operator before this pass begins, so the tool can be swapped/adjusted.")]
public bool PauseBefore { get; set; }
[DisplayName("Pause Message")]
[Description("Message shown on the controller during the pause.")]
public string PauseMessage { get; set; } = "";
public override string ToString() => $"{FeedMmPerSec} mm/s, {Depth:0.###}\"" + (PauseBefore ? ", pause" : "");
}
/// <summary>
/// Configuration for the Gravograph IS post processor: one <see cref="LayerCutConfig"/>
/// per cut kind. The engrave block applies to <see cref="LayerType.Scribe"/> paths,
/// the cut block to <see cref="LayerType.Cut"/>/<see cref="LayerType.Leadin"/>/<see cref="LayerType.Leadout"/>.
/// The cut block carries the tool-change pause by default.
/// </summary>
public sealed class GravographISPostConfig
{
[Category("Engrave (Scribe)")]
[DisplayName("Engrave")]
[Description("Parameters for engrave/scribe geometry (text).")]
public LayerCutConfig Engrave { get; set; } = new LayerCutConfig
{
FeedMmPerSec = 10,
Depth = 0.25,
PauseBefore = false,
PauseMessage = "",
};
[Category("Cut")]
[DisplayName("Cut")]
[Description("Parameters for cut geometry (outlines). Pauses for a tool change by default.")]
public LayerCutConfig Cut { get; set; } = new LayerCutConfig
{
FeedMmPerSec = 3,
Depth = 0.25,
PauseBefore = true,
PauseMessage = "Change tool",
};
/// <summary>
/// Returns the cut config a polyline of the given layer should use, or null
/// if the layer is non-cutting (<see cref="LayerType.Display"/>) and should be skipped.
/// </summary>
public LayerCutConfig ConfigFor(LayerType layer)
{
switch (layer)
{
case LayerType.Scribe:
return Engrave;
case LayerType.Cut:
case LayerType.Leadin:
case LayerType.Leadout:
return Cut;
default:
return null;
}
}
}
}
@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Ports;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/>
/// writes the binary HPGL bytes. For serial streaming, use <see cref="Stream(Nest, string, Handshake, CancellationToken)"/>.
///
/// Geometry is split by <see cref="OpenNest.CNC.LayerType"/> into an engrave pass
/// (Scribe) and a cut pass (Cut), each with its own feed/depth from <see cref="Config"/>.
/// The cut pass pauses by default so the operator can swap/adjust the tool.
/// </summary>
public sealed class GravographISPostProcessor : IConfigurablePostProcessor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
public string Name => "Gravograph IS8000";
public string Author => "OpenNest";
public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
public GravographISWriterOptions WriterOptions { get; } = new GravographISWriterOptions();
public NestPolylineExtractor Extractor { get; } = new NestPolylineExtractor();
public double StitchTolerance { get; set; } = PolylinePrePass.DefaultStitchTolerance;
public bool AllowReverse { get; set; } = true;
public GravographISPostConfig Config { get; }
object IConfigurablePostProcessor.Config => Config;
public GravographISPostProcessor()
{
var configPath = GetConfigPath();
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
Config = JsonSerializer.Deserialize<GravographISPostConfig>(json, JsonOptions)
?? new GravographISPostConfig();
}
else
{
Config = new GravographISPostConfig();
SaveConfig();
}
}
public GravographISPostProcessor(GravographISPostConfig config)
{
Config = config ?? throw new ArgumentNullException(nameof(config));
}
public void SaveConfig()
{
var configPath = GetConfigPath();
var json = JsonSerializer.Serialize(Config, JsonOptions);
File.WriteAllText(configPath, json);
}
private static string GetConfigPath()
{
var assemblyPath = typeof(GravographISPostProcessor).Assembly.Location;
var dir = Path.GetDirectoryName(assemblyPath);
var name = Path.GetFileNameWithoutExtension(assemblyPath);
return Path.Combine(dir, name + ".json");
}
public void Post(Nest nest, Stream outputStream)
{
if (nest == null) throw new ArgumentNullException(nameof(nest));
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
var passes = BuildPasses(Extractor.ExtractLayered(nest));
new GravographISWriter(WriterOptions).Write(passes, outputStream);
}
public void Post(Nest nest, string outputFile)
{
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
Post(nest, fs);
}
/// <summary>
/// Groups layer-tagged polylines into ordered tool passes: engrave (Scribe)
/// first, then cut. Each group is stitch/reverse-optimized independently.
/// Geometry whose layer maps to no config (Display) is skipped. When only one
/// group is present, a single pass is returned (and so the writer emits no pause).
/// </summary>
public IReadOnlyList<GravographPass> BuildPasses(IEnumerable<LayeredPolyline> polylines)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
var engrave = new List<IReadOnlyList<Vector>>();
var cut = new List<IReadOnlyList<Vector>>();
foreach (var poly in polylines)
{
if (poly == null) continue;
var block = Config.ConfigFor(poly.Layer);
if (block == null)
continue; // non-cutting (Display) geometry
if (ReferenceEquals(block, Config.Engrave))
engrave.Add(poly.Points);
else
cut.Add(poly.Points);
}
var passes = new List<GravographPass>();
if (engrave.Count > 0)
passes.Add(MakePass(Config.Engrave, engrave));
if (cut.Count > 0)
passes.Add(MakePass(Config.Cut, cut));
return passes;
}
private GravographPass MakePass(LayerCutConfig block, List<IReadOnlyList<Vector>> polylines)
{
return new GravographPass
{
Polylines = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse),
FeedMmPerSec = block.FeedMmPerSec,
DepthInches = block.Depth,
PauseBefore = block.PauseBefore,
PauseMessage = block.PauseMessage ?? "",
};
}
/// <summary>
/// Buffers the encoded job in memory, then streams it to the named COM port.
/// </summary>
public void Stream(Nest nest, string portName,
Handshake handshake = Handshake.RequestToSend,
CancellationToken cancellationToken = default)
{
byte[] bytes;
using (var ms = new MemoryStream())
{
Post(nest, ms);
bytes = ms.ToArray();
}
using var port = new GravographISPort();
port.Open(portName, handshake);
port.StreamJob(bytes, cancellationToken);
}
}
}
@@ -0,0 +1,464 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// One tool pass: a run of polylines cut at a single feed/depth, optionally
/// preceded by an operator pause (to swap or adjust the tool). The Gravograph
/// post builds one pass for engrave and one for cut.
/// </summary>
public sealed class GravographPass
{
public IEnumerable<IReadOnlyList<Vector>> Polylines { get; set; }
public int FeedMmPerSec { get; set; }
public double DepthInches { get; set; }
/// <summary>When true, park to origin and prompt the operator before this pass.</summary>
public bool PauseBefore { get; set; }
public string PauseMessage { get; set; } = "";
}
/// <summary>
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
///
/// Scale: 80 steps/mm = 2032 steps/inch. Y (and Z) are negated on the wire.
/// Deltas are signed big-endian int16 (max ±32767 steps ≈ ±16 inches per move).
/// </summary>
public sealed class GravographISWriter
{
// 93-byte preamble — captured from GravoStyle'98 with the trailing
// job-specific travel block stripped. The VS, VZ and DZ operands are
// patched by the writer to reflect feed and depth options.
//
// The original capture ended with a DR command (FF FD 44 52 00 00)
// followed by three 8-byte int16 records — same format as PU/PD —
// that carried a chunked travel from the head's parked position to
// the original job's first vertex (cumulative ΔX ≈ 1", ΔY ≈ 47").
// Those frozen deltas have nothing to do with our job geometry, so
// replaying them sends the head to a fixed point regardless of where
// the operator set zero. Stripped for the same reason as the captured
// fixed return-to-home block.
private static readonly byte[] PreambleTemplate = new byte[]
{
0x21, 0x41, 0x53, 0x20, 0x33, 0x38, 0x3b, 0x01, 0x90, 0x01,
0xf4, 0x01, 0x90, 0x01, 0xf4, 0x01, 0x90, 0x01, 0xf4, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x09, 0x00, 0x00, 0x03, 0xe8, 0x05, 0x06, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xfd, 0x32, 0x44, 0x00,
0x00, 0xff, 0xfd, 0x4d, 0x43, 0x00, 0x01, 0xff, 0xfd, 0x4f,
0x55, 0xff, 0xfb, 0xff, 0xfd, 0x4f, 0x55, 0xff, 0xfa, 0xff,
0xfd, 0x50, 0x5a, 0x00, 0x00, 0xff, 0xfd, 0x56, 0x53, 0x00,
0x23, 0xff, 0xfd, 0x56, 0x5a, 0x00, 0x23, 0xff, 0xfd, 0x44,
0x5a, 0x01, 0xfc,
};
// Stripped 36-byte postamble: lift, aux off, motor off, operator beep,
// job-finish. The 24-byte return-to-home block that appears in GravoStyle's
// captured postamble between MC and OP is intentionally OMITTED — those
// three 8-byte int16 records carry chunked job-specific return deltas
// (each record is [word1:int16][param:int16][ΔX:int16][ΔY:int16], same
// format as PU/PD records; the original capture chunked the long Y return
// across three records because each delta has to fit in int16). Reusing
// GravoStyle's frozen deltas on different geometry overshoots the X-axis
// limit. We emit calculated return deltas for the current job instead.
// The writer now replaces the captured fixed return block with a calculated
// lift + PU travel to the operator-set origin before these final commands.
private static readonly byte[] EndJobBytes = new byte[]
{
0xff, 0xfd, 0x4f, 0x55, 0xff, 0xfa, // OU 0xFFFA aux off
0xff, 0xfd, 0x4f, 0x55, 0xff, 0xfb, // OU 0xFFFB aux off
0xff, 0xfd, 0x4d, 0x43, 0x00, 0x00, // MC 0x0000 motor off
0xff, 0xfd, 0x4f, 0x50, 0x00, 0x00, // OP 0x0000 operator beep
0xff, 0xfd, 0x4a, 0x46, 0x00, 0x00, // JF 0x0000 job finish
};
// 80 steps/mm × 25.4 mm/in
internal const int StepsPerInch = 2032;
public GravographISWriterOptions Options { get; }
public GravographISWriter()
: this(new GravographISWriterOptions())
{
}
public GravographISWriter(GravographISWriterOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Writes the full byte stream (preamble + geometry + postamble) for the given
/// polylines. Polyline coordinates are in inches, relative to the operator-set
/// work origin. The writer emits a leading DR travel to the first polyline
/// start before lowering for the first cut.
/// </summary>
public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
// A single pass at the configured feed/depth — byte-identical to the
// original single-group output (no transitions, no pause).
Write(new[]
{
new GravographPass
{
Polylines = polylines,
FeedMmPerSec = Options.FeedMmPerSec,
DepthInches = Options.DepthInches,
PauseBefore = false,
PauseMessage = "",
},
}, output);
}
/// <summary>
/// Writes the full byte stream for an ordered list of tool passes. The preamble
/// carries the first pass's feed/depth; each later pass emits an inline feed
/// (and depth, if changed) and, when <see cref="GravographPass.PauseBefore"/> is
/// set, parks to the operator origin and emits an operator pause before cutting.
/// </summary>
public void Write(IReadOnlyList<GravographPass> passes, Stream output)
{
if (passes == null) throw new ArgumentNullException(nameof(passes));
if (output == null) throw new ArgumentNullException(nameof(output));
var firstFeed = passes.Count > 0 ? passes[0].FeedMmPerSec : Options.FeedMmPerSec;
var firstDepth = passes.Count > 0 ? passes[0].DepthInches : Options.DepthInches;
var preamble = (byte[])PreambleTemplate.Clone();
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
output.Write(preamble, 0, preamble.Length);
// Cumulative head position from the operator-set upper-left origin, in
// wire steps. The first polyline gets a leading DR travel from this
// origin before PD lowers for cutting. Used by the envelope guard to
// catch bad records before they ship to the engraver.
var headX = 0;
var headY = 0;
var envelopeXSteps = (int)System.Math.Round(Options.WorkEnvelopeXMm * StepsPerMm,
MidpointRounding.AwayFromZero);
var envelopeYSteps = (int)System.Math.Round(Options.WorkEnvelopeYMm * StepsPerMm,
MidpointRounding.AwayFromZero);
var firstPolyline = true;
var polyIndex = 0;
var currentFeed = firstFeed;
var currentDepth = firstDepth;
for (var p = 0; p < passes.Count; p++)
{
var pass = passes[p];
if (p > 0)
{
if (pass.PauseBefore)
{
// Park: lift Z, then rapid (pen-up) back to the operator origin so
// the head is clear of the work while the tool is swapped.
WriteLiftOnly(output);
WriteTravel(output, (byte)'P', (byte)'U',
checked(-headX), checked(-headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
WritePauseCore(output, pass.PauseMessage);
}
if (pass.FeedMmPerSec != currentFeed)
{
WriteCommand(output, (byte)'V', (byte)'S', (short)pass.FeedMmPerSec);
WriteCommand(output, (byte)'V', (byte)'Z', (short)pass.FeedMmPerSec);
currentFeed = pass.FeedMmPerSec;
}
if (pass.DepthInches != currentDepth)
{
WriteCommand(output, (byte)'D', (byte)'Z', DepthInStepsAsInt16(pass.DepthInches));
currentDepth = pass.DepthInches;
}
}
if (pass.Polylines == null) continue;
foreach (var poly in pass.Polylines)
{
polyIndex++;
if (poly == null || poly.Count < 2)
continue;
WritePolyline(output, poly, ref firstPolyline, ref headX, ref headY,
envelopeXSteps, envelopeYSteps, polyIndex);
}
}
WriteLiftOnly(output);
if (Options.ReturnToOriginAtEnd && !firstPolyline)
{
WriteTravel(output, (byte)'P', (byte)'U',
checked(-headX), checked(-headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
}
output.Write(EndJobBytes, 0, EndJobBytes.Length);
}
private void WritePolyline(Stream output, IReadOnlyList<Vector> poly,
ref bool firstPolyline, ref int headX, ref int headY,
int envelopeXSteps, int envelopeYSteps, int polyIndex)
{
var (startX, startY) = ToWire(poly[0]);
WriteTravel(output,
firstPolyline ? (byte)'D' : (byte)'P',
firstPolyline ? (byte)'R' : (byte)'U',
checked(startX - headX), checked(startY - headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
// PD command + single records-follow flag, then one record per segment.
output.WriteByte(0xFF);
output.WriteByte(0xFD);
output.WriteByte((byte)'P');
output.WriteByte((byte)'D');
output.WriteByte(0x00);
output.WriteByte(0x00);
var prevX = startX;
var prevY = startY;
for (int i = 1; i < poly.Count; i++)
{
var (cx, cy) = ToWire(poly[i]);
var dx = checked(cx - prevX);
var dy = checked(cy - prevY);
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
polyIndex, segment: i, isTravel: false);
WriteRecord(output, dx, dy);
prevX = cx;
prevY = cy;
headX += dx;
headY += dy;
}
firstPolyline = false;
}
// The operator pause, minus the leading lift/park which the caller emits.
// Stops the spindle (MC off), turns off aux, writes the console message, then
// restarts the spindle (MC on) so the job resumes when the operator presses start.
private static void WritePauseCore(Stream s, string message)
{
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x00); // motor off
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFA); // aux off
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x00); // begin message
WriteMessagePackets(s, message);
WriteCommandRaw(s, (byte)'N', (byte)'R', 0x00, 0x01); // line terminator
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x01); // end message
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x01); // motor on
}
// Console label packets carry two ASCII chars each; an odd-length message is
// space-padded to a whole number of packets so widths stay 2 bytes.
private static void WriteMessagePackets(Stream s, string message)
{
if (string.IsNullOrEmpty(message)) return;
var chars = Encoding.ASCII.GetBytes(message);
for (var i = 0; i < chars.Length; i += 2)
{
var c0 = chars[i];
var c1 = (i + 1 < chars.Length) ? chars[i + 1] : (byte)0x20;
WriteCommandRaw(s, (byte)'L', (byte)'B', c0, c1);
}
}
private static void WriteCommand(Stream s, byte c0, byte c1, short value)
{
WriteCommandRaw(s, c0, c1, (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF));
}
private static void WriteCommandRaw(Stream s, byte c0, byte c1, byte hi, byte lo)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
s.WriteByte(hi);
s.WriteByte(lo);
}
private const double StepsPerMm = 80.0;
private void EnsureEnvelope(int wireX, int wireY,
int envXSteps, int envYSteps,
int polyIndex, int segment, bool isTravel)
{
if (!Options.EnvelopeGuardEnabled) return;
// Wire frame: X is identity to input; Y is negated. With the operator
// origin set at the upper-left of the work envelope and an OpenNest
// quadrant-4 plate, valid part coordinates are +X/right and -Y/down:
// wireX ∈ [0, +envXSteps]
// wireY ∈ [0, +envYSteps]
if (wireX >= 0 && wireX <= envXSteps && wireY >= 0 && wireY <= envYSteps)
return;
var inputX = wireX / (double)StepsPerInch;
var inputY = -wireY / (double)StepsPerInch;
var kind = isTravel ? "pen-up travel" : "cut segment";
throw new InvalidOperationException(
$"Polyline {polyIndex} {kind} (segment {segment}) would place the head at " +
$"({inputX:F3}\", {inputY:F3}\"), outside the {Options.WorkEnvelopeXMm}×{Options.WorkEnvelopeYMm} mm " +
$"work envelope from upper-left origin. Refusing to emit the record.");
}
private static short DepthInStepsAsInt16(double depthInches)
{
var steps = (long)System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero);
if (steps < short.MinValue || steps > short.MaxValue)
throw new ArgumentOutOfRangeException(nameof(depthInches), $"Depth {depthInches} in. → {steps} steps overflows int16.");
return (short)steps;
}
private static (int x, int y) ToWire(Vector v)
{
// Inches -> steps. With upper-left origin in OpenNest quadrant 4,
// negative input Y is down; Y is negated on the wire.
var x = (int)System.Math.Round(v.X * StepsPerInch, MidpointRounding.AwayFromZero);
var y = (int)System.Math.Round(-v.Y * StepsPerInch, MidpointRounding.AwayFromZero);
return (x, y);
}
private void WriteTravel(Stream s, byte c0, byte c1, int dx, int dy,
ref int headX, ref int headY,
int envelopeXSteps, int envelopeYSteps,
int polyIndex)
{
if (dx == 0 && dy == 0)
return;
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
s.WriteByte(0x00);
s.WriteByte(0x00);
var chunks = System.Math.Max(
(int)System.Math.Ceiling(System.Math.Abs(dx) / (double)short.MaxValue),
(int)System.Math.Ceiling(System.Math.Abs(dy) / (double)short.MaxValue));
if (chunks < 1) chunks = 1;
var emittedX = 0;
var emittedY = 0;
for (var i = 1; i <= chunks; i++)
{
var targetX = (int)System.Math.Round(dx * (i / (double)chunks), MidpointRounding.AwayFromZero);
var targetY = (int)System.Math.Round(dy * (i / (double)chunks), MidpointRounding.AwayFromZero);
var chunkX = checked(targetX - emittedX);
var chunkY = checked(targetY - emittedY);
EnsureEnvelope(headX + chunkX, headY + chunkY, envelopeXSteps, envelopeYSteps,
polyIndex, segment: 0, isTravel: true);
WriteRecord(s, chunkX, chunkY);
emittedX = targetX;
emittedY = targetY;
headX += chunkX;
headY += chunkY;
}
}
private static void WriteLiftOnly(Stream s)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte((byte)'P');
s.WriteByte((byte)'U');
s.WriteByte(0x00);
s.WriteByte(0x01);
}
private static void WriteCommandWithRecord(Stream s, byte c0, byte c1, int dx, int dy)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
// Records-follow flag (0x0000) emitted once per PU/PD packet.
s.WriteByte(0x00);
s.WriteByte(0x00);
WriteRecord(s, dx, dy);
}
private static void WriteRecord(Stream s, int dx, int dy)
{
if (dx < short.MinValue || dx > short.MaxValue ||
dy < short.MinValue || dy > short.MaxValue)
{
throw new InvalidOperationException(
$"Move delta ({dx}, {dy}) steps overflows signed int16 — split moves upstream.");
}
int word1;
int param;
var absDx = (double)System.Math.Abs(dx);
var absDy = (double)System.Math.Abs(dy);
var len = System.Math.Sqrt(absDx * absDx + absDy * absDy);
if (len < 1.0)
{
// Zero-length lift (PU 00 01) is the dedicated form; for a record-carrying
// packet a true zero-length move shouldn't occur, but stay numerically safe.
word1 = 16384;
param = 1;
}
else
{
var maxAbs = System.Math.Max(absDx, absDy);
word1 = (int)System.Math.Round(16384.0 * maxAbs / len, MidpointRounding.AwayFromZero);
param = (int)System.Math.Round(len / 22.4, MidpointRounding.AwayFromZero);
if (param < 1) param = 1;
if (param > 180) param = 180;
if (word1 > 16384) word1 = 16384;
}
WriteBigEndianInt16(s, (short)word1);
WriteBigEndianInt16(s, (short)param);
WriteBigEndianInt16(s, (short)dx);
WriteBigEndianInt16(s, (short)dy);
}
private static void WriteBigEndianInt16(Stream s, short value)
{
s.WriteByte((byte)((value >> 8) & 0xFF));
s.WriteByte((byte)(value & 0xFF));
}
// Locates the operand of a command (FF FD <c0> <c1> <hi> <lo>) and overwrites it.
// Throws if the command isn't present — that would mean the preamble was mis-edited.
private static void PatchOperand(byte[] buffer, byte c0, byte c1, short value)
{
for (int i = 0; i <= buffer.Length - 6; i++)
{
if (buffer[i] == 0xFF && buffer[i + 1] == 0xFD &&
buffer[i + 2] == c0 && buffer[i + 3] == c1)
{
buffer[i + 4] = (byte)((value >> 8) & 0xFF);
buffer[i + 5] = (byte)(value & 0xFF);
return;
}
}
throw new InvalidOperationException(
$"Command '{(char)c0}{(char)c1}' not found in preamble template.");
}
}
}
@@ -0,0 +1,24 @@
namespace OpenNest.Posts.GravographIS
{
public sealed class GravographISWriterOptions
{
public double DepthInches { get; set; } = 0.25;
public int FeedMmPerSec { get; set; } = 35;
// IS8000 work envelope in millimeters, from the operator-set upper-left
// work origin. Defaults to the catalog 0.610 m x 1.220 m bed. With an
// OpenNest quadrant-4 plate, motion is allowed right (+X) and down (-Y).
public double WorkEnvelopeXMm { get; set; } = 610.0;
public double WorkEnvelopeYMm { get; set; } = 1220.0;
// When true, the writer throws an InvalidOperationException naming the
// offending polyline and segment before any out-of-envelope record is
// emitted. Disable only for off-machine encoding tests.
public bool EnvelopeGuardEnabled { get; set; } = true;
// When true, lift at the end of the last cut and return to the
// operator-set origin before shutting the job down.
public bool ReturnToOriginAtEnd { get; set; } = true;
}
}
@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// A polyline together with the <see cref="LayerType"/> of the moves that
/// produced it. The Gravograph post groups by layer to emit separate engrave
/// and cut passes (with a tool-change pause between them).
/// </summary>
public sealed class LayeredPolyline
{
public LayeredPolyline(List<Vector> points, LayerType layer)
{
Points = points;
Layer = layer;
}
public List<Vector> Points { get; }
public LayerType Layer { get; }
}
/// <summary>
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
/// polylines at rapid moves and at <see cref="LayerType"/> changes, and
/// tessellates arcs to a chord-deviation tolerance (the wire format takes
/// line segments only).
/// </summary>
public sealed class NestPolylineExtractor
{
public double ArcChordToleranceInches { get; set; } = 0.001;
/// <summary>
/// Extracts polylines from every non-cutoff part in every plate of the nest,
/// returning them in plate coordinates (inches). Layer information is dropped;
/// use <see cref="ExtractLayered(Nest)"/> to keep it.
/// </summary>
public List<List<Vector>> Extract(Nest nest)
{
return ExtractLayered(nest).Select(p => p.Points).ToList();
}
/// <summary>
/// Extracts polylines for a single part without layer information.
/// </summary>
public List<List<Vector>> ExtractPart(Part part)
{
return ExtractPartLayered(part).Select(p => p.Points).ToList();
}
/// <summary>
/// Extracts layer-tagged polylines from every non-cutoff part in every plate,
/// in plate coordinates (inches). Each polyline is layer-uniform.
/// </summary>
public List<LayeredPolyline> ExtractLayered(Nest nest)
{
if (nest == null) throw new ArgumentNullException(nameof(nest));
var result = new List<LayeredPolyline>();
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing != null && part.BaseDrawing.IsCutOff)
continue;
ExtractPart(part, result);
}
}
return result;
}
/// <summary>
/// Extracts layer-tagged polylines for a single part. Public so callers
/// driving the writer directly (e.g. from a console one-off) can use it.
/// </summary>
public List<LayeredPolyline> ExtractPartLayered(Part part)
{
var list = new List<LayeredPolyline>();
ExtractPart(part, list);
return list;
}
private void ExtractPart(Part part, List<LayeredPolyline> sink)
{
var program = part.Program;
if (program == null) return;
// The walk below treats Motion.EndPoint as absolute. Convert a working
// copy to absolute mode so G91 programs (the form OpenNest's UI writes)
// produce correct geometry. Cloning keeps part.Program untouched.
if (program.Mode == Mode.Incremental)
{
program = (Program)program.Clone();
program.Mode = Mode.Absolute;
}
var offset = part.Location;
var pos = new Vector(0, 0);
List<Vector> current = null;
var currentLayer = LayerType.Cut;
foreach (var code in program.Codes)
{
if (code is Motion m && m.Suppressed)
continue;
switch (code)
{
case RapidMove rapid:
{
FlushCurrent(sink, ref current, currentLayer);
pos = rapid.EndPoint;
break;
}
case LinearMove linear:
{
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset);
var end = linear.EndPoint;
current.Add(end + offset);
pos = end;
break;
}
case ArcMove arc:
{
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
pos = arc.EndPoint;
break;
}
}
}
FlushCurrent(sink, ref current, currentLayer);
}
// Ensures `current` is an open polyline whose layer matches `moveLayer`,
// seeded at `seed` (the current pen position). When the layer changes
// mid-chain the previous polyline is flushed and a new one begins at the
// shared seam vertex so engrave and cut passes stay geometrically continuous.
private static void StartOrSplit(List<LayeredPolyline> sink, ref List<Vector> current,
ref LayerType currentLayer, LayerType moveLayer, Vector seed)
{
if (current == null)
{
current = new List<Vector> { seed };
currentLayer = moveLayer;
}
else if (moveLayer != currentLayer)
{
FlushCurrent(sink, ref current, currentLayer);
current = new List<Vector> { seed };
currentLayer = moveLayer;
}
}
private static void FlushCurrent(List<LayeredPolyline> sink, ref List<Vector> current, LayerType layer)
{
if (current != null && current.Count >= 2)
sink.Add(new LayeredPolyline(current, layer));
current = null;
}
// Sample points along an arc to within chordTol of the true curve. start is
// the arc's start point (current pen position), arc.CenterPoint is absolute
// (G-code I/J in this codebase are stored as the absolute center), arc.EndPoint
// is absolute end. The starting point is assumed to already be in the polyline;
// intermediate samples and the endpoint are appended.
private static void TessellateArc(Vector start, ArcMove arc, Vector offset,
double chordTol, List<Vector> sink)
{
var c = arc.CenterPoint;
var r = c.DistanceTo(start);
if (r < 1e-9)
{
sink.Add(arc.EndPoint + offset);
return;
}
var a0 = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
var a1 = System.Math.Atan2(arc.EndPoint.Y - c.Y, arc.EndPoint.X - c.X);
double sweep;
if (arc.Rotation == RotationType.CW)
{
sweep = a0 - a1;
if (sweep <= 0) sweep += 2 * System.Math.PI;
}
else
{
sweep = a1 - a0;
if (sweep <= 0) sweep += 2 * System.Math.PI;
}
// Treat a near-zero sweep with coincident start/end as a full circle.
if (sweep < 1e-9 &&
System.Math.Abs(start.X - arc.EndPoint.X) < 1e-9 &&
System.Math.Abs(start.Y - arc.EndPoint.Y) < 1e-9)
{
sweep = 2 * System.Math.PI;
}
// Max angle step from chord-deviation tolerance: dev = r * (1 - cos(t/2)).
var maxAngleStep = 2.0 * System.Math.Acos(System.Math.Max(0.0, 1.0 - chordTol / r));
if (double.IsNaN(maxAngleStep) || maxAngleStep <= 0)
maxAngleStep = System.Math.PI / 32;
var steps = (int)System.Math.Ceiling(sweep / maxAngleStep);
if (steps < 1) steps = 1;
var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0;
for (int i = 1; i < steps; i++)
{
var t = sweep * (i / (double)steps);
var ang = a0 + direction * t;
var pt = new Vector(c.X + r * System.Math.Cos(ang), c.Y + r * System.Math.Sin(ang));
sink.Add(pt + offset);
}
sink.Add(arc.EndPoint + offset);
}
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Posts.GravographIS</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
<Target Name="CopyToPostsDir" AfterTargets="Build">
<PropertyGroup>
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
</PropertyGroup>
<MakeDir Directories="$(PostsDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
</Target>
</Project>
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// Geometry pre-pass for the Gravograph IS8000 backend. The machine is a dumb
/// executor — it never reorders geometry and always lifts between separate
/// entities — so we stitch shared-endpoint polylines together and reorder by
/// nearest-neighbor before encoding.
/// </summary>
public static class PolylinePrePass
{
public const double DefaultStitchTolerance = 1e-6;
/// <summary>
/// Joins polylines whose endpoints coincide (within <paramref name="tolerance"/>)
/// into single continuous polylines. Polylines with fewer than two points are
/// dropped. Direction is reversed as needed to make a join. Each input polyline
/// is copied — the inputs are not mutated.
/// </summary>
public static List<List<Vector>> Stitch(
IEnumerable<IReadOnlyList<Vector>> polylines,
double tolerance = DefaultStitchTolerance)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
var segs = new List<List<Vector>>();
foreach (var p in polylines)
{
if (p == null || p.Count < 2)
continue;
segs.Add(new List<Vector>(p));
}
bool changed;
do
{
changed = false;
for (int i = 0; i < segs.Count; i++)
{
var a = segs[i];
for (int j = 0; j < segs.Count; j++)
{
if (i == j) continue;
var b = segs[j];
// a-end ↔ b-start: append b to a (skip duplicated joint)
if (Near(a[a.Count - 1], b[0], tolerance))
{
for (int k = 1; k < b.Count; k++) a.Add(b[k]);
segs.RemoveAt(j);
if (j < i) i--;
changed = true;
break;
}
// a-end ↔ b-end: append reversed b to a
if (Near(a[a.Count - 1], b[b.Count - 1], tolerance))
{
for (int k = b.Count - 2; k >= 0; k--) a.Add(b[k]);
segs.RemoveAt(j);
if (j < i) i--;
changed = true;
break;
}
// a-start ↔ b-end: prepend b to a
if (Near(a[0], b[b.Count - 1], tolerance))
{
var combined = new List<Vector>(b.Count + a.Count - 1);
combined.AddRange(b);
for (int k = 1; k < a.Count; k++) combined.Add(a[k]);
segs[i] = combined;
segs.RemoveAt(j);
if (j < i) i--;
changed = true;
break;
}
// a-start ↔ b-start: prepend reversed b to a
if (Near(a[0], b[0], tolerance))
{
var combined = new List<Vector>(b.Count + a.Count - 1);
for (int k = b.Count - 1; k >= 0; k--) combined.Add(b[k]);
for (int k = 1; k < a.Count; k++) combined.Add(a[k]);
segs[i] = combined;
segs.RemoveAt(j);
if (j < i) i--;
changed = true;
break;
}
}
if (changed) break;
}
}
while (changed);
return segs;
}
/// <summary>
/// Greedy nearest-neighbor ordering of polylines starting from
/// <paramref name="origin"/> (defaults to 0,0 = the work origin = the first
/// polyline's first point on the wire). When <paramref name="allowReverse"/>
/// is true a polyline may be reversed if its tail is closer than its head.
/// </summary>
public static List<List<Vector>> Reorder(
IEnumerable<IReadOnlyList<Vector>> polylines,
bool allowReverse = true,
Vector? origin = null)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
var pool = new List<List<Vector>>();
foreach (var p in polylines)
{
if (p == null || p.Count < 2)
continue;
pool.Add(new List<Vector>(p));
}
var ordered = new List<List<Vector>>(pool.Count);
var current = origin ?? new Vector(0, 0);
while (pool.Count > 0)
{
var bestIdx = -1;
var bestReverse = false;
var bestDistSq = double.PositiveInfinity;
for (int i = 0; i < pool.Count; i++)
{
var p = pool[i];
var dHead = SquaredDistance(current, p[0]);
if (dHead < bestDistSq)
{
bestDistSq = dHead;
bestIdx = i;
bestReverse = false;
}
if (allowReverse)
{
var dTail = SquaredDistance(current, p[p.Count - 1]);
if (dTail < bestDistSq)
{
bestDistSq = dTail;
bestIdx = i;
bestReverse = true;
}
}
}
var pick = pool[bestIdx];
pool.RemoveAt(bestIdx);
if (bestReverse)
pick.Reverse();
ordered.Add(pick);
current = pick[pick.Count - 1];
}
return ordered;
}
/// <summary>
/// Convenience: stitch then reorder.
/// </summary>
public static List<List<Vector>> Prepare(
IEnumerable<IReadOnlyList<Vector>> polylines,
double stitchTolerance = DefaultStitchTolerance,
bool allowReverse = true,
Vector? origin = null)
{
var stitched = Stitch(polylines, stitchTolerance);
return Reorder(stitched, allowReverse, origin);
}
private static bool Near(Vector a, Vector b, double tol)
{
var dx = a.X - b.X;
var dy = a.Y - b.Y;
return (dx * dx + dy * dy) <= tol * tol;
}
private static double SquaredDistance(Vector a, Vector b)
{
var dx = a.X - b.X;
var dy = a.Y - b.Y;
return dx * dx + dy * dy;
}
}
}
@@ -0,0 +1,72 @@
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
namespace OpenNest.Tests.BestFit;
public class BestFitResultFrameTests
{
[Fact]
public void BuildCanonicalParts_NonAxisAlignedPairNormalizesActualBounds()
{
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
Assert.True(IsNonAxisAligned(result.OptimalRotation),
$"Expected a non-axis-aligned result, got {Angle.ToDegrees(result.OptimalRotation):F2} degrees.");
var parts = result.BuildCanonicalParts();
var bounds = result.GetCutBounds(parts);
Assert.Equal(0, bounds.Left, 3);
Assert.Equal(0, bounds.Bottom, 3);
Assert.Equal(result.BoundingWidth, bounds.Length, 2);
Assert.Equal(result.BoundingHeight, bounds.Width, 2);
}
[Fact]
public void BuildSourceParts_RebindsCanonicalResultToRotatedSourceDrawing()
{
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center);
drawing.RecomputeCanonicalAngle();
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
var parts = result.BuildSourceParts(drawing);
var bounds = result.GetCutBounds(parts);
Assert.All(parts, p => Assert.Same(drawing, p.BaseDrawing));
Assert.Equal(0, bounds.Left, 3);
Assert.Equal(0, bounds.Bottom, 3);
Assert.False(parts[0].Intersects(parts[1], out _));
}
private static BestFitResult EvaluateOffsetPair(Drawing drawing, Vector offset)
{
var candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = System.Math.PI,
Part2Offset = offset,
Spacing = 0.25
};
return new PairEvaluator().Evaluate(candidate);
}
private static bool IsNonAxisAligned(double angle)
{
var normalized = Angle.NormalizeRad(angle);
var nearestQuadrant = Angle.HalfPI * System.Math.Round(normalized / Angle.HalfPI);
var delta = System.Math.Abs(normalized - nearestQuadrant);
delta = System.Math.Min(delta, Angle.HalfPI - delta);
return delta > Angle.ToRadians(1);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.CNC
{
public class RapidEnumeratorTests
{
[Fact]
public void Enumerate_AbsoluteProgram_OffsetsMotionsByBasePos()
{
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(1, 0));
pgm.Codes.Add(new LinearMove(2, 0));
pgm.Codes.Add(new RapidMove(3, 3));
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
// Origin → first pierce, then interior rapid from contour end to next rapid target.
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(101, 200), segments[0].To);
Assert.Equal(new Vector(102, 200), segments[1].From);
Assert.Equal(new Vector(103, 203), segments[1].To);
}
[Fact]
public void Enumerate_IncrementalProgram_InterpretsDeltasFromBasePos()
{
// Pre-lead-in raw program: first rapid normalized to (0,0), Mode=Incremental
// (matches ConvertGeometry.ToProgram output).
var pgm = new Program(Mode.Incremental);
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(5, 0));
pgm.Codes.Add(new LinearMove(0, 5));
pgm.Codes.Add(new RapidMove(1, 1));
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
Assert.Equal(2, segments.Count);
// First rapid: plate origin → part pierce at basePos.
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(100, 200), segments[0].To);
// Interior rapid: after deltas (5,0) and (0,5) from basePos, rapid delta (1,1).
Assert.Equal(new Vector(105, 205), segments[1].From);
Assert.Equal(new Vector(106, 206), segments[1].To);
}
[Fact]
public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce()
{
// Main program: lead-in rapid, a line, then a SubProgramCall for a hole.
// Sub-program (incremental) starts with RapidMove(radius, 0) to the hole pierce.
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new RapidMove(0.5, 0));
sub.Codes.Add(new LinearMove(0, 0.1));
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(0.2, 0.3)); // first pierce (perimeter lead-in)
pgm.Codes.Add(new LinearMove(1.0, 1.0)); // contour move
pgm.Codes.Add(new SubProgramCall
{
Id = 1,
Program = sub,
Offset = new Vector(2, 2), // hole center (drawing-local)
});
var basePos = new Vector(100, 200); // part.Location
var segments = RapidEnumerator.Enumerate(pgm, basePos, startPos: new Vector(0, 0));
// Expected rapids:
// 1. origin → first pierce (0.2+100, 0.3+200) = (100.2, 200.3)
// 2. end of contour (1+100, 1+200) = (101, 201) → hole pierce (2+100+0.5, 2+200) = (102.5, 202)
// The sub's internal first rapid is skipped (already drawn in #2).
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(100.2, 200.3), segments[0].To);
Assert.Equal(new Vector(101, 201), segments[1].From);
Assert.Equal(new Vector(102.5, 202), segments[1].To);
}
}
}
@@ -0,0 +1,70 @@
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.Converters;
public class ConvertGeometryLayerTests
{
private static Program ProgramFor(Entity entity)
{
var shape = new Shape();
shape.Entities.Add(entity);
return ConvertGeometry.ToProgram(shape);
}
[Fact]
public void AddLine_EngraveLayer_TagsScribe()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddLine_EtchLayer_TagsScribe()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("etch") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddArc_EngraveLayer_TagsScribe()
{
var arc = new Arc(new Vector(0, 0), 1, 0, System.Math.PI / 2) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(arc);
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
Assert.NotEmpty(arcs);
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddCircle_EngraveLayer_TagsScribe()
{
var circle = new Circle(0, 0, 1) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(circle);
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
Assert.NotEmpty(arcs);
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddLine_DefaultLayer_StaysCut()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("0") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Cut, m.Layer));
}
}
@@ -0,0 +1,156 @@
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.Engine;
public class CanonicalAngleTests
{
private const double AngleTol = 0.002; // ~0.11°
private static Drawing MakeRect(double w, double h)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("rect", pgm);
}
private static Drawing RotateCopy(Drawing src, double angle)
{
var pgm = src.Program.Clone() as OpenNest.CNC.Program;
pgm.Rotate(angle, pgm.BoundingBox().Center);
return new Drawing("rotated", pgm);
}
[Fact]
public void AxisAlignedRectangle_ReturnsZero()
{
var d = MakeRect(100, 50);
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
// Program.BoundingBox() has a pre-existing bug where minX/minY initialize to 0 and can
// only decrease, so programs whose extents stay in the positive half-plane report a
// too-large AABB. To validate MBR-axis-alignment without tripping that bug, extract the
// outer perimeter polygon and compute its true AABB from vertices.
private static (double length, double width) TrueAabb(OpenNest.CNC.Program pgm)
{
var entities = ConvertProgram.ToGeometry(pgm).Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = ShapeBuilder.GetShapes(entities);
var outer = shapes.OrderByDescending(s => s.Area()).First();
var poly = outer.ToPolygonWithTolerance(0.1);
var minX = poly.Vertices.Min(v => v.X);
var maxX = poly.Vertices.Max(v => v.X);
var minY = poly.Vertices.Min(v => v.Y);
var maxY = poly.Vertices.Max(v => v.Y);
return (maxX - minX, maxY - minY);
}
[Theory]
[InlineData(0.3)]
[InlineData(0.7)]
[InlineData(1.2)]
public void Rectangle_ReturnsNegatedRotation_Modulo90(double theta)
{
var rotated = RotateCopy(MakeRect(100, 50), theta);
var angle = CanonicalAngle.Compute(rotated);
// Applying the returned angle should leave MBR axis-aligned.
var canonical = rotated.Program.Clone() as OpenNest.CNC.Program;
canonical.Rotate(angle, canonical.BoundingBox().Center);
var (length, width) = TrueAabb(canonical);
var longer = System.Math.Max(length, width);
var shorter = System.Math.Min(length, width);
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
}
[Fact]
public void NearZeroInput_SnapsToZero()
{
var rotated = RotateCopy(MakeRect(100, 50), 0.0005);
Assert.Equal(0.0, CanonicalAngle.Compute(rotated), precision: 6);
}
[Fact]
public void DegeneratePolygon_ReturnsZero()
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
var d = new Drawing("line", pgm);
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
[Fact]
public void EmptyProgram_ReturnsZero()
{
var d = new Drawing("empty", new OpenNest.CNC.Program());
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
}
public class DrawingCanonicalAngleWiringTests
{
private static OpenNest.CNC.Program RotatedRectProgram(double w, double h, double theta)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
if (!OpenNest.Math.Tolerance.IsEqualTo(theta, 0))
pgm.Rotate(theta, pgm.BoundingBox().Center);
return pgm;
}
[Fact]
public void Constructor_ComputesAngleOnProgramAssignment()
{
var pgm = RotatedRectProgram(100, 50, 0.5);
var d = new Drawing("r", pgm);
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
[Fact]
public void SetProgram_RecomputesAngle()
{
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
Assert.Equal(0.0, d.Source.Angle, precision: 6);
d.Program = RotatedRectProgram(100, 50, 0.5);
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
[Fact]
public void IsCutOff_SkipsAngleComputation()
{
var d = new Drawing("cut", RotatedRectProgram(100, 50, 0.5)) { IsCutOff = true };
// Re-assign after flag is set so the setter observes IsCutOff.
d.Program = RotatedRectProgram(100, 50, 0.5);
Assert.Equal(0.0, d.Source.Angle, precision: 6);
}
[Fact]
public void RecomputeCanonicalAngle_UpdatesAfterMutation()
{
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
Assert.Equal(0.0, d.Source.Angle, precision: 6);
// Mutate in-place (doesn't trigger setter).
d.Program.Rotate(0.5, d.Program.BoundingBox().Center);
Assert.Equal(0.0, d.Source.Angle, precision: 6); // still stale
d.RecomputeCanonicalAngle();
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.Engine;
public class CanonicalFrameTests
{
private static Drawing MakeRect(double w, double h, double rotation)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
if (!Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("rect", pgm) { Source = new SourceInfo { Angle = -rotation } };
}
[Fact]
public void AsCanonicalCopy_AxisAlignsMbr()
{
var d = MakeRect(100, 50, 0.6);
var canonical = CanonicalFrame.AsCanonicalCopy(d);
var bb = canonical.Program.BoundingBox();
var longer = System.Math.Max(bb.Length, bb.Width);
var shorter = System.Math.Min(bb.Length, bb.Width);
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
Assert.Equal(0.0, canonical.Source.Angle, precision: 6);
}
[Fact]
public void AsCanonicalCopy_DoesNotMutateSource()
{
var d = MakeRect(100, 50, 0.6);
var originalBbox = d.Program.BoundingBox();
var originalAngle = d.Source.Angle;
CanonicalFrame.AsCanonicalCopy(d);
var afterBbox = d.Program.BoundingBox();
Assert.Equal(originalBbox.Width, afterBbox.Width, precision: 6);
Assert.Equal(originalBbox.Length, afterBbox.Length, precision: 6);
Assert.Equal(originalAngle, d.Source.Angle, precision: 6);
}
[Fact]
public void FromCanonical_ComposesSourceAngleOntoRotation()
{
var d = MakeRect(100, 50, 0.0);
var part = new Part(d);
part.Rotate(0.2); // engine returned a canonical-frame part at R = 0.2
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, sourceAngle: -0.5);
// R' = R + sourceAngle = 0.2 + (-0.5) = -0.3
// Part.Rotation comes from Program.Rotation which is normalized to [0, 2PI),
// so compare after normalizing the expected value as well.
Assert.Single(placed);
Assert.Equal(Angle.NormalizeRad(-0.3), placed[0].Rotation, precision: 4);
}
[Fact]
public void RoundTrip_RestoresGeometry()
{
var d = MakeRect(100, 50, 0.4);
var canonical = CanonicalFrame.AsCanonicalCopy(d);
// Place a part at origin in the canonical frame.
var part = Part.CreateAtOrigin(canonical);
var canonicalBbox = part.BoundingBox;
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, d.Source.Angle);
var originalBbox = d.Program.BoundingBox();
Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, precision: 2);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Threading;
namespace OpenNest.Tests.Engine;
public class NestInvarianceTests
{
private static OpenNest.CNC.Program MakeLShapedProgram()
{
// L-shape: 100x50 outer rect with a 50x30 notch removed from top-right.
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return pgm;
}
private static Drawing MakeImportedAt(double rotation)
{
var pgm = MakeLShapedProgram();
if (!Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("L", pgm);
}
private static Plate MakePlate() => new Plate(new Size(500, 500))
{
Quadrant = 1,
PartSpacing = 2,
};
private static int RunFillCount(Drawing drawing, Plate plate)
{
BestFitCache.Clear();
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = drawing };
var parts = engine.Fill(item, plate.WorkArea(), progress: null, token: CancellationToken.None);
return parts?.Count ?? 0;
}
[Theory]
[InlineData(0.0)]
[InlineData(0.3)]
[InlineData(0.8)]
[InlineData(1.2)]
public void Fill_SameCount_AcrossImportOrientations(double theta)
{
var baseline = RunFillCount(MakeImportedAt(0.0), MakePlate());
var rotated = RunFillCount(MakeImportedAt(theta), MakePlate());
// Allow +/-1 tolerance for sweep quantization edge effects near plate boundaries.
Assert.InRange(rotated, baseline - 1, baseline + 1);
}
[Fact]
public void Fill_PlacedPartsStayWithinWorkArea_AcrossImportOrientations()
{
var plate = MakePlate();
var workArea = plate.WorkArea();
foreach (var theta in new[] { 0.0, 0.3, 0.8, 1.2 })
{
BestFitCache.Clear();
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = MakeImportedAt(theta) };
var parts = engine.Fill(item, workArea, progress: null, token: CancellationToken.None);
Assert.NotNull(parts);
foreach (var p in parts)
{
Assert.InRange(p.BoundingBox.Left, workArea.Left - 0.5, workArea.Right + 0.5);
Assert.InRange(p.BoundingBox.Bottom, workArea.Bottom - 0.5, workArea.Top + 0.5);
}
}
}
}
+177
View File
@@ -8,6 +8,76 @@ namespace OpenNest.Tests.Fill
{ {
public class CompactorTests public class CompactorTests
{ {
[Fact]
public void DirectionalDistance_ArcVsInclinedLine_DoesNotOverPush()
{
// Arc (top semicircle) pushed upward toward a 45° inclined line.
// The critical angle on the arc gives a shorter distance than any
// sampled vertex (endpoints + cardinal extremes).
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
var line = new Line(new Vector(3, 4), new Vector(7, 6));
var moving = new List<Entity> { arc };
var stationary = new List<Entity> { line };
var direction = new Vector(0, 1); // push up
var dist = SpatialQuery.DirectionalDistance(moving, stationary, direction);
// Move the arc up by the computed distance, then verify no overlap.
// The topmost reachable point on the arc at the critical angle θ ≈ 2.034
// (between π/2 and π) should just touch the line.
Assert.True(dist < double.MaxValue, "Should find a finite distance");
Assert.True(dist > 0, "Should be a positive distance");
// Verify: after moving, the closest point on the arc should be within
// tolerance of the line, not past it.
var theta = System.Math.Atan2(
line.pt2.X - line.pt1.X, -(line.pt2.Y - line.pt1.Y));
theta = OpenNest.Math.Angle.NormalizeRad(theta + System.Math.PI);
var qx = arc.Center.X + arc.Radius * System.Math.Cos(theta);
var qy = arc.Center.Y + arc.Radius * System.Math.Sin(theta) + dist;
// The moved point should be on or just touching the line, not past it.
// Line equation: (y - 4) / (x - 3) = (6 - 4) / (7 - 3) = 0.5
// y = 0.5x + 2.5
var lineYAtQx = 0.5 * qx + 2.5;
Assert.True(qy <= lineYAtQx + 0.001,
$"Arc point ({qx:F4}, {qy:F4}) should not be past line (line Y={lineYAtQx:F4} at X={qx:F4}). " +
$"dist={dist:F6}, overshot by {qy - lineYAtQx:F6}");
}
[Fact]
public void DirectionalDistance_ArcVsInclinedLine_BetterThanVertexSampling()
{
// Same geometry — verify the analytical Phase 3 finds a shorter
// distance than the Phase 1/2 vertex sampling alone would.
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
var line = new Line(new Vector(3, 4), new Vector(7, 6));
// Phase 1/2 vertex-only distance: sample arc endpoints + cardinal extreme.
var vertices = new[]
{
new Vector(7, 0), // arc endpoint θ=0
new Vector(3, 0), // arc endpoint θ=π
new Vector(5, 2), // cardinal extreme θ=π/2
};
var vertexMin = double.MaxValue;
foreach (var v in vertices)
{
var d = SpatialQuery.RayEdgeDistance(v.X, v.Y,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, 0, 1);
if (d < vertexMin) vertexMin = d;
}
// Full directional distance (includes Phase 3 arc-to-line).
var moving = new List<Entity> { arc };
var stationary = new List<Entity> { line };
var fullDist = SpatialQuery.DirectionalDistance(moving, stationary, new Vector(0, 1));
Assert.True(fullDist < vertexMin,
$"Full distance ({fullDist:F6}) should be less than vertex-only ({vertexMin:F6})");
}
private static Drawing MakeRectDrawing(double w, double h) private static Drawing MakeRectDrawing(double w, double h)
{ {
var pgm = new OpenNest.CNC.Program(); var pgm = new OpenNest.CNC.Program();
@@ -27,6 +97,33 @@ namespace OpenNest.Tests.Fill
return part; return part;
} }
private static Drawing MakeTriangleDrawing(params Vector[] points)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(points[0]));
for (var i = 1; i < points.Length; i++)
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[i]));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[0]));
return new Drawing("triangle", pgm);
}
private static Part MakeTrianglePart(params Vector[] points)
{
var part = new Part(MakeTriangleDrawing(points));
part.UpdateBounds();
return part;
}
private static Part MakeTrianglePart(double x, double y, params Vector[] points)
{
var part = MakeTrianglePart(points);
part.Location = new Vector(x, y);
part.UpdateBounds();
return part;
}
[Fact] [Fact]
public void Push_Left_MovesPartTowardEdge() public void Push_Left_MovesPartTowardEdge()
{ {
@@ -101,6 +198,86 @@ namespace OpenNest.Tests.Fill
Assert.NotEqual(distNoSpacing, distWithSpacing); Assert.NotEqual(distNoSpacing, distWithSpacing);
} }
[Fact]
public void Push_Up_AllowsSharedDiagonalEdgeToSeparate()
{
var workArea = new Box(0, 0, 20, 20);
var obstacle = MakeTrianglePart(
new Vector(0, 0),
new Vector(10, 0),
new Vector(0, 10));
var movingPart = MakeTrianglePart(
new Vector(0, 10),
new Vector(10, 0),
new Vector(10, 10));
var distance = Compactor.Push(
new List<Part> { movingPart },
new List<Part> { obstacle },
workArea,
0,
PushDirection.Up);
Assert.True(distance > 0);
Assert.True(movingPart.BoundingBox.Top > 19.9);
Assert.False(movingPart.Intersects(obstacle, out _));
}
[Fact]
public void Push_Up_MovesAfterRightTriangleIsPushedLeftIntoSharedEdge()
{
var workArea = new Box(0, 0, 24, 24);
var leftTriangle = MakeTrianglePart(
2, 2,
new Vector(0, 0),
new Vector(8, 0),
new Vector(4, 10));
var rightTriangle = MakeTrianglePart(
14, 4,
new Vector(0, 10),
new Vector(8, 10),
new Vector(4, 0));
var moving = new List<Part> { rightTriangle };
var obstacles = new List<Part> { leftTriangle };
var leftDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left);
var yBeforePushUp = rightTriangle.Location.Y;
var bottomBeforePushUp = rightTriangle.BoundingBox.Bottom;
var upDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Up);
Assert.True(leftDistance > 0);
Assert.True(upDistance > 0);
Assert.True(rightTriangle.Location.Y > yBeforePushUp);
Assert.True(rightTriangle.BoundingBox.Bottom > bottomBeforePushUp);
Assert.False(rightTriangle.Intersects(leftTriangle, out _));
}
[Fact]
public void Push_Left_BlocksWhenSharedDiagonalEdgeWouldOverlap()
{
var workArea = new Box(0, 0, 20, 20);
var obstacle = MakeTrianglePart(
new Vector(0, 0),
new Vector(10, 0),
new Vector(0, 10));
var movingPart = MakeTrianglePart(
new Vector(0, 10),
new Vector(10, 0),
new Vector(10, 10));
var distance = Compactor.Push(
new List<Part> { movingPart },
new List<Part> { obstacle },
workArea,
0,
PushDirection.Left);
Assert.Equal(0, distance);
Assert.Equal(0, movingPart.BoundingBox.Left);
}
[Fact] [Fact]
public void Push_AngleLeft_MovesPartTowardEdge() public void Push_AngleLeft_MovesPartTowardEdge()
{ {
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Geometry;
public class BoxComparisonTests
{
[Fact]
public void GreaterThan_TallerBox_ReturnsTrue()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(tall > short_);
Assert.False(short_ > tall);
}
[Fact]
public void GreaterThan_SameWidthLongerBox_ReturnsTrue()
{
var longer = new Box(0, 0, 20, 10);
var shorter = new Box(0, 0, 10, 10);
Assert.True(longer > shorter);
Assert.False(shorter > longer);
}
[Fact]
public void LessThan_ShorterBox_ReturnsTrue()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(short_ < tall);
Assert.False(tall < short_);
}
[Fact]
public void GreaterThanOrEqual_EqualBoxes_ReturnsTrue()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.True(a >= b);
Assert.True(b >= a);
}
[Fact]
public void LessThanOrEqual_EqualBoxes_ReturnsTrue()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.True(a <= b);
Assert.True(b <= a);
}
[Fact]
public void CompareTo_TallerBox_ReturnsPositive()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(tall.CompareTo(short_) > 0);
Assert.True(short_.CompareTo(tall) < 0);
}
[Fact]
public void CompareTo_EqualBoxes_ReturnsZero()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.Equal(0, a.CompareTo(b));
}
[Fact]
public void Sort_OrdersByWidthThenLength()
{
var boxes = new List<Box>
{
new Box(0, 0, 20, 10),
new Box(0, 0, 5, 30),
new Box(0, 0, 10, 10),
};
boxes.Sort();
Assert.Equal(10, boxes[0].Width);
Assert.Equal(10, boxes[0].Length);
Assert.Equal(10, boxes[1].Width);
Assert.Equal(20, boxes[1].Length);
Assert.Equal(30, boxes[2].Width);
}
}
@@ -1,14 +1,19 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math; using OpenNest.Math;
using Xunit; using Xunit;
using Xunit.Abstractions;
using System.Linq; using System.Linq;
namespace OpenNest.Tests.Geometry; namespace OpenNest.Tests.Geometry;
public class EllipseConverterTests public class EllipseConverterTests
{ {
private readonly ITestOutputHelper _output;
private const double Tol = 1e-10; private const double Tol = 1e-10;
public EllipseConverterTests(ITestOutputHelper output) => _output = output;
[Fact] [Fact]
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd() public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
{ {
@@ -244,6 +249,101 @@ public class EllipseConverterTests
} }
} }
[Fact]
public void DxfImport_ArcBoundingBoxes_Diagnostic()
{
var path = @"C:\Users\aisaacs\Desktop\11ga tab.dxf";
if (!System.IO.File.Exists(path)) return;
var result = Dxf.Import(path);
var all = (System.Collections.Generic.IEnumerable<IBoundable>)result.Entities;
var bbox = all.GetBoundingBox();
_output.WriteLine($"Overall: X={bbox.X:F4} Y={bbox.Y:F4} W={bbox.Length:F4} H={bbox.Width:F4}");
for (var i = 0; i < result.Entities.Count; i++)
{
var e = result.Entities[i];
var b = e.BoundingBox;
var flag = (b.Length > 1 || b.Width > 1) ? " ***" : "";
_output.WriteLine($"{i + 1,3}. {e.GetType().Name,-8} X={b.X:F4} Y={b.Y:F4} W={b.Length:F4} H={b.Width:F4}{flag}");
}
}
[Fact]
public void ToOpenNest_FlippedNormalZ_ProducesCorrectArcs()
{
var normal = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(-0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, 1)
};
var flipped = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(-0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, -1)
};
var normalArcs = normal.ToOpenNest();
var flippedArcs = flipped.ToOpenNest();
Assert.True(normalArcs.Count > 0);
Assert.True(flippedArcs.Count > 0);
Assert.True(normalArcs.All(e => e is Arc));
Assert.True(flippedArcs.All(e => e is Arc));
var normalFirst = (Arc)normalArcs.First();
var flippedFirst = (Arc)flippedArcs.First();
var normalStart = GetArcStart(normalFirst);
var flippedStart = GetArcStart(flippedFirst);
Assert.True(normalStart.X < 0, $"Normal ellipse start X should be negative, got {normalStart.X}");
Assert.True(flippedStart.X > 0, $"Flipped ellipse should bulge right, got {flippedStart.X}");
var normalBbox = GetBoundingBox(normalArcs.Cast<Arc>());
var flippedBbox = GetBoundingBox(flippedArcs.Cast<Arc>());
Assert.True(flippedBbox.minX > 0, $"Flipped ellipse should stay on positive X side, minX={flippedBbox.minX}");
Assert.True(normalBbox.maxX < 0, $"Normal ellipse should stay on negative X side, maxX={normalBbox.maxX}");
}
private static (double minX, double maxX) GetBoundingBox(IEnumerable<Arc> arcs)
{
var minX = double.MaxValue;
var maxX = double.MinValue;
foreach (var arc in arcs)
{
var s = GetArcStart(arc);
var e = GetArcEnd(arc);
minX = System.Math.Min(minX, System.Math.Min(s.X, e.X));
maxX = System.Math.Max(maxX, System.Math.Max(s.X, e.X));
}
return (minX, maxX);
}
private static Vector GetArcStart(Arc arc)
{
var angle = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static Vector GetArcEnd(Arc arc)
{
var angle = arc.IsReversed ? arc.StartAngle : arc.EndAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter, private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
double semiMajor, double semiMinor, double rotation, int samples) double semiMajor, double semiMinor, double rotation, int samples)
{ {
@@ -131,6 +131,111 @@ public class GeometrySimplifierTests
Assert.IsType<Arc>(result.Entities[6]); Assert.IsType<Arc>(result.Entities[6]);
} }
[Fact]
public void Analyze_FilletBetweenTangentLines_ArcIsTangentToLines()
{
// A 90-degree fillet (r=0.3, center origin, 270deg..360deg CCW) between two
// long tangent lines, approximated by 8 chords whose interior vertices bulge
// radially outward within tolerance (simulates real DXF tessellation noise).
var r = 0.3;
var deltas = new[] { 0.0, 0.002, 0.003, 0.0035, 0.0035, 0.0035, 0.003, 0.002, 0.0 };
var pts = new List<Vector>();
for (var i = 0; i <= 8; i++)
{
var ang = OpenNest.Math.Angle.ToRadians(270 + 11.25 * i);
var radius = r + deltas[i];
pts.Add(new Vector(radius * System.Math.Cos(ang), radius * System.Math.Sin(ang)));
}
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(-2, -r), pts[0]));
for (var i = 0; i < pts.Count - 1; i++)
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
shape.Entities.Add(new Line(pts[^1], new Vector(r, 2)));
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
var candidates = simplifier.Analyze(shape);
Assert.Single(candidates);
var arc = candidates[0].FittedArc;
// Arc must pass exactly through the run's boundary vertices (no gaps)
Assert.True(arc.StartPoint().DistanceTo(pts[0]) < 1e-6);
Assert.True(arc.EndPoint().DistanceTo(pts[^1]) < 1e-6);
// Arc must be tangent to the adjacent straight edges at its endpoints
var startDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.StartPoint()), new Vector(1, 0));
var endDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.EndPoint()), new Vector(0, 1));
Assert.True(startDelta < 0.3, $"Arc start not tangent to incoming line: off by {startDelta:F3} deg");
Assert.True(endDelta < 0.3, $"Arc end not tangent to outgoing line: off by {endDelta:F3} deg");
}
[Fact]
public void Analyze_CompoundCurve_AdjacentArcsAreTangentAtJunction()
{
// Two tangent-continuous arcs of different radii (r=0.2 sweeping 60deg, then
// r=0.6 sweeping 40deg), tessellated into chords with slight radial noise.
// The fitted arcs must stay tangent-continuous at their junction.
var c1 = new Vector(0, 0);
var r1 = 0.2;
var deltas1 = new[] { 0.0, 0.001, 0.0005, -0.0005, -0.001, -0.0005, 0.0 };
var pts = new List<Vector>();
for (var i = 0; i <= 6; i++)
{
var ang = OpenNest.Math.Angle.ToRadians(10 * i);
var radius = r1 + deltas1[i];
pts.Add(new Vector(c1.X + radius * System.Math.Cos(ang), c1.Y + radius * System.Math.Sin(ang)));
}
// Second arc center along the junction radius so tangents match at the junction
var junctionAngle = OpenNest.Math.Angle.ToRadians(60);
var u = new Vector(System.Math.Cos(junctionAngle), System.Math.Sin(junctionAngle));
var r2 = 0.6;
var c2 = new Vector(c1.X + u.X * (r1 - r2), c1.Y + u.Y * (r1 - r2));
var deltas2 = new[] { 0.0, 0.001, -0.001, 0.0005, -0.0005, 0.0 };
for (var i = 1; i <= 5; i++)
{
var ang = OpenNest.Math.Angle.ToRadians(60 + 8 * i);
var radius = r2 + deltas2[i];
pts.Add(new Vector(c2.X + radius * System.Math.Cos(ang), c2.Y + radius * System.Math.Sin(ang)));
}
var shape = new Shape();
for (var i = 0; i < pts.Count - 1; i++)
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
var candidates = simplifier.Analyze(shape);
Assert.Equal(2, candidates.Count);
var arcA = candidates[0].FittedArc;
var arcB = candidates[1].FittedArc;
// Arcs must share the junction vertex exactly
Assert.True(arcA.EndPoint().DistanceTo(arcB.StartPoint()) < 1e-6);
// Tangent continuity across the junction
var junctionDelta = AngleBetweenDeg(ArcTangentAt(arcA, arcA.EndPoint()), ArcTangentAt(arcB, arcB.StartPoint()));
Assert.True(junctionDelta < 0.3, $"Tangent break of {junctionDelta:F3} deg at arc-arc junction");
}
private static Vector ArcTangentAt(Arc arc, Vector pt)
{
var ang = System.Math.Atan2(pt.Y - arc.Center.Y, pt.X - arc.Center.X);
return arc.IsReversed
? new Vector(System.Math.Sin(ang), -System.Math.Cos(ang))
: new Vector(-System.Math.Sin(ang), System.Math.Cos(ang));
}
private static double AngleBetweenDeg(Vector v1, Vector v2)
{
var l1 = System.Math.Sqrt(v1.X * v1.X + v1.Y * v1.Y);
var l2 = System.Math.Sqrt(v2.X * v2.X + v2.Y * v2.Y);
var dot = (v1.X * v2.X + v1.Y * v2.Y) / (l1 * l2);
dot = System.Math.Max(-1, System.Math.Min(1, dot));
return System.Math.Acos(dot) * 180.0 / System.Math.PI;
}
[Fact] [Fact]
public void Apply_DynaPanDxf_NoGapsAfterSimplification() public void Apply_DynaPanDxf_NoGapsAfterSimplification()
{ {
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.Geometry;
public class WeldEndpointsTests
{
[Fact]
public void WeldEndpoints_SnapsNearbyLineEndpoints()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.0000005, 0, 20, 0);
var entities = new List<Entity> { line1, line2 };
ShapeBuilder.WeldEndpoints(entities, 0.000001);
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) <= Tolerance.Epsilon);
}
[Fact]
public void WeldEndpoints_SnapsArcEndpointByAdjustingAngle()
{
var line = new Line(0, 0, 10, 0);
var arc = new Arc(15, 0, 5, Angle.ToRadians(180.001), Angle.ToRadians(90));
var entities = new List<Entity> { line, arc };
ShapeBuilder.WeldEndpoints(entities, 0.01);
var arcStart = arc.StartPoint();
Assert.True(line.EndPoint.DistanceTo(arcStart) <= 0.01);
}
[Fact]
public void WeldEndpoints_DoesNotWeldDistantEndpoints()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.1, 0, 20, 0);
var entities = new List<Entity> { line1, line2 };
ShapeBuilder.WeldEndpoints(entities, 0.000001);
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) > 0.01);
}
[Fact]
public void GetShapes_WithWeldTolerance_WeldsBeforeChaining()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.0000005, 0, 10.0000005, 10);
var entities = new List<Entity> { line1, line2 };
var shapes = ShapeBuilder.GetShapes(entities, weldTolerance: 0.000001);
Assert.Single(shapes);
Assert.Equal(2, shapes[0].Entities.Count);
}
[Fact]
public void GetShapes_WithoutWeldTolerance_DefaultBehavior()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10, 0, 10, 10);
var entities = new List<Entity> { line1, line2 };
var shapes = ShapeBuilder.GetShapes(entities);
Assert.Single(shapes);
Assert.Equal(2, shapes[0].Entities.Count);
}
}
@@ -0,0 +1,165 @@
using System.Collections.Generic;
using System.IO;
using OpenNest.Geometry;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.GravographIS;
public class EnvelopeGuardTests
{
// 0.610 m / 0.0125 mm/step = 48 800 steps = 24.0157 inches
// 1.220 m / 0.0125 mm/step = 97 600 steps = 48.0315 inches
[Fact]
public void NegativeX_FromOrigin_Throws()
{
// Operator origin is upper-left; quadrant 4 walks right/down. A cut that walks
// left of origin in -X must be refused.
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(-1, 0) },
};
var ex = Assert.Throws<System.InvalidOperationException>(() =>
{
using var ms = new MemoryStream();
new GravographISWriter().Write(polylines, ms);
});
Assert.Contains("Polyline 1", ex.Message);
Assert.Contains("cut segment", ex.Message);
Assert.Contains("segment 1", ex.Message);
}
[Fact]
public void PositiveY_FromOrigin_Throws()
{
// Positive input-Y is above the upper-left origin in quadrant 4.
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(0, 1) },
};
var ex = Assert.Throws<System.InvalidOperationException>(() =>
{
using var ms = new MemoryStream();
new GravographISWriter().Write(polylines, ms);
});
Assert.Contains("Polyline 1", ex.Message);
}
[Fact]
public void XExceedsEnvelope_Throws_AndNamesSegment()
{
// 25" in X is past the 0.610 m (~24.02") envelope.
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(10, 0), new Vector(25, 0) },
};
var ex = Assert.Throws<System.InvalidOperationException>(() =>
{
using var ms = new MemoryStream();
new GravographISWriter().Write(polylines, ms);
});
Assert.Contains("Polyline 1", ex.Message);
Assert.Contains("segment 2", ex.Message); // 0→10 ok; 10→25 trips
Assert.Contains("25.000\"", ex.Message);
}
[Fact]
public void YExceedsEnvelope_Throws()
{
// -49" in Y is past the 1.220 m (~48.03") envelope.
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(0, -49) },
};
Assert.Throws<System.InvalidOperationException>(() =>
{
using var ms = new MemoryStream();
new GravographISWriter().Write(polylines, ms);
});
}
[Fact]
public void PenUpTravel_OutsideEnvelope_AlsoThrows_AndIsLabeledTravel()
{
// Polyline 1 ends in-envelope; the PU travel to polyline 2 leaves it.
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(1, 0) },
new[] { new Vector(30, 0), new Vector(30, -1) },
};
var ex = Assert.Throws<System.InvalidOperationException>(() =>
{
using var ms = new MemoryStream();
new GravographISWriter().Write(polylines, ms);
});
Assert.Contains("Polyline 2", ex.Message);
Assert.Contains("pen-up travel", ex.Message);
}
[Fact]
public void RightAtEnvelopeCorner_IsAllowed()
{
// Walk to (24", -48") in int16-sized hops (each delta < 16.1"). The
// catalog envelope is 24.02" × 48.03", so this lands just inside.
var polylines = new List<IReadOnlyList<Vector>>
{
new[]
{
new Vector(0, 0),
new Vector(8, -16),
new Vector(16, -32),
new Vector(24, -48),
},
};
using var ms = new MemoryStream();
new GravographISWriter().Write(polylines, ms); // no throw
Assert.True(ms.Length > 0);
}
[Fact]
public void EnvelopeGuard_CanBeDisabled_ForOffMachineEncoding()
{
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(-5, 0) },
};
var opts = new GravographISWriterOptions { EnvelopeGuardEnabled = false };
using var ms = new MemoryStream();
new GravographISWriter(opts).Write(polylines, ms); // no throw
Assert.True(ms.Length > 0);
}
[Fact]
public void CustomEnvelope_TightensTheCheck()
{
// Restrict to 1" × 1" — a 2" line in -Y now overshoots.
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(0, -2) },
};
var opts = new GravographISWriterOptions
{
WorkEnvelopeXMm = 25.4,
WorkEnvelopeYMm = 25.4,
};
Assert.Throws<System.InvalidOperationException>(() =>
{
using var ms = new MemoryStream();
new GravographISWriter(opts).Write(polylines, ms);
});
}
}
@@ -0,0 +1,51 @@
using OpenNest.CNC;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.GravographIS;
public class GravographISPostConfigTests
{
[Fact]
public void Defaults_EngraveBlock_NoPause_FasterFeed()
{
var config = new GravographISPostConfig();
Assert.Equal(10, config.Engrave.FeedMmPerSec);
Assert.False(config.Engrave.PauseBefore);
}
[Fact]
public void Defaults_CutBlock_PausesToChangeTool()
{
var config = new GravographISPostConfig();
Assert.Equal(3, config.Cut.FeedMmPerSec);
Assert.True(config.Cut.PauseBefore);
Assert.Equal("Change tool", config.Cut.PauseMessage);
}
[Theory]
[InlineData(LayerType.Scribe)]
public void ConfigFor_Scribe_ReturnsEngraveBlock(LayerType layer)
{
var config = new GravographISPostConfig();
Assert.Same(config.Engrave, config.ConfigFor(layer));
}
[Theory]
[InlineData(LayerType.Cut)]
[InlineData(LayerType.Leadin)]
[InlineData(LayerType.Leadout)]
public void ConfigFor_CutLayers_ReturnCutBlock(LayerType layer)
{
var config = new GravographISPostConfig();
Assert.Same(config.Cut, config.ConfigFor(layer));
}
[Fact]
public void ConfigFor_Display_ReturnsNull_SoItIsSkipped()
{
var config = new GravographISPostConfig();
Assert.Null(config.ConfigFor(LayerType.Display));
}
}
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.GravographIS;
public class GravographISPostProcessorTests
{
private static LayeredPolyline Poly(LayerType layer, params Vector[] pts)
=> new LayeredPolyline(new List<Vector>(pts), layer);
[Fact]
public void BuildPasses_EngraveAndCut_OrdersEngraveFirstThenCutWithPause()
{
var post = new GravographISPostProcessor();
var passes = post.BuildPasses(new[]
{
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
Poly(LayerType.Scribe, new Vector(0, 0), new Vector(0, 1)),
});
Assert.Equal(2, passes.Count);
Assert.Equal(post.Config.Engrave.FeedMmPerSec, passes[0].FeedMmPerSec);
Assert.False(passes[0].PauseBefore);
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[1].FeedMmPerSec);
Assert.True(passes[1].PauseBefore);
Assert.Equal("Change tool", passes[1].PauseMessage);
}
[Fact]
public void BuildPasses_CutOnly_IsSinglePass()
{
var post = new GravographISPostProcessor();
var passes = post.BuildPasses(new[]
{
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
});
Assert.Single(passes);
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
}
[Fact]
public void BuildPasses_SkipsDisplayGeometry()
{
var post = new GravographISPostProcessor();
var passes = post.BuildPasses(new[]
{
Poly(LayerType.Display, new Vector(0, 0), new Vector(1, 0)),
Poly(LayerType.Cut, new Vector(0, 0), new Vector(0, 1)),
});
Assert.Single(passes);
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
}
[Fact]
public void Config_IsExposedThroughConfigurableInterface()
{
var post = new GravographISPostProcessor(new GravographISPostConfig());
OpenNest.IConfigurablePostProcessor configurable = post;
Assert.Same(post.Config, configurable.Config);
}
}
@@ -0,0 +1,318 @@
using System.Collections.Generic;
using System.IO;
using OpenNest.Geometry;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.GravographIS;
public class GravographISWriterTests
{
// 93-byte preamble captured from GravoStyle'98 (VS/VZ=35, DZ=508 → matches defaults).
// The original capture ended with a DR command (FF FD 44 52) followed by three
// 8-byte int16 records carrying a chunked job-specific travel (~1" X, ~47" Y).
// Stripped from the writer (see GravographISWriter.PreambleTemplate) because
// those frozen deltas send the head to a fixed point regardless of the job. The
// writer now emits a job-specific leading DR travel from operator zero instead.
private const string PreambleHex =
"21 41 53 20 33 38 3b 01 90 01 f4 01 90 01 f4 01 90 01 f4 00 00 00 00 00 00 00 00 00 00 " +
"00 00 00 09 00 00 03 e8 05 06 00 00 00 00 00 00 ff fd 32 44 00 00 ff fd 4d 43 00 01 ff fd " +
"4f 55 ff fb ff fd 4f 55 ff fa ff fd 50 5a 00 00 ff fd 56 53 00 23 ff fd 56 5a 00 23 ff fd " +
"44 5a 01 fc";
// Legacy 36-byte tail with lift, aux off, motor off, operator beep, job finish.
// Byte-exact capture tests disable dynamic return-to-origin to preserve this form.
private const string PostambleHex =
"ff fd 50 55 00 01 ff fd 4f 55 ff fa ff fd 4f 55 ff fb ff fd 4d 43 00 00 " +
"ff fd 4f 50 00 00 ff fd 4a 46 00 00";
[Fact]
public void TestA_SingleTwoInchVerticalLine_IsByteExact()
{
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(1, 1), new Vector(1, 3) },
};
var writer = new GravographISWriter(new GravographISWriterOptions
{
DepthInches = 0.25,
FeedMmPerSec = 35,
EnvelopeGuardEnabled = false,
ReturnToOriginAtEnd = false,
});
using var ms = new MemoryStream();
writer.Write(polylines, ms);
const string GeomHex =
"ff fd 44 52 00 00 2d 41 00 80 07 f0 f8 10 " +
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20";
var expected = HexToBytes(PreambleHex + " " + GeomHex + " " + PostambleHex);
Assert.Equal(expected, ms.ToArray());
}
[Fact]
public void TestB_FourLines_IsByteExact()
{
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(1, 1), new Vector(1, 3) },
new[] { new Vector(4, 1), new Vector(4, 3) },
new[] { new Vector(4, 5), new Vector(4, 7) },
new[] { new Vector(1, 5), new Vector(1, 7) },
};
var writer = new GravographISWriter(new GravographISWriterOptions
{
DepthInches = 0.25,
FeedMmPerSec = 35,
EnvelopeGuardEnabled = false,
ReturnToOriginAtEnd = false,
});
using var ms = new MemoryStream();
writer.Write(polylines, ms);
const string GeomHex =
"ff fd 44 52 00 00 2d 41 00 80 07 f0 f8 10 " +
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
"ff fd 50 55 00 00 35 40 00 b4 17 d0 0f e0 " +
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
"ff fd 50 55 00 00 40 00 00 b4 00 00 f0 20 " +
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
"ff fd 50 55 00 00 35 40 00 b4 e8 30 0f e0 " +
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20";
var expected = HexToBytes(PreambleHex + " " + GeomHex + " " + PostambleHex);
Assert.Equal(expected, ms.ToArray());
}
[Fact]
public void LeadingDR_TravelsToFirstPolylineStartBeforePD()
{
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(2, 2), new Vector(3, 2) },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false }).Write(polylines, ms);
var bytes = ms.ToArray();
// First command after the 93-byte preamble must be DR to the first point,
// followed by PD for the first cut.
Assert.Equal(0xFF, bytes[93]);
Assert.Equal(0xFD, bytes[94]);
Assert.Equal((byte)'D', bytes[95]);
Assert.Equal((byte)'R', bytes[96]);
Assert.Equal(0xFF, bytes[107]);
Assert.Equal(0xFD, bytes[108]);
Assert.Equal((byte)'P', bytes[109]);
Assert.Equal((byte)'D', bytes[110]);
}
[Fact]
public void LeadingDR_LongTravel_IsChunked()
{
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(1, 47), new Vector(2, 47) },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false }).Write(polylines, ms);
var bytes = ms.ToArray();
Assert.Equal((byte)'D', bytes[95]);
Assert.Equal((byte)'R', bytes[96]);
Assert.Equal((byte)'P', bytes[125]);
Assert.Equal((byte)'D', bytes[126]);
}
[Fact]
public void OptionsPatchVsVzDz()
{
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(0.5, 0) },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions
{
DepthInches = 0.125, // 254 steps = 0x00FE
FeedMmPerSec = 50, // 0x0032
}).Write(polylines, ms);
var bytes = ms.ToArray();
AssertOperand(bytes, (byte)'V', (byte)'S', 0x00, 0x32);
AssertOperand(bytes, (byte)'V', (byte)'Z', 0x00, 0x32);
AssertOperand(bytes, (byte)'D', (byte)'Z', 0x00, 0xFE);
}
[Fact]
public void ReturnsToOriginAfterFinalLift_ByDefault()
{
var polylines = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(1, 0), new Vector(1, -1) },
};
using var ms = new MemoryStream();
new GravographISWriter().Write(polylines, ms);
var bytes = ms.ToArray();
var liftIndex = LastIndexOfCommand(bytes, (byte)'P', (byte)'U', 0x00, 0x01);
Assert.True(liftIndex >= 0);
Assert.Equal(0xFF, bytes[liftIndex + 6]);
Assert.Equal(0xFD, bytes[liftIndex + 7]);
Assert.Equal((byte)'P', bytes[liftIndex + 8]);
Assert.Equal((byte)'U', bytes[liftIndex + 9]);
var dx = ReadInt16(bytes, liftIndex + 16);
var dy = ReadInt16(bytes, liftIndex + 18);
Assert.Equal(-GravographISWriter.StepsPerInch, dx);
Assert.Equal(-GravographISWriter.StepsPerInch, dy);
}
[Fact]
public void Passes_PauseBeforeCut_EmitsPauseSequenceBetweenGroups()
{
var engrave = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } };
var cut = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } };
var passes = new List<GravographPass>
{
new GravographPass { Polylines = engrave, FeedMmPerSec = 10, DepthInches = 0.25 },
new GravographPass { Polylines = cut, FeedMmPerSec = 3, DepthInches = 0.25, PauseBefore = true, PauseMessage = "Hi" },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions
{
EnvelopeGuardEnabled = false,
ReturnToOriginAtEnd = false,
}).Write(passes, ms);
var bytes = ms.ToArray();
var mcOff = IndexOf(bytes, 0, (byte)'M', (byte)'C', 0x00, 0x00);
var ouFb = IndexOf(bytes, mcOff, (byte)'O', (byte)'U', 0xFF, 0xFB);
var ouFa = IndexOf(bytes, ouFb, (byte)'O', (byte)'U', 0xFF, 0xFA);
var lbBegin = IndexOf(bytes, ouFa, (byte)'L', (byte)'B', 0x00, 0x00);
var lbMsg = IndexOf(bytes, lbBegin, (byte)'L', (byte)'B', (byte)'H', (byte)'i');
var nr = IndexOf(bytes, lbMsg, (byte)'N', (byte)'R', 0x00, 0x01);
var lbEnd = IndexOf(bytes, nr, (byte)'L', (byte)'B', 0x00, 0x01);
var mcOn = IndexOf(bytes, lbEnd, (byte)'M', (byte)'C', 0x00, 0x01);
Assert.True(mcOff >= 0, "motor-off (MC 0000) not found");
Assert.True(mcOff < ouFb && ouFb < ouFa && ouFa < lbBegin && lbBegin < lbMsg
&& lbMsg < nr && nr < lbEnd && lbEnd < mcOn,
"pause commands out of order");
// Resume sets the cut feed inline (VS 0x0003) after the motor restarts.
var vsCut = IndexOf(bytes, mcOn, (byte)'V', (byte)'S', 0x00, 0x03);
Assert.True(vsCut > mcOn, "cut feed not set after resume");
}
[Fact]
public void Passes_PauseOddMessage_SpacePadsLastPacket()
{
var passes = new List<GravographPass>
{
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = true, PauseMessage = "abc" },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
var bytes = ms.ToArray();
var lbAb = IndexOf(bytes, 0, (byte)'L', (byte)'B', (byte)'a', (byte)'b');
var lbCPad = IndexOf(bytes, lbAb, (byte)'L', (byte)'B', (byte)'c', 0x20);
Assert.True(lbAb >= 0, "first message packet 'ab' not found");
Assert.True(lbCPad > lbAb, "odd packet not space-padded to 'c '");
}
[Fact]
public void Passes_DifferentFeeds_NoPause_EmitsInlineFeedChangeNoMessage()
{
var passes = new List<GravographPass>
{
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = false },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
var bytes = ms.ToArray();
Assert.True(IndexOf(bytes, 0, (byte)'V', (byte)'S', 0x00, 0x03) >= 0, "inline cut feed change missing");
Assert.True(IndexOfCmd(bytes, (byte)'L', (byte)'B') < 0, "no LB message expected without a pause");
}
private static int IndexOf(byte[] bytes, int from, byte c0, byte c1, byte hi, byte lo)
{
for (var i = System.Math.Max(0, from); i <= bytes.Length - 6; i++)
{
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 &&
bytes[i + 3] == c1 && bytes[i + 4] == hi && bytes[i + 5] == lo)
return i;
}
return -1;
}
private static int IndexOfCmd(byte[] bytes, byte c0, byte c1)
{
for (var i = 0; i <= bytes.Length - 4; i++)
{
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 && bytes[i + 3] == c1)
return i;
}
return -1;
}
private static void AssertOperand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
{
for (var i = 0; i < bytes.Length - 5; i++)
{
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 && bytes[i + 3] == c1)
{
Assert.Equal(hi, bytes[i + 4]);
Assert.Equal(lo, bytes[i + 5]);
return;
}
}
Assert.Fail($"Command {(char)c0}{(char)c1} not found in stream.");
}
private static int LastIndexOfCommand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
{
for (var i = bytes.Length - 6; i >= 0; i--)
{
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD &&
bytes[i + 2] == c0 && bytes[i + 3] == c1 &&
bytes[i + 4] == hi && bytes[i + 5] == lo)
{
return i;
}
}
return -1;
}
private static short ReadInt16(byte[] bytes, int offset)
{
return unchecked((short)((bytes[offset] << 8) | bytes[offset + 1]));
}
internal static byte[] HexToBytes(string hex)
{
var clean = hex.Replace(" ", string.Empty).Replace("\n", string.Empty).Replace("\r", string.Empty);
var bytes = new byte[clean.Length / 2];
for (var i = 0; i < bytes.Length; i++)
bytes[i] = System.Convert.ToByte(clean.Substring(i * 2, 2), 16);
return bytes;
}
}
@@ -0,0 +1,78 @@
using OpenNest;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.GravographIS;
public class NestPolylineExtractorTests
{
[Fact]
public void ExtractPart_IncrementalProgram_ProducesAbsoluteCoordinates()
{
// 1x1 square in G91 (incremental) mode — the form OpenNest's UI writes
// to .nest files. Without absolute-mode handling the extractor plotted
// each EndPoint as if it were absolute, producing a 2x2 diamond.
var program = new Program(Mode.Incremental);
program.Codes.Add(new RapidMove(new Vector(0, 0)));
program.Codes.Add(new LinearMove(1, 0));
program.Codes.Add(new LinearMove(0, 1));
program.Codes.Add(new LinearMove(-1, 0));
program.Codes.Add(new LinearMove(0, -1));
var drawing = new Drawing("Square 1x1", program);
var part = new Part(drawing, new Vector(0.25, 46.75));
var polylines = new NestPolylineExtractor().ExtractPart(part);
Assert.Single(polylines);
var poly = polylines[0];
Assert.Equal(5, poly.Count);
Assert.Equal(new Vector(0.25, 46.75), poly[0]);
Assert.Equal(new Vector(1.25, 46.75), poly[1]);
Assert.Equal(new Vector(1.25, 47.75), poly[2]);
Assert.Equal(new Vector(0.25, 47.75), poly[3]);
Assert.Equal(new Vector(0.25, 46.75), poly[4]);
}
[Fact]
public void ExtractPartLayered_SplitsContinuousChainAtLayerChange()
{
// A single continuous chain (no rapid) that switches from Scribe to Cut
// must be split into two layer-uniform polylines sharing the seam vertex,
// so the post can emit a tool-change pause between engrave and cut.
var program = new Program(Mode.Absolute);
program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Scribe });
program.Codes.Add(new LinearMove(2, 0) { Layer = LayerType.Scribe });
program.Codes.Add(new LinearMove(2, 1) { Layer = LayerType.Cut });
program.Codes.Add(new LinearMove(3, 1) { Layer = LayerType.Cut });
var drawing = new Drawing("Mixed", program);
var part = new Part(drawing, new Vector(0, 0));
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
Assert.Equal(2, polylines.Count);
Assert.Equal(LayerType.Scribe, polylines[0].Layer);
Assert.Equal(new[] { new Vector(0, 0), new Vector(1, 0), new Vector(2, 0) }, polylines[0].Points);
Assert.Equal(LayerType.Cut, polylines[1].Layer);
Assert.Equal(new[] { new Vector(2, 0), new Vector(2, 1), new Vector(3, 1) }, polylines[1].Points);
}
[Fact]
public void ExtractPartLayered_UniformChain_IsSinglePolyline()
{
var program = new Program(Mode.Absolute);
program.Codes.Add(new LinearMove(1, 0));
program.Codes.Add(new LinearMove(1, 1));
var part = new Part(new Drawing("Cut", program), new Vector(0, 0));
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
Assert.Single(polylines);
Assert.Equal(LayerType.Cut, polylines[0].Layer);
}
}
@@ -0,0 +1,164 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.GravographIS;
public class PolylinePrePassTests
{
[Fact]
public void Stitch_TwoConnectedSegments_BecomeOnePolyline()
{
var inputs = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(1, 0) },
new[] { new Vector(1, 0), new Vector(1, 1) },
};
var stitched = PolylinePrePass.Stitch(inputs);
Assert.Single(stitched);
Assert.Equal(3, stitched[0].Count);
Assert.Equal(new Vector(0, 0), stitched[0][0]);
Assert.Equal(new Vector(1, 0), stitched[0][1]);
Assert.Equal(new Vector(1, 1), stitched[0][2]);
}
[Fact]
public void Stitch_FourSegmentsFormingClosedSquare_BecomeOnePolyline()
{
var inputs = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(1, 0) },
new[] { new Vector(1, 0), new Vector(1, 1) },
new[] { new Vector(1, 1), new Vector(0, 1) },
new[] { new Vector(0, 1), new Vector(0, 0) },
};
var stitched = PolylinePrePass.Stitch(inputs);
Assert.Single(stitched);
// Four edges + closing return-to-start = five vertices.
Assert.Equal(5, stitched[0].Count);
}
[Fact]
public void Stitch_ReversesOneSegmentToMakeAJoin()
{
// Second segment is given backward; stitcher should reverse it.
var inputs = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(1, 0) },
new[] { new Vector(2, 0), new Vector(1, 0) },
};
var stitched = PolylinePrePass.Stitch(inputs);
Assert.Single(stitched);
Assert.Equal(3, stitched[0].Count);
Assert.Equal(new Vector(0, 0), stitched[0][0]);
Assert.Equal(new Vector(2, 0), stitched[0][stitched[0].Count - 1]);
}
[Fact]
public void Stitch_DisjointSegments_StayDistinct()
{
var inputs = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(1, 0) },
new[] { new Vector(5, 5), new Vector(6, 5) },
};
var stitched = PolylinePrePass.Stitch(inputs);
Assert.Equal(2, stitched.Count);
}
[Fact]
public void Stitch_DropsZeroAndSinglePointPolylines()
{
var inputs = new List<IReadOnlyList<Vector>>
{
new Vector[] { },
new[] { new Vector(0, 0) },
new[] { new Vector(0, 0), new Vector(1, 0) },
};
var stitched = PolylinePrePass.Stitch(inputs);
Assert.Single(stitched);
Assert.Equal(2, stitched[0].Count);
}
[Fact]
public void Reorder_ReducesTotalPenUpTravelVsWorstCase()
{
// Three short polylines at (0,0), (10,0), (5,0). The greedy NN starting
// from origin should pick (0,0)→(5,0)→(10,0) (travels of 4 + 4 ≈ 8) over
// the worst-case input order (0,0)→(10,0)→(5,0) (travels 9 + 4 ≈ 13).
var inputs = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(1, 0) },
new[] { new Vector(10, 0), new Vector(11, 0) },
new[] { new Vector(5, 0), new Vector(6, 0) },
};
var reordered = PolylinePrePass.Reorder(inputs);
Assert.Equal(3, reordered.Count);
var travelBefore = TotalPenUpTravel(inputs);
var travelAfter = TotalPenUpTravel(reordered);
Assert.True(travelAfter < travelBefore,
$"Expected reorder to reduce pen-up travel; before={travelBefore}, after={travelAfter}");
}
[Fact]
public void Reorder_ReversesPolylineIfTailIsCloser()
{
// Origin (0,0); a single polyline whose tail is much closer to origin
// than its head. Reorder should flip it.
var inputs = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(10, 0), new Vector(0.5, 0) },
};
var reordered = PolylinePrePass.Reorder(inputs, allowReverse: true);
Assert.Single(reordered);
Assert.Equal(new Vector(0.5, 0), reordered[0][0]);
Assert.Equal(new Vector(10, 0), reordered[0][1]);
}
[Fact]
public void Reorder_ReverseDisabled_KeepsDirection()
{
var inputs = new List<IReadOnlyList<Vector>>
{
new[] { new Vector(10, 0), new Vector(0.5, 0) },
};
var reordered = PolylinePrePass.Reorder(inputs, allowReverse: false);
Assert.Single(reordered);
Assert.Equal(new Vector(10, 0), reordered[0][0]);
Assert.Equal(new Vector(0.5, 0), reordered[0][1]);
}
private static double TotalPenUpTravel(IEnumerable<IReadOnlyList<Vector>> polylines)
{
var total = 0.0;
Vector? last = null;
foreach (var p in polylines)
{
if (p == null || p.Count < 2) continue;
if (last.HasValue)
{
var dx = p[0].X - last.Value.X;
var dy = p[0].Y - last.Value.Y;
total += System.Math.Sqrt(dx * dx + dy * dy);
}
last = p[p.Count - 1];
}
return total;
}
}
+180
View File
@@ -0,0 +1,180 @@
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.IO;
public class ChrFontTests
{
private ChrFont LoadFont()
{
var path = TestConfig.GetExistingPath("ChrFontPath");
Skip.If(path == null, "ChrFontPath not configured in test-config.json or file not found");
return ChrFont.Read(path);
}
[SkippableFact]
public void Read_ParsesFontName()
{
var font = LoadFont();
Assert.Equal("US BLOCK 1L", font.Name);
}
[SkippableFact]
public void Read_ParsesVersion()
{
var font = LoadFont();
Assert.StartsWith("C1.", font.Version);
}
[SkippableFact]
public void Read_HasAsciiGlyphs()
{
var font = LoadFont();
Assert.True(font.HasGlyph('A'));
Assert.True(font.HasGlyph('Z'));
Assert.True(font.HasGlyph('0'));
Assert.True(font.HasGlyph(' '));
}
[SkippableFact]
public void Read_HasExtendedGlyphs()
{
var font = LoadFont();
Assert.True(font.HasGlyph(0xC7)); // C-cedilla
}
[SkippableFact]
public void Glyph_L_ProducesLines()
{
var font = LoadFont();
var glyph = font.GetGlyph('L');
Assert.NotNull(glyph);
var entities = glyph.ToEntities(1.0, 0, 0);
Assert.True(entities.Count >= 2, $"Expected at least 2 entities for 'L', got {entities.Count}");
Assert.All(entities, e => Assert.Equal(EntityType.Line, e.Type));
}
[SkippableFact]
public void Glyph_O_ProducesEntities()
{
var font = LoadFont();
var glyph = font.GetGlyph('O');
Assert.NotNull(glyph);
var entities = glyph.ToEntities(1.0, 0, 0);
Assert.True(entities.Count > 0);
}
[SkippableFact]
public void RenderText_ProducesEntities()
{
var font = LoadFont();
var entities = font.RenderText("HELLO", 1.0, new Vector(0, 0));
Assert.True(entities.Count > 0, "RenderText should produce entities");
}
[SkippableFact]
public void RenderText_ScalesCorrectly()
{
var font = LoadFont();
var small = font.RenderText("A", 0.5, Vector.Zero);
var large = font.RenderText("A", 2.0, Vector.Zero);
var smallBox = small.GetBoundingBox();
var largeBox = large.GetBoundingBox();
Assert.True(largeBox.Width > smallBox.Width);
Assert.True(largeBox.Length > smallBox.Length);
}
[SkippableFact]
public void RenderText_AdvancesCursor()
{
var font = LoadFont();
var abEntities = font.RenderText("AB", 1.0, Vector.Zero);
var aEntities = font.RenderText("A", 1.0, Vector.Zero);
var abBox = abEntities.GetBoundingBox();
var aBox = aEntities.GetBoundingBox();
Assert.True(abBox.Length > aBox.Length * 1.5,
$"AB width ({abBox.Length:F1}) should be significantly wider than A width ({aBox.Length:F1})");
}
[SkippableFact]
public void RenderText_MatchesGravographReference()
{
var font = LoadFont();
var height = 5.08;
var centerX = 50.8;
var centerY = 34.925;
var entities = font.RenderText("Text", height, Vector.Zero);
var rawBox = entities.GetBoundingBox();
var shiftX = centerX - (rawBox.Left + rawBox.Right) / 2;
var shiftY = centerY - (rawBox.Top + rawBox.Bottom) / 2;
foreach (var e in entities)
e.Offset(new Vector(shiftX, shiftY));
Assert.True(entities.Count > 0, "Should produce entities for 'Text'");
var box = entities.GetBoundingBox();
var refLeft = 43.53;
var refRight = 58.07;
var refBottom = 32.39;
var refTop = 37.47;
var tolerance = 0.5;
Assert.True(System.Math.Abs(box.Left - refLeft) < tolerance,
$"Left: ours={box.Left:F2}, ref={refLeft:F2}, diff={System.Math.Abs(box.Left - refLeft):F2}");
Assert.True(System.Math.Abs(box.Right - refRight) < tolerance,
$"Right: ours={box.Right:F2}, ref={refRight:F2}, diff={System.Math.Abs(box.Right - refRight):F2}");
Assert.True(System.Math.Abs(box.Bottom - refBottom) < tolerance,
$"Bottom: ours={box.Bottom:F2}, ref={refBottom:F2}, diff={System.Math.Abs(box.Bottom - refBottom):F2}");
Assert.True(System.Math.Abs(box.Top - refTop) < tolerance,
$"Top: ours={box.Top:F2}, ref={refTop:F2}, diff={System.Math.Abs(box.Top - refTop):F2}");
var actualCapHeight = box.Top - box.Bottom;
Assert.True(System.Math.Abs(actualCapHeight - height) < 0.5,
$"Cap height: ours={actualCapHeight:F2}, expected={height:F2}");
}
[SkippableFact]
public void MeasureTextWidth_IsConsistent()
{
var font = LoadFont();
var height = 5.08;
var measuredWidth = font.MeasureTextWidth("Text", height);
var entities = font.RenderText("Text", height, Vector.Zero);
var box = entities.GetBoundingBox();
Assert.True(measuredWidth >= box.Length,
$"Measured={measuredWidth:F2} should be >= rendered={box.Length:F2}");
Assert.True(measuredWidth - box.Length < 2.0,
$"Measured={measuredWidth:F2}, rendered={box.Length:F2}, diff={measuredWidth - box.Length:F2}");
}
[SkippableFact]
public void Glyph_t_HasCurveAtBottom()
{
var font = LoadFont();
var glyph = font.GetGlyph('t');
Assert.NotNull(glyph);
var entities = glyph.ToEntities(1.0, 0, 0);
var lines = entities.Cast<Line>().ToList();
Assert.True(lines.Count >= 10, $"Expected at least 10 entities for 't', got {lines.Count}");
var curveLines = lines.Skip(1).Take(lines.Count - 3).ToList();
Assert.True(curveLines.Count >= 14, $"Expected at least 14 curve segments, got {curveLines.Count}");
var lastCurve = curveLines[^1];
Assert.True(lastCurve.EndPoint.X > curveLines[0].StartPoint.X,
$"Curve should end to the right of where it starts: start X={curveLines[0].StartPoint.X:F1}, end X={lastCurve.EndPoint.X:F1}");
}
}
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.IO;
public class RemoveDuplicateArcsTests
{
[Fact]
public void RemoveDuplicateArcs_RemovesArcMatchingCircle_SameLayer()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var line = new Line(0, 0, 10, 0) { Layer = layer };
var entities = new List<Entity> { circle, arc, line };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
Assert.Contains(circle, entities);
Assert.Contains(line, entities);
Assert.DoesNotContain(arc, entities);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcOnDifferentLayer()
{
var layer1 = new Layer("cut");
var layer2 = new Layer("etch");
var circle = new Circle(10, 10, 5) { Layer = layer1 };
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer2 };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
Assert.Contains(arc, entities);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcWithDifferentRadius()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(10, 10, 3, 0, Angle.ToRadians(90)) { Layer = layer };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcWithDifferentCenter()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(20, 20, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_NoCircles_NoChange()
{
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90));
var line = new Line(0, 0, 10, 0);
var entities = new List<Entity> { arc, line };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_MultipleArcsMatchOneCircle_RemovesAll()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc1 = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var arc2 = new Arc(10, 10, 5, Angle.ToRadians(90), Angle.ToRadians(180)) { Layer = layer };
var entities = new List<Entity> { circle, arc1, arc2 };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Single(entities);
Assert.Contains(circle, entities);
}
}
+46
View File
@@ -0,0 +1,46 @@
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.Math;
public class FractionTests
{
[Theory]
[InlineData("3/8", 0.375)]
[InlineData("1 3/4", 1.75)]
[InlineData("1-3/4", 1.75)]
[InlineData("1/2", 0.5)]
public void Parse_ValidFraction_ReturnsDouble(string input, double expected)
{
var result = Fraction.Parse(input);
Assert.Equal(expected, result, 8);
}
[Theory]
[InlineData("3/8", true)]
[InlineData("abc", false)]
[InlineData("1 3/4", true)]
public void IsValid_ReturnsExpected(string input, bool expected)
{
Assert.Equal(expected, Fraction.IsValid(input));
}
[Fact]
public void TryParse_InvalidInput_ReturnsFalse()
{
var result = Fraction.TryParse("abc", out var value);
Assert.False(result);
Assert.Equal(0, value);
}
[Fact]
public void ReplaceFractionsWithDecimals_ReplacesFractionInString()
{
var result = Fraction.ReplaceFractionsWithDecimals("length is 1 3/4 inches");
Assert.Contains("1.75", result);
Assert.DoesNotContain("3/4", result);
}
}
+8
View File
@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" /> <PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -27,6 +28,7 @@
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" /> <ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" /> <ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" /> <ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
<ProjectReference Include="..\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
<ProjectReference Include="..\OpenNest\OpenNest.csproj" /> <ProjectReference Include="..\OpenNest\OpenNest.csproj" />
</ItemGroup> </ItemGroup>
@@ -34,6 +36,12 @@
<Content Include="Bending\TestData\**\*"> <Content Include="Bending\TestData\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Splitting\TestData\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="test-config.json" Condition="Exists('test-config.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,118 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests;
public class PlateSnapToStandardSizeTests
{
private static Part MakeRectPart(double x, double y, double length, double width)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(length, 0)));
pgm.Codes.Add(new LinearMove(new Vector(length, width)));
pgm.Codes.Add(new LinearMove(new Vector(0, width)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("test", pgm);
var part = new Part(drawing);
part.Offset(x, y);
return part;
}
[Fact]
public void SnapToStandardSize_SmallParts_SnapsToIncrement()
{
var plate = new Plate(200, 200); // oversized starting size
plate.Parts.Add(MakeRectPart(0, 0, 10, 20));
var result = plate.SnapToStandardSize();
// 10x20 is well below 48x48 MinSheet -> snap to integer increment.
Assert.Null(result.MatchedLabel);
Assert.Equal(10, plate.Size.Length); // X axis
Assert.Equal(20, plate.Size.Width); // Y axis
}
[Fact]
public void SnapToStandardSize_SmallPartsWithFractionalIncrement_UsesIncrement()
{
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 10.3, 20.7));
var result = plate.SnapToStandardSize(new PlateSizeOptions { SnapIncrement = 0.25 });
Assert.Null(result.MatchedLabel);
Assert.Equal(10.5, plate.Size.Length, 4);
Assert.Equal(20.75, plate.Size.Width, 4);
}
[Fact]
public void SnapToStandardSize_40x90Part_SnapsToStandard48x96_XLong()
{
// Part is 90 long (X) x 40 wide (Y) -> X is the long axis.
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 90, 40));
var result = plate.SnapToStandardSize();
Assert.Equal("48x96", result.MatchedLabel);
Assert.Equal(96, plate.Size.Length); // X axis = long
Assert.Equal(48, plate.Size.Width); // Y axis = short
}
[Fact]
public void SnapToStandardSize_90TallPart_SnapsToStandard48x96_YLong()
{
// Part is 40 long (X) x 90 wide (Y) -> Y is the long axis.
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 40, 90));
var result = plate.SnapToStandardSize();
Assert.Equal("48x96", result.MatchedLabel);
Assert.Equal(48, plate.Size.Length); // X axis = short
Assert.Equal(96, plate.Size.Width); // Y axis = long
}
[Fact]
public void SnapToStandardSize_JustOver48_PicksNextStandardSize()
{
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 100, 50));
var result = plate.SnapToStandardSize();
Assert.Equal("60x120", result.MatchedLabel);
Assert.Equal(120, plate.Size.Length); // X long
Assert.Equal(60, plate.Size.Width);
}
[Fact]
public void SnapToStandardSize_EmptyPlate_DoesNotModifySize()
{
var plate = new Plate(60, 120);
var result = plate.SnapToStandardSize();
Assert.Null(result.MatchedLabel);
Assert.Equal(60, plate.Size.Width);
Assert.Equal(120, plate.Size.Length);
}
[Fact]
public void SnapToStandardSize_MultipleParts_UsesCombinedEnvelope()
{
var plate = new Plate(200, 200);
plate.Parts.Add(MakeRectPart(0, 0, 30, 40));
plate.Parts.Add(MakeRectPart(30, 0, 30, 40)); // combined X-extent = 60
plate.Parts.Add(MakeRectPart(0, 40, 60, 60)); // combined extent = 60 x 100
var result = plate.SnapToStandardSize();
// 60 x 100 fits 60x120 standard sheet, Y is the long axis.
Assert.Equal("60x120", result.MatchedLabel);
Assert.Equal(60, plate.Size.Length); // X
Assert.Equal(120, plate.Size.Width); // Y long
}
}
-104
View File
@@ -1,104 +0,0 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class FlangeShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesOD()
{
var shape = new FlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(10, bbox.Width, 0.01);
Assert.Equal(10, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_AreaExcludesBoltHoles()
{
var shape = new FlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
// Area = pi * 5^2 - 4 * pi * 0.5^2 = pi * (25 - 1) = pi * 24
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_DefaultName_IsFlange()
{
var shape = new FlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
Assert.Equal("Flange", drawing.Name);
}
[Fact]
public void LoadFromJson_ProducesCorrectDrawing()
{
var json = """
[
{
"Name": "2in-150#",
"NominalPipeSize": 2.0,
"OD": 6.0,
"HoleDiameter": 0.75,
"HolePatternDiameter": 4.75,
"HoleCount": 4
},
{
"Name": "2in-300#",
"NominalPipeSize": 2.0,
"OD": 6.5,
"HoleDiameter": 0.75,
"HolePatternDiameter": 5.0,
"HoleCount": 8
}
]
""";
var tempFile = Path.GetTempFileName();
try
{
File.WriteAllText(tempFile, json);
var flanges = ShapeDefinition.LoadFromJson<FlangeShape>(tempFile);
Assert.Equal(2, flanges.Count);
var first = flanges[0];
Assert.Equal("2in-150#", first.Name);
var drawing = first.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(6, bbox.Width, 0.01);
var second = flanges[1];
Assert.Equal("2in-300#", second.Name);
Assert.Equal(8, second.HoleCount);
}
finally
{
File.Delete(tempFile);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class NgonShapeTests
{
[Fact]
public void GetDrawing_Octagon_BoundingBoxFitsWithinExpectedSize()
{
var shape = new NgonShape { Sides = 8, Width = 20 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
// Corner-to-corner is larger than flat-to-flat
Assert.True(bbox.Width >= 20 - 0.01);
Assert.True(bbox.Length >= 20 - 0.01);
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
Assert.True(bbox.Width < 22);
Assert.True(bbox.Length < 22);
}
[Theory]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(8)]
[InlineData(12)]
public void GetDrawing_HasOneLinearMovePerSide(int sides)
{
var shape = new NgonShape { Sides = sides, Width = 20 };
var drawing = shape.GetDrawing();
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(sides, moves);
}
[Fact]
public void GetDrawing_ClampsSidesBelowThreeToTriangle()
{
var shape = new NgonShape { Sides = 2, Width = 20 };
var drawing = shape.GetDrawing();
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(3, moves);
}
}
@@ -1,34 +0,0 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class OctagonShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxFitsWithinExpectedSize()
{
var shape = new OctagonShape { Width = 20 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
// Corner-to-corner is larger than flat-to-flat
Assert.True(bbox.Width >= 20 - 0.01);
Assert.True(bbox.Length >= 20 - 0.01);
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
Assert.True(bbox.Width < 22);
Assert.True(bbox.Length < 22);
}
[Fact]
public void GetDrawing_HasEightEdges()
{
var shape = new OctagonShape { Width = 20 };
var drawing = shape.GetDrawing();
// An octagon program should have 8 linear moves (one per edge)
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(8, moves);
}
}
@@ -0,0 +1,216 @@
using System;
using System.IO;
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class PipeFlangeShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesOD()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(10, bbox.Width, 0.01);
Assert.Equal(10, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_AreaExcludesBoltHoles()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
Blind = true
};
var drawing = shape.GetDrawing();
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_DefaultName_IsPipeFlange()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4
};
var drawing = shape.GetDrawing();
Assert.Equal("PipeFlange", drawing.Name);
}
[Fact]
public void GetDrawing_WithPipeSize_CutsCenterBoreAtPipeODPlusClearance()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
PipeSize = "2", // OD = 2.375
PipeClearance = 0.125,
Blind = false
};
var drawing = shape.GetDrawing();
// Expected bore diameter = 2.375 + 0.125 = 2.5
// Area = pi * (5^2 - 0.5^2 * 4 - 1.25^2) = pi * (25 - 1 - 1.5625) = pi * 22.4375
var expectedArea = System.Math.PI * 22.4375;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_Blind_OmitsCenterBore()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
PipeSize = "2",
PipeClearance = 0.125,
Blind = true
};
var drawing = shape.GetDrawing();
// With Blind=true, area = outer - 4 bolt holes = pi * (25 - 1) = pi * 24
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_UnknownPipeSize_OmitsCenterBore()
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
PipeSize = "not-a-real-pipe",
PipeClearance = 0.125,
Blind = false
};
var drawing = shape.GetDrawing();
// Unknown pipe size → no bore, area matches blind case
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void GetDrawing_NullOrEmptyPipeSize_OmitsCenterBore(string pipeSize)
{
var shape = new PipeFlangeShape
{
OD = 10,
HoleDiameter = 1,
HolePatternDiameter = 7,
HoleCount = 4,
PipeSize = pipeSize,
PipeClearance = 0.125
};
var drawing = shape.GetDrawing();
var expectedArea = System.Math.PI * 24;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void LoadFromJson_ProducesCorrectDrawing()
{
var json = """
[
{
"Name": "2in-150#",
"PipeSize": "2",
"PipeClearance": 0.0625,
"OD": 6.0,
"HoleDiameter": 0.75,
"HolePatternDiameter": 4.75,
"HoleCount": 4
},
{
"Name": "2in-300#",
"PipeSize": "2",
"PipeClearance": 0.0625,
"OD": 6.5,
"HoleDiameter": 0.75,
"HolePatternDiameter": 5.0,
"HoleCount": 8
}
]
""";
var tempFile = Path.GetTempFileName();
try
{
File.WriteAllText(tempFile, json);
var flanges = ShapeDefinition.LoadFromJson<PipeFlangeShape>(tempFile);
Assert.Equal(2, flanges.Count);
var first = flanges[0];
Assert.Equal("2in-150#", first.Name);
Assert.Equal("2", first.PipeSize);
Assert.Equal(0.0625, first.PipeClearance, 0.0001);
var drawing = first.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(6, bbox.Width, 0.01);
var second = flanges[1];
Assert.Equal("2in-300#", second.Name);
Assert.Equal(8, second.HoleCount);
}
finally
{
File.Delete(tempFile);
}
}
[Fact]
public void LoadFromJson_RealShippedConfig_LoadsAllEntries()
{
// Resolve the repo-relative config path from the test binary location.
var dir = AppDomain.CurrentDomain.BaseDirectory;
while (dir != null && !File.Exists(Path.Combine(dir, "OpenNest.sln")))
dir = Path.GetDirectoryName(dir);
Assert.NotNull(dir);
var configPath = Path.Combine(dir, "OpenNest", "Configurations", "PipeFlangeShape.json");
Assert.True(File.Exists(configPath), $"Config missing at {configPath}");
var flanges = ShapeDefinition.LoadFromJson<PipeFlangeShape>(configPath);
Assert.NotEmpty(flanges);
foreach (var f in flanges)
{
Assert.False(string.IsNullOrWhiteSpace(f.PipeSize));
Assert.True(PipeSizes.TryGetOD(f.PipeSize, out _),
$"Unknown PipeSize '{f.PipeSize}' in entry '{f.Name}'");
Assert.Equal(0.0625, f.PipeClearance, 0.0001);
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class PipeSizesTests
{
[Fact]
public void All_ContainsExpectedCount()
{
Assert.Equal(35, PipeSizes.All.Count);
}
[Fact]
public void All_IsSortedByOuterDiameterAscending()
{
for (var i = 1; i < PipeSizes.All.Count; i++)
Assert.True(PipeSizes.All[i].OuterDiameter > PipeSizes.All[i - 1].OuterDiameter);
}
[Theory]
[InlineData("1/8", 0.405)]
[InlineData("1/2", 0.840)]
[InlineData("2", 2.375)]
[InlineData("2 1/2", 2.875)]
[InlineData("12", 12.750)]
[InlineData("48", 48.000)]
public void TryGetOD_KnownLabel_ReturnsExpectedOD(string label, double expected)
{
Assert.True(PipeSizes.TryGetOD(label, out var od));
Assert.Equal(expected, od, 0.001);
}
[Fact]
public void TryGetOD_UnknownLabel_ReturnsFalse()
{
Assert.False(PipeSizes.TryGetOD("bogus", out _));
}
[Fact]
public void GetFittingSizes_FiltersByMaxOD()
{
var results = PipeSizes.GetFittingSizes(3.0).ToList();
Assert.Contains(results, e => e.Label == "2 1/2");
Assert.DoesNotContain(results, e => e.Label == "3");
Assert.DoesNotContain(results, e => e.Label == "4");
}
[Fact]
public void GetFittingSizes_ExactBoundary_IsInclusive()
{
// NPS 3 has OD 3.500; passing maxOD = 3.500 should include it.
var results = PipeSizes.GetFittingSizes(3.500).ToList();
Assert.Contains(results, e => e.Label == "3");
Assert.DoesNotContain(results, e => e.Label == "3 1/2");
}
[Fact]
public void GetFittingSizes_MaxSmallerThanSmallest_ReturnsEmpty()
{
Assert.Empty(PipeSizes.GetFittingSizes(0.1));
}
}
+311
View File
@@ -0,0 +1,311 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class PlateSizesTests
{
[Fact]
public void All_IsNotEmpty()
{
Assert.NotEmpty(PlateSizes.All);
}
[Fact]
public void All_DoesNotContain48x48()
{
// 48x48 is not a standard sheet - it's the default MinSheet threshold only.
Assert.DoesNotContain(PlateSizes.All, e => e.Width == 48 && e.Length == 48);
}
[Fact]
public void All_Smallest_Is48x96()
{
var smallest = PlateSizes.All.OrderBy(e => e.Area).First();
Assert.Equal(48, smallest.Width);
Assert.Equal(96, smallest.Length);
}
[Fact]
public void All_SortedByAreaAscending()
{
for (var i = 1; i < PlateSizes.All.Count; i++)
Assert.True(PlateSizes.All[i].Area >= PlateSizes.All[i - 1].Area);
}
[Fact]
public void All_Entries_AreCanonical_WidthLessOrEqualLength()
{
foreach (var entry in PlateSizes.All)
Assert.True(entry.Width <= entry.Length, $"{entry.Label} not in canonical orientation");
}
[Theory]
[InlineData(40, 40, true)] // small - fits trivially
[InlineData(48, 96, true)] // exact
[InlineData(96, 48, true)] // rotated exact
[InlineData(90, 40, true)] // rotated
[InlineData(49, 97, false)] // just over in both dims
[InlineData(50, 50, false)] // too wide in both orientations
public void Entry_Fits_RespectsRotation(double w, double h, bool expected)
{
var entry = new PlateSizes.Entry("48x96", 48, 96);
Assert.Equal(expected, entry.Fits(w, h));
}
[Fact]
public void TryGet_KnownLabel_ReturnsEntry()
{
Assert.True(PlateSizes.TryGet("48x96", out var entry));
Assert.Equal(48, entry.Width);
Assert.Equal(96, entry.Length);
}
[Fact]
public void TryGet_IsCaseInsensitive()
{
Assert.True(PlateSizes.TryGet("48X96", out var entry));
Assert.Equal(48, entry.Width);
Assert.Equal(96, entry.Length);
}
[Fact]
public void TryGet_UnknownLabel_ReturnsFalse()
{
Assert.False(PlateSizes.TryGet("bogus", out _));
}
[Fact]
public void Recommend_BelowMin_SnapsToDefaultIncrementOfOne()
{
var bbox = new Box(0, 0, 10.3, 20.7);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(11, result.Width);
Assert.Equal(21, result.Length);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_BelowMin_UsesCustomIncrement()
{
var bbox = new Box(0, 0, 10.3, 20.7);
var options = new PlateSizeOptions { SnapIncrement = 0.25 };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(10.5, result.Width, 4);
Assert.Equal(20.75, result.Length, 4);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_ExactlyAtMin_Snaps()
{
var bbox = new Box(0, 0, 48, 48);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(48, result.Width);
Assert.Equal(48, result.Length);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_AboveMin_PicksSmallestContainingStandardSheet()
{
var bbox = new Box(0, 0, 40, 90);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(48, result.Width);
Assert.Equal(96, result.Length);
Assert.Equal("48x96", result.MatchedLabel);
}
[Fact]
public void Recommend_AboveMin_WithRotation_PicksSmallestSheet()
{
var bbox = new Box(0, 0, 90, 40);
var result = PlateSizes.Recommend(bbox);
Assert.Equal("48x96", result.MatchedLabel);
}
[Fact]
public void Recommend_JustOver48_PicksNextStandardSize()
{
var bbox = new Box(0, 0, 50, 100);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(60, result.Width);
Assert.Equal(120, result.Length);
Assert.Equal("60x120", result.MatchedLabel);
}
[Fact]
public void Recommend_MarginIsAppliedPerSide()
{
// 46 + 2*1 = 48 (fits exactly), 94 + 2*1 = 96 (fits exactly)
var bbox = new Box(0, 0, 46, 94);
var options = new PlateSizeOptions { Margin = 1 };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal("48x96", result.MatchedLabel);
}
[Fact]
public void Recommend_MarginPushesToNextSheet()
{
// 47 + 2 = 49 > 48, so 48x96 no longer fits -> next standard
var bbox = new Box(0, 0, 47, 95);
var options = new PlateSizeOptions { Margin = 1 };
var result = PlateSizes.Recommend(bbox, options);
Assert.NotEqual("48x96", result.MatchedLabel);
Assert.True(result.Width >= 49);
Assert.True(result.Length >= 97);
}
[Fact]
public void Recommend_AllowedSizes_StandardLabelWhitelist()
{
// 60x120 is the only option; 50x50 is above min so it routes to standard
var bbox = new Box(0, 0, 50, 50);
var options = new PlateSizeOptions { AllowedSizes = new[] { "60x120" } };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal("60x120", result.MatchedLabel);
}
[Fact]
public void Recommend_AllowedSizes_ArbitraryWxHString()
{
// 50x100 isn't in the standard catalog but is valid as an ad-hoc entry.
// bbox 49x99 doesn't fit 48x96 or 48x120, does fit 50x100 and 60x120,
// but only 50x100 is allowed.
var bbox = new Box(0, 0, 49, 99);
var options = new PlateSizeOptions { AllowedSizes = new[] { "50x100" } };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(50, result.Width);
Assert.Equal(100, result.Length);
Assert.Equal("50x100", result.MatchedLabel);
}
[Fact]
public void Recommend_NothingFits_FallsBackToSnapUp()
{
// Larger than any catalog sheet
var bbox = new Box(0, 0, 100, 300);
var result = PlateSizes.Recommend(bbox);
Assert.Equal(100, result.Width);
Assert.Equal(300, result.Length);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_NothingFitsInAllowedList_FallsBackToSnapUp()
{
// Only 48x96 allowed, but bbox is too big for it
var bbox = new Box(0, 0, 50, 100);
var options = new PlateSizeOptions { AllowedSizes = new[] { "48x96" } };
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(50, result.Width);
Assert.Equal(100, result.Length);
Assert.Null(result.MatchedLabel);
}
[Fact]
public void Recommend_BoxEnumerable_CombinesIntoEnvelope()
{
// Two boxes that together span 0..40 x 0..90 -> fits 48x96
var boxes = new[]
{
new Box(0, 0, 40, 50),
new Box(0, 40, 30, 50),
};
var result = PlateSizes.Recommend(boxes);
Assert.Equal("48x96", result.MatchedLabel);
}
[Fact]
public void Recommend_BoxEnumerable_Empty_Throws()
{
Assert.Throws<System.ArgumentException>(
() => PlateSizes.Recommend(System.Array.Empty<Box>()));
}
[Fact]
public void PlateSizeOptions_Defaults()
{
var options = new PlateSizeOptions();
Assert.Equal(48, options.MinSheetWidth);
Assert.Equal(48, options.MinSheetLength);
Assert.Equal(1.0, options.SnapIncrement);
Assert.Equal(0, options.Margin);
Assert.Null(options.AllowedSizes);
Assert.Equal(PlateSizeSelection.SmallestArea, options.Selection);
}
[Fact]
public void Recommend_NarrowestFirst_PicksNarrowerSheetOverSmallerArea()
{
// Hypothetical: bbox (47, 47) fits both 48x96 (area 4608) and some narrower option.
// With SmallestArea: picks 48x96 (it's already the smallest 48-wide).
// With NarrowestFirst: also picks 48x96 since that's the narrowest.
// Better test: AllowedSizes = ["60x120", "48x120"] with bbox that fits both.
// 48x120 (area 5760) is narrower; 60x120 (area 7200) has more area.
// SmallestArea picks 48x120; NarrowestFirst also picks 48x120. Both pick the same.
//
// Real divergence: AllowedSizes = ["60x120", "72x120"] with bbox 55x100.
// 60x120 has narrower width (60) AND smaller area (7200 vs 8640), so both agree.
//
// To force divergence: AllowedSizes = ["60x96", "48x144"] with bbox 47x95.
// 60x96 area = 5760, 48x144 area = 6912. SmallestArea -> 60x96.
// NarrowestFirst width 48 < 60 -> 48x144.
var bbox = new Box(0, 0, 47, 95);
var options = new PlateSizeOptions
{
AllowedSizes = new[] { "60x96", "48x144" },
Selection = PlateSizeSelection.NarrowestFirst,
};
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(48, result.Width);
Assert.Equal(144, result.Length);
}
[Fact]
public void Recommend_SmallestArea_PicksSmallerAreaOverNarrowerWidth()
{
var bbox = new Box(0, 0, 47, 95);
var options = new PlateSizeOptions
{
AllowedSizes = new[] { "60x96", "48x144" },
Selection = PlateSizeSelection.SmallestArea,
};
var result = PlateSizes.Recommend(bbox, options);
Assert.Equal(60, result.Width);
Assert.Equal(96, result.Length);
}
}
@@ -384,6 +384,161 @@ public class DrawingSplitterTests
} }
} }
[Fact]
public void Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips()
{
// 255x55 outer rectangle with a 235x35 interior slot centered at (10,10)-(245,45).
// 4 vertical splits at x = 55, 110, 165, 220.
//
// Expected: regions R2/R3/R4 are entirely "over" the slot horizontally, so the
// surviving material in each is two physically disjoint strips (upper + lower).
// R1 and R5 each have a solid edge that connects the top and bottom strips, so
// they remain single (notched) pieces.
//
// Total output drawings: 1 (R1) + 2 (R2) + 2 (R3) + 2 (R4) + 1 (R5) = 8.
var outerEntities = new List<Entity>
{
new Line(new Vector(0, 0), new Vector(255, 0)),
new Line(new Vector(255, 0), new Vector(255, 55)),
new Line(new Vector(255, 55), new Vector(0, 55)),
new Line(new Vector(0, 55), new Vector(0, 0))
};
var slotEntities = new List<Entity>
{
new Line(new Vector(10, 10), new Vector(245, 10)),
new Line(new Vector(245, 10), new Vector(245, 45)),
new Line(new Vector(245, 45), new Vector(10, 45)),
new Line(new Vector(10, 45), new Vector(10, 10))
};
var allEntities = new List<Entity>();
allEntities.AddRange(outerEntities);
allEntities.AddRange(slotEntities);
var drawing = new Drawing("SLOT", ConvertGeometry.ToProgram(allEntities));
var originalArea = drawing.Area;
var splitLines = new List<SplitLine>
{
new SplitLine(55.0, CutOffAxis.Vertical),
new SplitLine(110.0, CutOffAxis.Vertical),
new SplitLine(165.0, CutOffAxis.Vertical),
new SplitLine(220.0, CutOffAxis.Vertical)
};
var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight });
// R1 (0..55) → 1 notched piece, height 55
// R2 (55..110) → upper strip + lower strip, each height 10
// R3 (110..165)→ upper strip + lower strip, each height 10
// R4 (165..220)→ upper strip + lower strip, each height 10
// R5 (220..255)→ 1 notched piece, height 55
Assert.Equal(8, results.Count);
// Area preservation: sum of all output areas equals (outer slot).
var totalArea = results.Sum(d => d.Area);
Assert.Equal(originalArea, totalArea, 1);
// Box.Length = X-extent, Box.Width = Y-extent.
// Exactly 6 strips (Y-extent ~10mm) from the three middle regions, and
// exactly 2 notched pieces (Y-extent 55mm) from R1 and R5.
var strips = results
.Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 10.0) < 0.5)
.ToList();
var notched = results
.Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 55.0) < 0.5)
.ToList();
Assert.Equal(6, strips.Count);
Assert.Equal(2, notched.Count);
// Each piece should form a closed perimeter (no dangling edges, no gaps).
foreach (var piece in results)
{
var entities = ConvertProgram.ToGeometry(piece.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
Assert.True(entities.Count >= 3, $"{piece.Name} must have at least 3 edges");
for (var i = 0; i < entities.Count; i++)
{
var end = GetEndPoint(entities[i]);
var nextStart = GetStartPoint(entities[(i + 1) % entities.Count]);
var gap = end.DistanceTo(nextStart);
Assert.True(gap < 0.01,
$"{piece.Name} gap of {gap:F4} between edge {i} end and edge {(i + 1) % entities.Count} start");
}
}
}
[Fact]
public void Split_DxfFile_WithSpanningSlot_HasNoCutLinesThroughCutout()
{
// Real DXF regression: 255x55 plate with a centered slot cutout, split into
// five columns. Exercises the same path as the synthetic
// Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips test but through
// the full DXF import pipeline.
var path = Path.Combine(AppContext.BaseDirectory, "Splitting", "TestData", "split_test.dxf");
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
var imported = OpenNest.IO.Dxf.Import(path);
var profile = new OpenNest.Geometry.ShapeProfile(imported.Entities);
// Normalize to origin so the split line positions are predictable.
var bb = profile.Perimeter.BoundingBox;
var offsetX = -bb.X;
var offsetY = -bb.Y;
foreach (var e in profile.Perimeter.Entities) e.Offset(offsetX, offsetY);
foreach (var cutout in profile.Cutouts)
foreach (var e in cutout.Entities) e.Offset(offsetX, offsetY);
var allEntities = new List<Entity>();
allEntities.AddRange(profile.Perimeter.Entities);
foreach (var cutout in profile.Cutouts) allEntities.AddRange(cutout.Entities);
var drawing = new Drawing("SPLITTEST", ConvertGeometry.ToProgram(allEntities));
var originalArea = drawing.Area;
// Part is ~255x55 with an interior slot. Split into 5 columns (55mm each).
var splitLines = new List<SplitLine>
{
new SplitLine(55.0, CutOffAxis.Vertical),
new SplitLine(110.0, CutOffAxis.Vertical),
new SplitLine(165.0, CutOffAxis.Vertical),
new SplitLine(220.0, CutOffAxis.Vertical)
};
var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight });
// Area must be preserved within tolerance (floating-point coords in the DXF).
var totalArea = results.Sum(d => d.Area);
Assert.Equal(originalArea, totalArea, 0);
// At least one region must yield more than one physical strip — that's the
// whole point of the fix: a cutout that spans a region disconnects it.
Assert.True(results.Count > splitLines.Count + 1,
$"Expected more than {splitLines.Count + 1} pieces (some regions split into strips), got {results.Count}");
// Every output drawing must resolve into fully-closed shapes (outer loop
// and any hole loops), with no dangling geometry. A piece that contains
// a cutout will have its entities span more than one connected loop.
foreach (var piece in results)
{
var entities = ConvertProgram.ToGeometry(piece.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
Assert.True(entities.Count >= 3, $"{piece.Name} has only {entities.Count} entities");
var shapes = OpenNest.Geometry.ShapeBuilder.GetShapes(entities);
Assert.NotEmpty(shapes);
foreach (var shape in shapes)
{
Assert.True(shape.IsClosed(),
$"{piece.Name} contains an open chain of {shape.Entities.Count} entities");
}
}
}
private static Vector GetStartPoint(Entity entity) private static Vector GetStartPoint(Entity entity)
{ {
return entity switch return entity switch
File diff suppressed because it is too large Load Diff
+29
View File
@@ -1,8 +1,37 @@
using System.Text.Json;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Tests; namespace OpenNest.Tests;
internal static class TestConfig
{
private static readonly Lazy<Dictionary<string, string>> Config = new(() =>
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 6; i++)
{
var path = Path.Combine(dir, "test-config.json");
if (File.Exists(path))
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
}
dir = Path.GetDirectoryName(dir)!;
}
return new();
});
public static string? Get(string key) =>
Config.Value.TryGetValue(key, out var val) ? val : null;
public static string? GetExistingPath(string key)
{
var path = Get(key);
return path != null && File.Exists(path) ? path : null;
}
}
internal static class TestHelpers internal static class TestHelpers
{ {
public static Part MakePartAt(double x, double y, double size = 1) public static Part MakePartAt(double x, double y, double size = 1)
+4 -2
View File
@@ -89,8 +89,10 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
new Size(48, 24), new Size(120, 10) new Size(48, 24), new Size(120, 10)
}; };
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories)
Console.WriteLine($"Found {dxfFiles.Length} DXF files"); .Concat(Directory.GetFiles(dir, "*.dwg", SearchOption.AllDirectories))
.ToArray();
Console.WriteLine($"Found {dxfFiles.Length} CAD files");
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db"; var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}"); Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations"); Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
+15
View File
@@ -30,6 +30,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostProcessors", "PostProce
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS", "OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj", "{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
EndProject EndProject
Global Global
@@ -186,12 +188,25 @@ Global
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU {A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU {A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU {A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.ActiveCfg = Debug|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.Build.0 = Debug|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x86.ActiveCfg = Debug|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x86.Build.0 = Debug|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|Any CPU.Build.0 = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.ActiveCfg = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E} {FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C} SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C}
-18
View File
@@ -16,15 +16,11 @@ namespace OpenNest.Actions
private CutOffSettings settings; private CutOffSettings settings;
private CutOffAxis lockedAxis = CutOffAxis.Vertical; private CutOffAxis lockedAxis = CutOffAxis.Vertical;
private Dictionary<Part, Entity> perimeterCache; private Dictionary<Part, Entity> perimeterCache;
private readonly Timer debounceTimer;
private bool regeneratePending;
public ActionCutOff(PlateView plateView) public ActionCutOff(PlateView plateView)
: base(plateView) : base(plateView)
{ {
settings = plateView.CutOffSettings; settings = plateView.CutOffSettings;
debounceTimer = new Timer { Interval = 16 };
debounceTimer.Tick += OnDebounce;
ConnectEvents(); ConnectEvents();
} }
@@ -40,8 +36,6 @@ namespace OpenNest.Actions
public override void DisconnectEvents() public override void DisconnectEvents()
{ {
debounceTimer.Stop();
debounceTimer.Dispose();
plateView.MouseMove -= OnMouseMove; plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown; plateView.MouseDown -= OnMouseDown;
plateView.KeyDown -= OnKeyDown; plateView.KeyDown -= OnKeyDown;
@@ -58,18 +52,6 @@ namespace OpenNest.Actions
private void OnMouseMove(object sender, MouseEventArgs e) private void OnMouseMove(object sender, MouseEventArgs e)
{ {
regeneratePending = true;
debounceTimer.Start();
}
private void OnDebounce(object sender, System.EventArgs e)
{
debounceTimer.Stop();
if (!regeneratePending)
return;
regeneratePending = false;
var pt = plateView.CurrentPoint; var pt = plateView.CurrentPoint;
previewCutOff = new CutOff(pt, lockedAxis); previewCutOff = new CutOff(pt, lockedAxis);
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache); previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);

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