Compare commits

116 Commits

Author SHA1 Message Date
aj 786b6e2e88 fix: show cutting parameters dialog before assigning lead-ins
Auto-assign lead-ins silently reused existing plate parameters with no
way to change them after the first assignment. Now a dialog with the
full CuttingPanel is shown every time, pre-populated with the current
settings, so the user can review and modify before confirming.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:34:25 -04:00
207 changed files with 13252 additions and 2978 deletions
+3 -2
View File
@@ -24,10 +24,10 @@ Eight projects form a layered architecture:
Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
@@ -116,3 +116,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
- **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.
+2 -3
View File
@@ -25,14 +25,13 @@ public static class NestRunner
// 1. Import DXFs → Drawings
var drawings = new List<Drawing>();
var importer = new DxfImporter();
foreach (var part in request.Parts)
{
if (!File.Exists(part.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
var geometry = Dxf.GetGeometry(part.DxfPath);
if (geometry.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
var normalized = ShapeProfile.NormalizeEntities(geometry);
+2 -8
View File
@@ -241,17 +241,11 @@ static class NestConsole
static Drawing ImportDxf(string path)
{
var importer = new DxfImporter();
if (!importer.GetGeometry(path, out var geometry))
{
Console.Error.WriteLine($"Error: failed to read DXF file: {path}");
return null;
}
var geometry = Dxf.GetGeometry(path);
if (geometry.Count == 0)
{
Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}");
Console.Error.WriteLine($"Error: failed to read DXF file or no geometry found: {path}");
return null;
}
+4 -2
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
@@ -66,7 +67,8 @@ namespace OpenNest.CNC
return new ArcMove(EndPoint, CenterPoint, Rotation)
{
Layer = Layer,
Suppressed = Suppressed
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
};
}
@@ -50,6 +50,126 @@ namespace OpenNest.CNC.CuttingStrategy
};
}
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType)
{
var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
var profile = new ShapeProfile(entities);
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribeEntities);
// Find the target shape that contains the clicked entity
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
// Emit cutouts — only the target gets lead-in/out
foreach (var cutout in profile.Cutouts)
{
if (cutout == targetShape)
{
var ct = DetectContourType(cutout);
EmitContour(result, cutout, point, matchedEntity, ct);
}
else
{
EmitRawContour(result, cutout);
}
}
// Emit perimeter
if (profile.Perimeter == targetShape)
{
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
}
else
{
EmitRawContour(result, profile.Perimeter);
}
result.Mode = Mode.Incremental;
return new CuttingResult
{
Program = result,
LastCutPoint = point
};
}
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity)
{
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
if (matched != null)
return (profile.Perimeter, matched);
foreach (var cutout in profile.Cutouts)
{
matched = FindMatchingEntity(cutout, clickedEntity);
if (matched != null)
return (cutout, matched);
}
// Fallback: closest shape, use closest point to find entity
var best = profile.Perimeter;
var bestPt = profile.Perimeter.ClosestPointTo(point, out var bestEntity);
var bestDist = bestPt.DistanceTo(point);
foreach (var cutout in profile.Cutouts)
{
var pt = cutout.ClosestPointTo(point, out var cutoutEntity);
var dist = pt.DistanceTo(point);
if (dist < bestDist)
{
best = cutout;
bestEntity = cutoutEntity;
bestDist = dist;
}
}
return (best, bestEntity);
}
private static Entity FindMatchingEntity(Shape shape, Entity clickedEntity)
{
foreach (var shapeEntity in shape.Entities)
{
if (shapeEntity.GetType() != clickedEntity.GetType())
continue;
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
{
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon)
return shapeEntity;
}
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
{
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon)
return shapeEntity;
}
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
{
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon)
return shapeEntity;
}
}
return null;
}
private void EmitRawContour(Program program, Shape shape)
{
var startPoint = GetShapeStartPoint(shape);
program.Codes.Add(new RapidMove(startPoint));
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
}
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint)
{
var entries = new ContourEntry[cutouts.Count];
@@ -70,8 +190,8 @@ namespace OpenNest.CNC.CuttingStrategy
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
{
var contourType = forceType ?? DetectContourType(shape);
var normal = ComputeNormal(point, entity, contourType);
var winding = DetermineWinding(shape);
var normal = ComputeNormal(point, entity, contourType, winding);
var leadIn = SelectLeadIn(contourType);
var leadOut = SelectLeadOut(contourType);
@@ -143,29 +263,33 @@ namespace OpenNest.CNC.CuttingStrategy
return ContourType.Internal;
}
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType)
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType,
RotationType winding = RotationType.CW)
{
double normal;
if (entity is Line line)
{
// Perpendicular to line direction
// Perpendicular to line direction: tangent + π/2 = left side.
// Left side = outward for CW winding; for CCW winding, outward
// is on the right side, so flip.
var tangent = line.EndPoint.AngleFrom(line.StartPoint);
normal = tangent + Math.Angle.HalfPI;
if (winding == RotationType.CCW)
normal += System.Math.PI;
}
else if (entity is Arc arc)
{
// Radial direction from center to point
// Radial direction from center to point.
// Flip when the arc direction differs from the contour winding —
// that indicates a concave feature where radial points inward.
normal = point.AngleFrom(arc.Center);
// For CCW arcs the radial points the wrong way — flip it.
// CW arcs are convex features (corners) where radial = outward.
// CCW arcs are concave features (slots) where radial = inward.
if (arc.Rotation == RotationType.CCW)
if (arc.Rotation != winding)
normal += System.Math.PI;
}
else if (entity is Circle circle)
{
// Radial outward — always correct regardless of winding
normal = point.AngleFrom(circle.Center);
}
else
@@ -182,9 +306,15 @@ namespace OpenNest.CNC.CuttingStrategy
public static RotationType DetermineWinding(Shape shape)
{
// Use signed area: positive = CCW, negative = CW
var area = shape.Area();
return area >= 0 ? RotationType.CCW : RotationType.CW;
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
return circle.Rotation;
var polygon = shape.ToPolygon();
if (polygon.Vertices.Count < 3)
return RotationType.CCW;
return polygon.RotationDirection();
}
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
@@ -23,6 +23,9 @@ namespace OpenNest.CNC.CuttingStrategy
public double PierceClearance { get; set; } = 0.0625;
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
public Tab TabConfig { get; set; }
public bool TabsEnabled { get; set; }
@@ -1,16 +0,0 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
public class MicrotabLeadOut : LeadOut
{
public double GapSize { get; set; } = 0.03;
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{
return new List<ICode>();
}
}
}
+3 -1
View File
@@ -17,6 +17,8 @@
public double Value { get; set; }
public string VariableRef { get; set; }
public CodeType Type
{
get { return CodeType.SetFeedrate; }
@@ -24,7 +26,7 @@
public ICode Clone()
{
return new Feedrate(Value);
return new Feedrate(Value) { VariableRef = VariableRef };
}
public override string ToString()
+4 -2
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
@@ -32,7 +33,8 @@ namespace OpenNest.CNC
return new LinearMove(EndPoint)
{
Layer = Layer,
Suppressed = Suppressed
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
};
}
+8 -1
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
@@ -14,6 +15,8 @@ namespace OpenNest.CNC
public bool Suppressed { get; set; }
public Dictionary<string, string> VariableRefs { get; set; }
protected Motion()
{
Feedrate = CNC.Feedrate.UseDefault;
@@ -22,21 +25,25 @@ namespace OpenNest.CNC
public virtual void Rotate(double angle)
{
EndPoint = EndPoint.Rotate(angle);
VariableRefs = null;
}
public virtual void Rotate(double angle, Vector origin)
{
EndPoint = EndPoint.Rotate(angle, origin);
VariableRefs = null;
}
public virtual void Offset(double x, double y)
{
EndPoint = new Vector(EndPoint.X + x, EndPoint.Y + y);
VariableRefs = null;
}
public virtual void Offset(Vector voffset)
{
EndPoint += voffset;
VariableRefs = null;
}
public abstract CodeType Type { get; }
+6
View File
@@ -1,6 +1,7 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.CNC
@@ -9,6 +10,8 @@ namespace OpenNest.CNC
{
public List<ICode> Codes;
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
private Mode mode;
public Program(Mode mode = Mode.Absolute)
@@ -454,6 +457,9 @@ namespace OpenNest.CNC
pgm.Codes.AddRange(codes);
foreach (var kvp in Variables)
pgm.Variables[kvp.Key] = kvp.Value;
return pgm;
}
+4 -2
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
@@ -28,7 +29,8 @@ namespace OpenNest.CNC
{
return new RapidMove(EndPoint)
{
Suppressed = Suppressed
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
};
}
+21
View File
@@ -0,0 +1,21 @@
namespace OpenNest.CNC
{
public sealed class VariableDefinition
{
public string Name { get; }
public string Expression { get; }
public double Value { get; }
public bool Inline { get; }
public bool Global { get; }
public VariableDefinition(string name, string expression, double value,
bool inline = false, bool global = false)
{
Name = name;
Expression = expression;
Value = value;
Inline = inline;
Global = global;
}
}
}
+2 -1
View File
@@ -46,7 +46,8 @@ namespace OpenNest.Collections
public bool Remove(T item)
{
var success = items.Remove(item);
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
if (success)
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
return success;
}
+1 -1
View File
@@ -97,7 +97,7 @@ namespace OpenNest.Converters
if (startpt != lastpt)
pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, RotationType.CCW);
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
lastpt = startpt;
return lastpt;
+1 -1
View File
@@ -106,7 +106,7 @@ namespace OpenNest.Converters
var layer = ConvertLayer(arcMove.Layer);
if (startAngle.IsEqualTo(endAngle))
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color });
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
else
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
+6 -6
View File
@@ -50,13 +50,13 @@ namespace OpenNest
{
cutPosition = Position.X;
lineStart = StartLimit ?? bounds.Y;
lineEnd = EndLimit ?? (bounds.Y + bounds.Length + settings.Overtravel);
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
}
else
{
cutPosition = Position.Y;
lineStart = StartLimit ?? bounds.X;
lineEnd = EndLimit ?? (bounds.X + bounds.Width + settings.Overtravel);
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
}
var exclusions = new List<(double Start, double End)>();
@@ -176,13 +176,13 @@ namespace OpenNest
private (double Min, double Max) AxisBounds(Box bb, double clearance) =>
Axis == CutOffAxis.Vertical
? (bb.X - clearance, bb.X + bb.Width + clearance)
: (bb.Y - clearance, bb.Y + bb.Length + clearance);
? (bb.X - clearance, bb.X + bb.Length + clearance)
: (bb.Y - clearance, bb.Y + bb.Width + clearance);
private (double Start, double End) CrossAxisBounds(Box bb, double clearance) =>
Axis == CutOffAxis.Vertical
? (bb.Y - clearance, bb.Y + bb.Length + clearance)
: (bb.X - clearance, bb.X + bb.Width + clearance);
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
: (bb.X - clearance, bb.X + bb.Length + clearance);
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
{
+37
View File
@@ -2,6 +2,7 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
@@ -12,8 +13,32 @@ namespace OpenNest
public class Drawing
{
private static int nextId;
private static int nextColorIndex;
private Program program;
public static readonly Color[] PartColors = new Color[]
{
Color.FromArgb(205, 92, 92), // Indian Red
Color.FromArgb(148, 103, 189), // Medium Purple
Color.FromArgb(75, 180, 175), // Teal
Color.FromArgb(210, 190, 75), // Goldenrod
Color.FromArgb(190, 85, 175), // Orchid
Color.FromArgb(185, 115, 85), // Sienna
Color.FromArgb(120, 100, 190), // Slate Blue
Color.FromArgb(200, 100, 140), // Rose
Color.FromArgb(80, 175, 155), // Sea Green
Color.FromArgb(195, 160, 85), // Dark Khaki
Color.FromArgb(175, 95, 160), // Plum
Color.FromArgb(215, 130, 130), // Light Coral
};
public static Color GetNextColor()
{
var color = PartColors[nextColorIndex % PartColors.Length];
nextColorIndex++;
return color;
}
public Drawing()
: this(string.Empty, new Program())
{
@@ -66,6 +91,18 @@ namespace OpenNest
public List<Bend> Bends { get; set; } = new List<Bend>();
/// <summary>
/// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file.
/// </summary>
public List<Entity> SourceEntities { get; set; }
/// <summary>
/// IDs of entities in <see cref="SourceEntities"/> that are suppressed (hidden).
/// Suppressed entities are excluded from the active Program but preserved for re-enabling.
/// </summary>
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
public double Area { get; protected set; }
public void UpdateArea()
+2 -2
View File
@@ -420,8 +420,8 @@ namespace OpenNest.Geometry
boundingBox.X = minX;
boundingBox.Y = minY;
boundingBox.Width = maxX - minX;
boundingBox.Length = maxY - minY;
boundingBox.Length = maxX - minX;
boundingBox.Width = maxY - minY;
}
public override Entity OffsetEntity(double distance, OffsetSide side)
+2 -2
View File
@@ -12,8 +12,8 @@ namespace OpenNest.Geometry
double minX = boxes[0].X;
double minY = boxes[0].Y;
double maxX = boxes[0].X + boxes[0].Width;
double maxY = boxes[0].Y + boxes[0].Length;
double maxX = boxes[0].Right;
double maxY = boxes[0].Top;
foreach (var box in boxes)
{
+8 -8
View File
@@ -14,15 +14,15 @@ namespace OpenNest.Geometry
public Box(double x, double y, double w, double h)
{
Location = new Vector(x, y);
Width = w;
Length = h;
Length = w;
Width = h;
}
public Vector Location;
public Vector Center
{
get { return new Vector(X + Width * 0.5, Y + Length * 0.5); }
get { return new Vector(X + Length * 0.5, Y + Width * 0.5); }
}
public Size Size;
@@ -76,12 +76,12 @@ namespace OpenNest.Geometry
public Box Translate(double x, double y)
{
return new Box(X + x, Y + y, Width, Length);
return new Box(X + x, Y + y, Length, Width);
}
public Box Translate(Vector offset)
{
return new Box(X + offset.X, Y + offset.Y, Width, Length);
return new Box(X + offset.X, Y + offset.Y, Length, Width);
}
public double Left
@@ -91,12 +91,12 @@ namespace OpenNest.Geometry
public double Right
{
get { return X + Width; }
get { return X + Length; }
}
public double Top
{
get { return Y + Length; }
get { return Y + Width; }
}
public double Bottom
@@ -207,7 +207,7 @@ namespace OpenNest.Geometry
public Box Offset(double d)
{
return new Box(X - d, Y - d, Width + d * 2, Length + d * 2);
return new Box(X - d, Y - d, Length + d * 2, Width + d * 2);
}
public override string ToString()
+4 -4
View File
@@ -9,7 +9,7 @@
var x = large.Left;
var y = small.Top;
var w = large.Width;
var w = large.Length;
var h = large.Top - y;
return new Box(x, y, w, h);
@@ -23,7 +23,7 @@
var x = large.Left;
var y = large.Bottom;
var w = small.Left - x;
var h = large.Length;
var h = large.Width;
return new Box(x, y, w, h);
}
@@ -35,7 +35,7 @@
var x = large.Left;
var y = large.Bottom;
var w = large.Width;
var w = large.Length;
var h = small.Top - y;
return new Box(x, y, w, h);
@@ -49,7 +49,7 @@
var x = small.Right;
var y = large.Bottom;
var w = large.Right - x;
var h = large.Length;
var h = large.Width;
return new Box(x, y, w, h);
}
+3 -1
View File
@@ -137,7 +137,9 @@ namespace OpenNest.Geometry
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
{
var points = new List<Vector>();
var stepAngle = Angle.TwoPI / segments;
var stepAngle = Rotation == RotationType.CW
? -Angle.TwoPI / segments
: Angle.TwoPI / segments;
var r = circumscribe && segments > 0
? Radius / System.Math.Cos(stepAngle / 2.0)
+7
View File
@@ -1,4 +1,5 @@
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Drawing;
@@ -10,10 +11,16 @@ namespace OpenNest.Geometry
protected Entity()
{
Id = Guid.NewGuid();
Layer = OpenNest.Geometry.Layer.Default;
boundingBox = new Box();
}
/// <summary>
/// Unique identifier for this entity, stable across edit sessions.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
/// </summary>
+4 -4
View File
@@ -370,23 +370,23 @@ namespace OpenNest.Geometry
if (StartPoint.X < EndPoint.X)
{
boundingBox.X = StartPoint.X;
boundingBox.Width = EndPoint.X - StartPoint.X;
boundingBox.Length = EndPoint.X - StartPoint.X;
}
else
{
boundingBox.X = EndPoint.X;
boundingBox.Width = StartPoint.X - EndPoint.X;
boundingBox.Length = StartPoint.X - EndPoint.X;
}
if (StartPoint.Y < EndPoint.Y)
{
boundingBox.Y = StartPoint.Y;
boundingBox.Length = EndPoint.Y - StartPoint.Y;
boundingBox.Width = EndPoint.Y - StartPoint.Y;
}
else
{
boundingBox.Y = EndPoint.Y;
boundingBox.Length = StartPoint.Y - EndPoint.Y;
boundingBox.Width = StartPoint.Y - EndPoint.Y;
}
}
+2 -2
View File
@@ -311,8 +311,8 @@ namespace OpenNest.Geometry
boundingBox.X = minX;
boundingBox.Y = minY;
boundingBox.Width = maxX - minX;
boundingBox.Length = maxY - minY;
boundingBox.Length = maxX - minX;
boundingBox.Width = maxY - minY;
}
public override Entity OffsetEntity(double distance, OffsetSide side)
+2 -2
View File
@@ -605,7 +605,7 @@ namespace OpenNest.Geometry
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break;
case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW });
break;
}
}
@@ -640,7 +640,7 @@ namespace OpenNest.Geometry
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break;
case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW });
break;
}
}
+385 -104
View File
@@ -104,6 +104,98 @@ namespace OpenNest.Geometry
return double.MaxValue;
}
/// <summary>
/// Solves ray-circle intersection, returning the two parametric t values.
/// Returns false if no real intersection exists.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
private static bool SolveRayCircle(
double vx, double vy,
double cx, double cy, double r,
double dirX, double dirY,
out double t1, out double t2)
{
var ox = vx - cx;
var oy = vy - cy;
var a = dirX * dirX + dirY * dirY;
var b = 2.0 * (ox * dirX + oy * dirY);
var c = ox * ox + oy * oy - r * r;
var discriminant = b * b - 4.0 * a * c;
if (discriminant < 0)
{
t1 = t2 = double.MaxValue;
return false;
}
var sqrtD = System.Math.Sqrt(discriminant);
var inv2a = 1.0 / (2.0 * a);
t1 = (-b - sqrtD) * inv2a;
t2 = (-b + sqrtD) * inv2a;
return true;
}
/// <summary>
/// Computes the distance from a point along a direction to an arc.
/// Solves ray-circle intersection, then constrains hits to the arc's
/// angular span. Returns double.MaxValue if no hit.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayArcDistance(
double vx, double vy,
double cx, double cy, double r,
double startAngle, double endAngle, bool reversed,
double dirX, double dirY)
{
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
return double.MaxValue;
var best = double.MaxValue;
if (t1 > -Tolerance.Epsilon)
{
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
vy + t1 * dirY - cy, vx + t1 * dirX - cx));
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t1 > Tolerance.Epsilon ? t1 : 0;
}
if (t2 > -Tolerance.Epsilon && t2 < best)
{
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
vy + t2 * dirY - cy, vx + t2 * dirX - cx));
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t2 > Tolerance.Epsilon ? t2 : 0;
}
return best;
}
/// <summary>
/// Computes the distance from a point along a direction to a full circle.
/// Returns double.MaxValue if no hit.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayCircleDistance(
double vx, double vy,
double cx, double cy, double r,
double dirX, double dirY)
{
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
return double.MaxValue;
if (t1 > Tolerance.Epsilon) return t1;
if (t1 >= -Tolerance.Epsilon) return 0;
if (t2 > Tolerance.Epsilon) return t2;
if (t2 >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
/// <summary>
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
@@ -111,57 +203,7 @@ namespace OpenNest.Geometry
/// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
{
var minDist = double.MaxValue;
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
// Sort edges for pruning if not already sorted (usually they aren't here)
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite);
if (d < minDist) minDist = d;
}
return minDist;
return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
}
/// <summary>
@@ -176,21 +218,10 @@ namespace OpenNest.Geometry
var movingOffset = new Vector(movingDx, movingDy);
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1 + movingOffset);
movingVertices.Add(movingLines[i].pt2 + movingOffset);
}
var movingVertices = CollectVertices(movingLines, movingOffset);
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
var stationaryEdges = ToEdgeArray(stationaryLines);
SortEdgesForPruning(stationaryEdges, direction);
foreach (var mv in movingVertices)
{
@@ -200,21 +231,10 @@ namespace OpenNest.Geometry
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
var movingEdges = ToEdgeArray(movingLines);
SortEdgesForPruning(movingEdges, opposite);
foreach (var sv in stationaryVertices)
{
@@ -253,15 +273,11 @@ namespace OpenNest.Geometry
{
var minDist = double.MaxValue;
// Extract unique vertices from moving edges.
var movingVertices = new HashSet<Vector>();
for (var i = 0; i < movingEdges.Length; i++)
{
movingVertices.Add(movingEdges[i].start + movingOffset);
movingVertices.Add(movingEdges[i].end + movingOffset);
}
SortEdgesForPruning(stationaryEdges, direction);
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = CollectVertices(movingEdges, movingOffset);
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
@@ -270,12 +286,9 @@ namespace OpenNest.Geometry
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (var i = 0; i < stationaryEdges.Length; i++)
{
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
}
SortEdgesForPruning(movingEdges, opposite);
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
foreach (var sv in stationaryVertices)
{
@@ -467,12 +480,7 @@ namespace OpenNest.Geometry
var dirX = direction.X;
var dirY = direction.Y;
var movingVertices = new HashSet<Vector>();
for (var i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
var movingVertices = CollectVertices(movingLines, Vector.Zero);
foreach (var mv in movingVertices)
{
@@ -487,12 +495,7 @@ namespace OpenNest.Geometry
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = new HashSet<Vector>();
for (var i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
foreach (var sv in stationaryVertices)
{
@@ -507,6 +510,284 @@ namespace OpenNest.Geometry
return minDist;
}
/// <summary>
/// Computes the minimum translation distance along a push direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
/// stationaryEntities. Delegates to the Vector-based overload.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction)
{
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0));
}
/// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
/// stationaryEntities. Works with native Line, Arc, and Circle entities
/// without tessellation.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, Vector direction)
{
var minDist = double.MaxValue;
var dirX = direction.X;
var dirY = direction.Y;
var movingVertices = ExtractEntityVertices(movingEntities);
for (var v = 0; v < movingVertices.Length; v++)
{
var vx = movingVertices[v].X;
var vy = movingVertices[v].Y;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) return 0;
}
}
}
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
for (var v = 0; v < stationaryVertices.Length; v++)
{
var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) return 0;
}
}
}
// Phase 3: Arc-to-line closest-point check.
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
// closest point on a small corner arc to a straight edge may lie between
// those samples. Use ClosestPointTo to find it and fire a ray from there.
minDist = ArcToLineClosestDistance(movingEntities, stationaryEntities, dirX, dirY, minDist);
if (minDist <= 0) return 0;
minDist = ArcToLineClosestDistance(stationaryEntities, movingEntities, oppX, oppY, minDist);
if (minDist <= 0) return 0;
// Phase 4: Curve-to-curve direct distance.
// The vertex-to-entity approach misses the closest contact between two
// curved entities (circles/arcs) because only a few cardinal vertices are
// sampled. The true closest contact along the push direction is found by
// treating it as a ray from one center to an expanded circle at the other
// center (radius = r1 + r2).
for (var i = 0; i < movingEntities.Count; i++)
{
var me = movingEntities[i];
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
continue;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var se = stationaryEntities[j];
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
continue;
var d = RayCircleDistance(mcx, mcy, scx, scy, mr + sr, dirX, dirY);
if (d >= minDist)
continue;
// For arcs, verify the contact point falls within both arcs' angular ranges.
if (me is Arc || se is Arc)
{
var mx = mcx + d * dirX;
var my = mcy + d * dirY;
var toCx = scx - mx;
var toCy = scy - my;
if (me is Arc mArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
continue;
}
if (se is Arc sArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
continue;
}
}
minDist = d;
if (d <= 0) return 0;
}
}
return minDist;
}
private static double ArcToLineClosestDistance(
List<Entity> arcEntities, List<Entity> lineEntities,
double dirX, double dirY, double minDist)
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (arcEntities[i] is Arc arc)
{
for (var j = 0; j < lineEntities.Count; j++)
{
if (lineEntities[j] is Line line)
{
var linePt = line.ClosestPointTo(arc.Center);
var arcPt = arc.ClosestPointTo(linePt);
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
dirX, dirY);
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
}
}
}
}
return minDist;
}
private static double RayEntityDistance(
double vx, double vy, Entity entity, double dirX, double dirY)
{
if (entity is Line line)
{
return RayEdgeDistance(vx, vy,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
dirX, dirY);
}
if (entity is Arc arc)
{
return RayArcDistance(vx, vy,
arc.Center.X, arc.Center.Y, arc.Radius,
arc.StartAngle, arc.EndAngle, arc.IsReversed,
dirX, dirY);
}
if (entity is Circle circle)
{
return RayCircleDistance(vx, vy,
circle.Center.X, circle.Center.Y, circle.Radius,
dirX, dirY);
}
return double.MaxValue;
}
private static Vector[] ExtractEntityVertices(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity is Line line)
{
vertices.Add(line.pt1);
vertices.Add(line.pt2);
}
else if (entity is Arc arc)
{
vertices.Add(arc.StartPoint());
vertices.Add(arc.EndPoint());
AddArcExtremeVertices(vertices, arc);
}
else if (entity is Circle circle)
{
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
}
}
return vertices.ToArray();
}
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
if (arc.IsReversed)
Generic.Swap(ref a1, ref a2);
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
private static HashSet<Vector> CollectVertices(List<Line> lines, Vector offset)
{
return CollectVertices(ToEdgeArray(lines), offset);
}
private static HashSet<Vector> CollectVertices((Vector start, Vector end)[] edges, Vector offset)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < edges.Length; i++)
{
vertices.Add(edges[i].start + offset);
vertices.Add(edges[i].end + offset);
}
return vertices;
}
private static (Vector start, Vector end)[] ToEdgeArray(List<Line> lines)
{
var edges = new (Vector start, Vector end)[lines.Count];
for (var i = 0; i < lines.Count; i++)
edges[i] = (lines[i].pt1, lines[i].pt2);
return edges;
}
private static void SortEdgesForPruning((Vector start, Vector end)[] edges, PushDirection direction)
{
if (direction == PushDirection.Left || direction == PushDirection.Right)
System.Array.Sort(edges, (a, b) =>
System.Math.Min(a.start.Y, a.end.Y).CompareTo(System.Math.Min(b.start.Y, b.end.Y)));
else
System.Array.Sort(edges, (a, b) =>
System.Math.Min(a.start.X, a.end.X).CompareTo(System.Math.Min(b.start.X, b.end.X)));
}
private static bool TryGetCurveParams(Entity entity, out double cx, out double cy, out double r)
{
if (entity is Circle circle)
{
cx = circle.Center.X; cy = circle.Center.Y; r = circle.Radius;
return true;
}
if (entity is Arc arc)
{
cx = arc.Center.X; cy = arc.Center.Y; r = arc.Radius;
return true;
}
cx = cy = r = 0;
return false;
}
private static double BoxProjectionMin(Box box, double dx, double dy)
{
var x = dx >= 0 ? box.Left : box.Right;
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace OpenNest.Math
{
/// <summary>
/// Recursive descent parser for simple arithmetic expressions supporting
/// +, -, *, /, parentheses, unary minus/plus, and $variable references.
/// </summary>
public static class ExpressionEvaluator
{
public static double Evaluate(string expression, IReadOnlyDictionary<string, double> variables)
{
var parser = new Parser(expression, variables);
var result = parser.ParseExpression();
parser.SkipWhitespace();
if (!parser.IsEnd)
throw new FormatException($"Unexpected character at position {parser.Position}: '{parser.Current}'");
return result;
}
private ref struct Parser
{
private readonly ReadOnlySpan<char> _input;
private readonly IReadOnlyDictionary<string, double> _variables;
private int _pos;
public Parser(string input, IReadOnlyDictionary<string, double> variables)
{
_input = input.AsSpan();
_variables = variables;
_pos = 0;
}
public int Position => _pos;
public bool IsEnd => _pos >= _input.Length;
public char Current => _input[_pos];
public void SkipWhitespace()
{
while (_pos < _input.Length && _input[_pos] == ' ')
_pos++;
}
// Expression = Term (('+' | '-') Term)*
public double ParseExpression()
{
SkipWhitespace();
var left = ParseTerm();
while (true)
{
SkipWhitespace();
if (IsEnd) break;
var op = Current;
if (op != '+' && op != '-') break;
_pos++;
SkipWhitespace();
var right = ParseTerm();
left = op == '+' ? left + right : left - right;
}
return left;
}
// Term = Unary (('*' | '/') Unary)*
private double ParseTerm()
{
var left = ParseUnary();
while (true)
{
SkipWhitespace();
if (IsEnd) break;
var op = Current;
if (op != '*' && op != '/') break;
_pos++;
SkipWhitespace();
var right = ParseUnary();
left = op == '*' ? left * right : left / right;
}
return left;
}
// Unary = ('-' | '+')? Primary
private double ParseUnary()
{
SkipWhitespace();
if (!IsEnd && Current == '-')
{
_pos++;
return -ParsePrimary();
}
if (!IsEnd && Current == '+')
{
_pos++;
}
return ParsePrimary();
}
// Primary = '(' Expression ')' | '$' Identifier | Number
private double ParsePrimary()
{
SkipWhitespace();
if (IsEnd)
throw new FormatException("Unexpected end of expression.");
if (Current == '(')
{
_pos++; // consume '('
var value = ParseExpression();
SkipWhitespace();
if (IsEnd || Current != ')')
throw new FormatException("Expected closing parenthesis.");
_pos++; // consume ')'
return value;
}
if (Current == '$')
{
_pos++; // consume '$'
var start = _pos;
while (_pos < _input.Length && (char.IsLetterOrDigit(_input[_pos]) || _input[_pos] == '_'))
_pos++;
if (_pos == start)
throw new FormatException("Expected variable name after '$'.");
var name = _input.Slice(start, _pos - start).ToString();
if (!_variables.TryGetValue(name, out var varValue))
throw new KeyNotFoundException($"Undefined variable: ${name}");
return varValue;
}
// Number
var numStart = _pos;
while (_pos < _input.Length && (char.IsDigit(_input[_pos]) || _input[_pos] == '.'))
_pos++;
if (_pos == numStart)
throw new FormatException($"Unexpected character '{Current}' at position {_pos}.");
var numSpan = _input.Slice(numStart, _pos - numStart).ToString();
if (!double.TryParse(numSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
throw new FormatException($"Invalid number: '{numSpan}'");
return number;
}
}
}
}
+5
View File
@@ -1,6 +1,7 @@
using OpenNest.Collections;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
namespace OpenNest
{
@@ -51,6 +52,10 @@ namespace OpenNest
public PlateSettings PlateDefaults { get; set; }
public List<PlateOption> PlateOptions { get; set; } = new();
public double SalvageRate { get; set; } = 0.5;
public Plate CreatePlate()
{
var plate = PlateDefaults.CreateNew();
+21 -2
View File
@@ -72,6 +72,18 @@ namespace OpenNest
UpdateBounds();
}
public void ApplySingleLeadIn(CNC.CuttingStrategy.CuttingParameters parameters,
Geometry.Vector point, Geometry.Entity entity, CNC.CuttingStrategy.ContourType contourType)
{
preLeadInRotation = Rotation;
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
var result = strategy.ApplySingle(Program, point, entity, contourType);
Program = result.Program;
CuttingParameters = parameters;
HasManualLeadIns = true;
UpdateBounds();
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
@@ -178,7 +190,14 @@ namespace OpenNest
{
var rotation = Rotation;
Program = BaseDrawing.Program.Clone() as Program;
Program.Rotate(Program.Rotation - rotation);
if (!Math.Tolerance.IsEqualTo(rotation, 0))
Program.Rotate(rotation);
HasManualLeadIns = false;
LeadInsLocked = false;
CuttingParameters = null;
UpdateBounds();
}
/// <summary>
@@ -265,7 +284,7 @@ namespace OpenNest
var part = new Part(BaseDrawing, Program,
location + offset,
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
BoundingBox.Width, BoundingBox.Length));
BoundingBox.Length, BoundingBox.Width));
return part;
}
+115 -4
View File
@@ -39,7 +39,115 @@ namespace OpenNest
return lines;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
/// <summary>
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
/// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs.
/// </summary>
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
if (offsetShape == null)
return new List<Entity>();
// Offset the shape's entities to the part's location.
// OffsetOutward creates a new Shape, so mutating is safe.
foreach (var entity in offsetShape.Entities)
entity.Offset(part.Location);
return offsetShape.Entities;
}
/// <summary>
/// Returns all entities (perimeter + cutouts) with spacing offset applied,
/// without tessellation. Perimeter is offset outward, cutouts inward.
/// </summary>
public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var entities = new List<Entity>();
var perimeter = profile.Perimeter.OffsetOutward(spacing);
if (perimeter != null)
{
foreach (var entity in perimeter.Entities)
entity.Offset(part.Location);
entities.AddRange(perimeter.Entities);
}
foreach (var cutout in profile.Cutouts)
{
var inset = cutout.OffsetInward(spacing);
if (inset == null) continue;
foreach (var entity in inset.Entities)
entity.Offset(part.Location);
entities.AddRange(inset.Entities);
}
return entities;
}
/// <summary>
/// Returns perimeter entities at the part's world location, without tessellation
/// or spacing offset.
/// </summary>
public static List<Entity> GetPerimeterEntities(Part part)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
}
/// <summary>
/// Returns all entities (perimeter + cutouts) at the part's world location,
/// without tessellation or spacing offset.
/// </summary>
public static List<Entity> GetPartEntities(Part part)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
foreach (var cutout in profile.Cutouts)
entities.AddRange(CopyEntitiesAtLocation(cutout.Entities, part.Location));
return entities;
}
private static List<Entity> CopyEntitiesAtLocation(List<Entity> source, Vector location)
{
var result = new List<Entity>(source.Count);
for (var i = 0; i < source.Count; i++)
{
var entity = source[i];
Entity copy;
if (entity is Line line)
copy = new Line(line.StartPoint + location, line.EndPoint + location);
else if (entity is Arc arc)
copy = new Arc(arc.Center + location, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed);
else if (entity is Circle circle)
copy = new Circle(circle.Center + location, circle.Radius);
else
continue;
result.Add(copy);
}
return result;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001,
bool perimeterOnly = false)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
@@ -50,9 +158,12 @@ namespace OpenNest
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location);
foreach (var cutout in profile.Cutouts)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location);
if (!perimeterOnly)
{
foreach (var cutout in profile.Cutouts)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location);
}
return lines;
}
+7 -7
View File
@@ -424,7 +424,7 @@ namespace OpenNest
{
var plateBox = new Box();
// Convention: Size.Length = X axis (horizontal), Size.Width = Y axis (vertical)
// Width = Y axis (vertical), Length = X axis (horizontal)
switch (Quadrant)
{
case 1:
@@ -451,8 +451,8 @@ namespace OpenNest
return new Box();
}
plateBox.Width = Size.Length;
plateBox.Length = Size.Width;
plateBox.Width = Size.Width;
plateBox.Length = Size.Length;
if (!includeParts)
return plateBox;
@@ -468,11 +468,11 @@ namespace OpenNest
? partsBox.Bottom
: plateBox.Bottom;
boundingBox.Width = partsBox.Right > plateBox.Right
boundingBox.Length = partsBox.Right > plateBox.Right
? partsBox.Right - boundingBox.X
: plateBox.Right - boundingBox.X;
boundingBox.Length = partsBox.Top > plateBox.Top
boundingBox.Width = partsBox.Top > plateBox.Top
? partsBox.Top - boundingBox.Y
: plateBox.Top - boundingBox.Y;
@@ -489,8 +489,8 @@ namespace OpenNest
box.X += EdgeSpacing.Left;
box.Y += EdgeSpacing.Bottom;
box.Width -= EdgeSpacing.Left + EdgeSpacing.Right;
box.Length -= EdgeSpacing.Top + EdgeSpacing.Bottom;
box.Length -= EdgeSpacing.Left + EdgeSpacing.Right;
box.Width -= EdgeSpacing.Top + EdgeSpacing.Bottom;
return box;
}
+243
View File
@@ -0,0 +1,243 @@
using OpenNest.Collections;
using System;
namespace OpenNest
{
public class PlateChangedEventArgs : EventArgs
{
public Plate Plate { get; }
public int Index { get; }
public PlateChangedEventArgs(Plate plate, int index)
{
Plate = plate;
Index = index;
}
}
public class PlateManager : IDisposable
{
private readonly Nest nest;
private bool disposed;
private bool suppressNavigation;
private bool batching;
private Plate subscribedLast;
private Plate subscribedSecondToLast;
public event EventHandler<PlateChangedEventArgs> CurrentPlateChanged;
public event EventHandler PlateListChanged;
public PlateManager(Nest nest)
{
this.nest = nest;
nest.Plates.ItemAdded += OnPlateAdded;
nest.Plates.ItemRemoved += OnPlateRemoved;
}
public int CurrentIndex { get; private set; }
public Plate CurrentPlate => nest.Plates.Count > 0 ? nest.Plates[CurrentIndex] : null;
public int Count => nest.Plates.Count;
public bool IsFirst => Count == 0 || CurrentIndex <= 0;
public bool IsLast => CurrentIndex + 1 >= Count;
public bool CanRemoveCurrent => Count > 1 && CurrentPlate != null && CurrentPlate.Parts.Count > 0;
public void LoadFirst()
{
if (Count == 0)
return;
CurrentIndex = 0;
FireCurrentPlateChanged();
}
public void LoadLast()
{
if (Count == 0)
return;
CurrentIndex = Count - 1;
FireCurrentPlateChanged();
}
public bool LoadNext()
{
if (CurrentIndex + 1 >= Count)
return false;
CurrentIndex++;
FireCurrentPlateChanged();
return true;
}
public bool LoadPrevious()
{
if (Count == 0 || CurrentIndex - 1 < 0)
return false;
CurrentIndex--;
FireCurrentPlateChanged();
return true;
}
public void LoadAt(int index)
{
if (index < 0 || index >= Count)
return;
CurrentIndex = index;
FireCurrentPlateChanged();
}
public void EnsureSentinel()
{
suppressNavigation = true;
try
{
if (Count == 0 || nest.Plates[^1].Parts.Count > 0)
nest.CreatePlate();
while (Count > 1
&& nest.Plates[^1].Parts.Count == 0
&& nest.Plates[^2].Parts.Count == 0)
{
nest.Plates.RemoveAt(Count - 1);
}
}
finally
{
suppressNavigation = false;
}
SubscribeToTailPlates();
}
public void BeginBatch()
{
batching = true;
}
public void EndBatch()
{
batching = false;
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
FireCurrentPlateChanged();
}
public Plate GetOrCreateEmpty()
{
for (var i = Count - 1; i >= 0; i--)
{
if (nest.Plates[i].Parts.Count == 0)
return nest.Plates[i];
}
return nest.CreatePlate();
}
public void RemoveCurrent()
{
if (Count < 2)
return;
nest.Plates.RemoveAt(CurrentIndex);
}
private void SubscribeToTailPlates()
{
UnsubscribeFromTailPlates();
if (Count > 0)
{
subscribedLast = nest.Plates[^1];
subscribedLast.PartAdded += OnTailPartAdded;
subscribedLast.PartRemoved += OnTailPartRemoved;
}
if (Count > 1)
{
subscribedSecondToLast = nest.Plates[^2];
subscribedSecondToLast.PartAdded += OnTailPartAdded;
subscribedSecondToLast.PartRemoved += OnTailPartRemoved;
}
}
private void UnsubscribeFromTailPlates()
{
if (subscribedLast != null)
{
subscribedLast.PartAdded -= OnTailPartAdded;
subscribedLast.PartRemoved -= OnTailPartRemoved;
subscribedLast = null;
}
if (subscribedSecondToLast != null)
{
subscribedSecondToLast.PartAdded -= OnTailPartAdded;
subscribedSecondToLast.PartRemoved -= OnTailPartRemoved;
subscribedSecondToLast = null;
}
}
private void OnTailPartAdded(object sender, ItemAddedEventArgs<Part> e)
{
if (!batching)
EnsureSentinel();
}
private void OnTailPartRemoved(object sender, ItemRemovedEventArgs<Part> e)
{
if (!batching)
EnsureSentinel();
}
private void OnPlateAdded(object sender, ItemAddedEventArgs<Plate> e)
{
if (!suppressNavigation && !batching)
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
if (!suppressNavigation)
{
CurrentIndex = Count - 1;
FireCurrentPlateChanged();
}
}
private void OnPlateRemoved(object sender, ItemRemovedEventArgs<Plate> e)
{
if (CurrentIndex >= Count && Count > 0)
CurrentIndex = Count - 1;
if (!suppressNavigation && !batching)
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
if (!suppressNavigation)
FireCurrentPlateChanged();
}
private void FireCurrentPlateChanged()
{
CurrentPlateChanged?.Invoke(this, new PlateChangedEventArgs(CurrentPlate, CurrentIndex));
}
public void Dispose()
{
if (disposed)
return;
disposed = true;
UnsubscribeFromTailPlates();
nest.Plates.ItemAdded -= OnPlateAdded;
nest.Plates.ItemRemoved -= OnPlateRemoved;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace OpenNest
{
public class PlateOptimizerResult
{
public List<Part> Parts { get; set; } = new();
public PlateOption ChosenSize { get; set; }
public double NetCost { get; set; }
public double Utilization { get; set; }
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace OpenNest
{
public class PlateOption
{
public double Width { get; set; }
public double Length { get; set; }
public double Cost { get; set; }
public double Area => Width * Length;
}
}
+5
View File
@@ -7,6 +7,11 @@ namespace OpenNest.Shapes
{
public double Diameter { get; set; }
public override void SetPreviewDefaults()
{
Diameter = 8;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>
+9
View File
@@ -11,6 +11,15 @@ namespace OpenNest.Shapes
public double HolePatternDiameter { get; set; }
public int HoleCount { get; set; }
public override void SetPreviewDefaults()
{
NominalPipeSize = 2;
OD = 7.5;
HoleDiameter = 0.875;
HolePatternDiameter = 5.5;
HoleCount = 8;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>();
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
public double Base { get; set; }
public double Height { get; set; }
public override void SetPreviewDefaults()
{
Base = 8;
Height = 10;
}
public override Drawing GetDrawing()
{
var midX = Base / 2.0;
+8
View File
@@ -10,6 +10,14 @@ namespace OpenNest.Shapes
public double LegWidth { get; set; }
public double LegHeight { get; set; }
public override void SetPreviewDefaults()
{
Width = 8;
Height = 10;
LegWidth = 3;
LegHeight = 3;
}
public override Drawing GetDrawing()
{
var lw = LegWidth > 0 ? LegWidth : Width / 2.0;
+5
View File
@@ -7,6 +7,11 @@ namespace OpenNest.Shapes
{
public double Width { get; set; }
public override void SetPreviewDefaults()
{
Width = 8;
}
public override Drawing GetDrawing()
{
var center = Width / 2.0;
+6
View File
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
public double Length { get; set; }
public double Width { get; set; }
public override void SetPreviewDefaults()
{
Length = 12;
Width = 6;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
public double Width { get; set; }
public double Height { get; set; }
public override void SetPreviewDefaults()
{
Width = 8;
Height = 6;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>
+6
View File
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
public double OuterDiameter { get; set; }
public double InnerDiameter { get; set; }
public override void SetPreviewDefaults()
{
OuterDiameter = 10;
InnerDiameter = 6;
}
public override Drawing GetDrawing()
{
var entities = new List<Entity>
@@ -10,6 +10,13 @@ namespace OpenNest.Shapes
public double Width { get; set; }
public double Radius { get; set; }
public override void SetPreviewDefaults()
{
Length = 12;
Width = 6;
Radius = 1;
}
public override Drawing GetDrawing()
{
var r = Radius;
+2
View File
@@ -26,6 +26,8 @@ namespace OpenNest.Shapes
public abstract Drawing GetDrawing();
public virtual void SetPreviewDefaults() { }
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
{
var json = File.ReadAllText(path);
+8
View File
@@ -10,6 +10,14 @@ namespace OpenNest.Shapes
public double StemWidth { get; set; }
public double BarHeight { get; set; }
public override void SetPreviewDefaults()
{
Width = 10;
Height = 8;
StemWidth = 3;
BarHeight = 3;
}
public override Drawing GetDrawing()
{
var sw = StemWidth > 0 ? StemWidth : Width / 3.0;
+7
View File
@@ -9,6 +9,13 @@ namespace OpenNest.Shapes
public double BottomWidth { get; set; }
public double Height { get; set; }
public override void SetPreviewDefaults()
{
TopWidth = 6;
BottomWidth = 10;
Height = 6;
}
public override Drawing GetDrawing()
{
var offset = (BottomWidth - TopWidth) / 2.0;
@@ -13,8 +13,8 @@ public static class AutoSplitCalculator
var lines = new List<SplitLine>();
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableWidth) - 1 : 0;
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableHeight) - 1 : 0;
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableWidth) - 1 : 0;
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableHeight) - 1 : 0;
if (verticalSplits < 0) verticalSplits = 0;
if (horizontalSplits < 0) horizontalSplits = 0;
@@ -34,14 +34,14 @@ public static class AutoSplitCalculator
if (verticalPieces > 1)
{
var spacing = partBounds.Width / verticalPieces;
var spacing = partBounds.Length / verticalPieces;
for (var i = 1; i < verticalPieces; i++)
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
}
if (horizontalPieces > 1)
{
var spacing = partBounds.Length / horizontalPieces;
var spacing = partBounds.Width / horizontalPieces;
for (var i = 1; i < horizontalPieces; i++)
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
}
+2 -1
View File
@@ -17,7 +17,8 @@ namespace OpenNest.Engine.BestFit
if (!result.Keep)
continue;
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight))
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight) ||
result.LongestSide > System.Math.Max(MaxPlateWidth, MaxPlateHeight))
{
result.Keep = false;
result.Reason = "Exceeds plate dimensions";
+3
View File
@@ -4,6 +4,7 @@ using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
@@ -49,6 +50,8 @@ namespace OpenNest.Engine.BestFit
var allCandidates = candidateBags.SelectMany(c => c).ToList();
Debug.WriteLine($"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates");
var results = _evaluator.EvaluateAll(allCandidates);
_filter.Apply(results);
+248 -8
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
@@ -17,7 +18,6 @@ namespace OpenNest.Engine.BestFit
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
@@ -43,7 +43,6 @@ namespace OpenNest.Engine.BestFit
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary edges
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
@@ -66,7 +65,6 @@ namespace OpenNest.Engine.BestFit
}
}
// Case 2: Facing stationary vertices → moving edges (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
@@ -95,6 +93,253 @@ namespace OpenNest.Engine.BestFit
return results;
}
public double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets)
{
var count = offsets.Length;
var results = new double[count];
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
var movingCurves = ExtractCurveParams(movingEntities);
var stationaryCurves = ExtractCurveParams(stationaryEntities);
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var offset = offsets[i];
var dirX = offset.DirX;
var dirY = offset.DirY;
var oppX = -dirX;
var oppY = -dirY;
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary entities
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
var vy = leadingMoving[v].Y + offset.Dy;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], 0, 0, dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Case 2: Facing stationary vertices → moving entities (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
var svy = facingStationary[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(svx, svy, movingEntities[j], offset.Dx, offset.Dy, oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Phase 3: Curve-to-curve direct distance.
// Vertex sampling misses the true contact between two curved entities
// when the approach angle doesn't align with a sampled vertex.
for (var m = 0; m < movingCurves.Length; m++)
{
var mc = movingCurves[m];
var mcx = mc.Cx + offset.Dx;
var mcy = mc.Cy + offset.Dy;
for (var s = 0; s < stationaryCurves.Length; s++)
{
var sc = stationaryCurves[s];
var d = SpatialQuery.RayCircleDistance(
mcx, mcy, sc.Cx, sc.Cy, mc.Radius + sc.Radius, dirX, dirY);
if (d >= minDist || d == double.MaxValue)
continue;
if (mc.Entity is Arc || sc.Entity is Arc)
{
var mx = mcx + d * dirX;
var my = mcy + d * dirY;
var toCx = sc.Cx - mx;
var toCy = sc.Cy - my;
if (mc.Entity is Arc mArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
continue;
}
if (sc.Entity is Arc sArc)
{
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
continue;
}
}
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
results[i] = minDist;
});
return results;
}
private readonly struct CurveParams
{
public readonly Entity Entity;
public readonly double Cx, Cy, Radius;
public CurveParams(Entity entity, double cx, double cy, double radius)
{
Entity = entity;
Cx = cx;
Cy = cy;
Radius = radius;
}
}
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
{
var curves = new List<CurveParams>();
for (var i = 0; i < entities.Count; i++)
{
if (entities[i] is Circle circle)
curves.Add(new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius));
else if (entities[i] is Arc arc)
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
}
return curves.ToArray();
}
private static double RayEntityDistance(
double vx, double vy, Entity entity,
double entityOffsetX, double entityOffsetY,
double dirX, double dirY)
{
if (entity is Line line)
{
return SpatialQuery.RayEdgeDistance(
vx, vy,
line.StartPoint.X + entityOffsetX, line.StartPoint.Y + entityOffsetY,
line.EndPoint.X + entityOffsetX, line.EndPoint.Y + entityOffsetY,
dirX, dirY);
}
if (entity is Arc arc)
{
return SpatialQuery.RayArcDistance(
vx, vy,
arc.Center.X + entityOffsetX, arc.Center.Y + entityOffsetY,
arc.Radius,
arc.StartAngle, arc.EndAngle, arc.IsReversed,
dirX, dirY);
}
if (entity is Circle circle)
{
return SpatialQuery.RayCircleDistance(
vx, vy,
circle.Center.X + entityOffsetX, circle.Center.Y + entityOffsetY,
circle.Radius,
dirX, dirY);
}
return double.MaxValue;
}
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity is Line line)
{
vertices.Add(line.StartPoint);
vertices.Add(line.EndPoint);
}
else if (entity is Arc arc)
{
vertices.Add(arc.StartPoint());
vertices.Add(arc.EndPoint());
AddArcExtremes(vertices, arc);
}
else if (entity is Circle circle)
{
// Four cardinal points
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
}
}
return vertices.ToArray();
}
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
var reversed = arc.IsReversed;
if (reversed)
Generic.Swap(ref a1, ref a2);
// Right (0°)
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
// Top (90°)
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
// Left (180°)
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
// Bottom (270°)
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
private static Vector[] ExtractUniqueVertices(List<Line> lines)
{
var vertices = new HashSet<Vector>();
@@ -106,11 +351,6 @@ namespace OpenNest.Engine.BestFit
return vertices.ToArray();
}
/// <summary>
/// Filters vertices by their projection onto the push direction.
/// keepHigh=true returns the leading half (front face, closest to target).
/// keepHigh=false returns the facing half (side facing the approaching part).
/// </summary>
private static Vector[] FilterVerticesByProjection(
Vector[] vertices, double dirX, double dirY, bool keepHigh)
{
@@ -36,6 +36,16 @@ namespace OpenNest.Engine.BestFit
flatOffsets, count, directions);
}
public double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets)
{
// GPU path doesn't support native entities yet — fall back to CPU.
var cpu = new CpuDistanceComputer();
return cpu.ComputeDistances(stationaryEntities, movingEntities, offsets);
}
/// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
/// Left=0, Down=1, Right=2, Up=3.
@@ -9,5 +9,10 @@ namespace OpenNest.Engine.BestFit
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets);
double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets);
}
}
@@ -36,8 +36,8 @@ namespace OpenNest.Engine.BestFit
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
var halfSpacing = spacing / 2;
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
var part1Entities = PartGeometry.GetOffsetPerimeterEntities(part1, halfSpacing);
var part2Entities = PartGeometry.GetOffsetPerimeterEntities(part2Template, halfSpacing);
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
@@ -48,7 +48,7 @@ namespace OpenNest.Engine.BestFit
return candidates;
var distances = _distanceComputer.ComputeDistances(
part1Lines, part2TemplateLines, offsets);
part1Entities, part2Entities, offsets);
var testNumber = 0;
@@ -89,15 +89,20 @@ namespace OpenNest.Engine.BestFit
if (isHorizontalPush)
{
perpMin = -(bbox2.Length + spacing);
perpMax = bbox1.Length + bbox2.Length + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
// Perpendicular sweep along Y → Width; push extent along X → Length
// Trim to offsets where the parts overlap by at least 50%.
var halfOverlap = bbox2.Width * 0.5;
perpMin = -(halfOverlap - spacing);
perpMax = bbox1.Width + halfOverlap + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
}
else
{
perpMin = -(bbox2.Width + spacing);
perpMax = bbox1.Width + bbox2.Width + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
// Perpendicular sweep along X → Length; push extent along Y → Width
var halfOverlap = bbox2.Length * 0.5;
perpMin = -(halfOverlap - spacing);
perpMax = bbox1.Length + halfOverlap + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
}
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
+74 -16
View File
@@ -139,28 +139,81 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
var best = bestFits.FirstOrDefault(r => r.Keep);
if (best == null)
return null;
List<Part> bestPlacement = null;
var parts = best.BuildParts(drawing);
foreach (var fit in bestFits)
{
if (!fit.Keep)
continue;
// BuildParts positions at origin — offset to work area.
// Skip pairs that can't possibly fit the work area in either orientation.
if (fit.ShortestSide > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue;
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue;
var landscape = fit.BuildParts(drawing);
var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea);
var pFits = TryOffsetToWorkArea(portrait, workArea);
// Pick the better orientation for this pair.
List<Part> candidate = null;
if (lFits && pFits)
candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
else if (lFits)
candidate = landscape;
else if (pFits)
candidate = portrait;
if (candidate == null)
continue;
if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea))
bestPlacement = candidate;
}
return bestPlacement;
}
private static List<Part> RotatePair90(List<Part> parts)
{
var rotated = new List<Part>(parts.Count);
foreach (var p in parts)
rotated.Add((Part)p.Clone());
var bbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
var center = bbox.Center;
foreach (var p in rotated)
p.Rotate(-Angle.HalfPI, center);
var newBbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
var offset = new Vector(-newBbox.Left, -newBbox.Bottom);
foreach (var p in rotated)
{
p.Offset(offset);
p.UpdateBounds();
}
return rotated;
}
private static bool TryOffsetToWorkArea(List<Part> parts, Box workArea)
{
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
if (bbox.Width > workArea.Width + Tolerance.Epsilon ||
bbox.Length > workArea.Length + Tolerance.Epsilon)
return false;
var offset = workArea.Location - bbox.Location;
foreach (var p in parts)
{
p.Offset(offset);
p.UpdateBounds();
}
// Verify pair fits in work area.
bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
if (bbox.Width > workArea.Width + Tolerance.Epsilon ||
bbox.Length > workArea.Length + Tolerance.Epsilon)
return null;
return parts;
return true;
}
/// <summary>
@@ -203,7 +256,7 @@ namespace OpenNest
if (newWidth >= workArea.Width && newLength >= workArea.Length)
return workArea;
return new Box(workArea.X, workArea.Y, newWidth, newLength);
return new Box(workArea.X, workArea.Y, newLength, newWidth);
}
private List<Part> RunFillPipeline(NestItem item, Box workArea,
@@ -295,6 +348,7 @@ namespace OpenNest
foreach (var strategy in FillStrategyRegistry.Strategies)
{
context.Token.ThrowIfCancellationRequested();
context.ActivePhase = strategy.Phase;
var sw = Stopwatch.StartNew();
var result = strategy.Fill(context);
@@ -308,14 +362,18 @@ namespace OpenNest
// during progress reporting.
PhaseResults.Add(phaseResult);
if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea))
// FillContext.ReportProgress updates CurrentBest during the
// strategy's angle sweep. This catches strategies that return a
// result without reporting it (e.g. RectBestFit).
var improved = context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea);
if (improved)
{
context.CurrentBest = result;
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
context.WinnerPhase = strategy.Phase;
}
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
if (improved && context.CurrentBest != null && context.CurrentBest.Count > 0)
{
ReportProgress(context.Progress, new ProgressReport
{
+6 -4
View File
@@ -2,13 +2,15 @@
namespace OpenNest
{
internal record CombinationResult(bool Found, int Count1, int Count2);
internal static class BestCombination
{
public static bool FindFrom2(double length1, double length2, double overallLength, out int count1, out int count2)
public static CombinationResult FindFrom2(double length1, double length2, double overallLength)
{
overallLength += Tolerance.Epsilon;
count1 = 0;
count2 = 0;
var count1 = 0;
var count2 = 0;
var maxCount1 = (int)System.Math.Floor(overallLength / length1);
var bestRemnant = overallLength + 1;
@@ -30,7 +32,7 @@ namespace OpenNest
break;
}
return count1 > 0 || count2 > 0;
return new CombinationResult(count1 > 0 || count2 > 0, count1, count2);
}
}
}
+26 -12
View File
@@ -11,8 +11,6 @@ namespace OpenNest.Engine.Fill
/// </summary>
public static class Compactor
{
private const double ChordTolerance = 0.001;
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{
var obstacleParts = plate.Parts
@@ -44,7 +42,7 @@ namespace OpenNest.Engine.Fill
var opposite = -direction;
var obstacleBoxes = new Box[obstacleParts.Count];
var obstacleLines = new List<Line>[obstacleParts.Count];
var obstacleEntities = new List<Entity>[obstacleParts.Count];
for (var i = 0; i < obstacleParts.Count; i++)
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
@@ -61,7 +59,19 @@ namespace OpenNest.Engine.Fill
distance = edgeDist;
var movingBox = moving.BoundingBox;
List<Line> movingLines = null;
List<Entity> movingEntities = null;
// Check if any obstacle is inside the moving part — only then
// do we need cutout entities on the moving part.
var needCutouts = false;
for (var i = 0; i < obstacleBoxes.Length; i++)
{
if (movingBox.Contains(obstacleBoxes[i]))
{
needCutouts = true;
break;
}
}
for (var i = 0; i < obstacleBoxes.Length; i++)
{
@@ -76,15 +86,19 @@ namespace OpenNest.Engine.Fill
if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
continue;
movingLines ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
movingEntities ??= halfSpacing > 0
? (needCutouts
? PartGeometry.GetOffsetPartEntities(moving, halfSpacing)
: PartGeometry.GetOffsetPerimeterEntities(moving, halfSpacing))
: (needCutouts
? PartGeometry.GetPartEntities(moving)
: PartGeometry.GetPerimeterEntities(moving));
obstacleLines[i] ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
obstacleEntities[i] ??= halfSpacing > 0
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
if (d < distance)
distance = d;
}
@@ -157,7 +171,7 @@ namespace OpenNest.Engine.Fill
continue;
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
var d = gap - partSpacing - 2 * ChordTolerance;
var d = gap - partSpacing - 0.002;
if (d < 0) d = 0;
if (d < distance)
distance = d;
+8 -31
View File
@@ -24,10 +24,8 @@ namespace OpenNest.Engine.Fill
}
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
int plateNumber = 0,
CancellationToken token = default,
IProgress<NestProgress> progress = null,
List<Engine.BestFit.BestFitResult> bestFits = null)
Action<List<Part>, string> reportProgress = null)
{
var pair = BuildPair(drawing, rotationAngle);
if (pair == null)
@@ -37,14 +35,7 @@ namespace OpenNest.Engine.Fill
if (column.Count == 0)
return new List<Part>();
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = column,
WorkArea = workArea,
Description = $"Extents: initial column {column.Count} parts",
});
reportProgress?.Invoke(column, $"Extents: initial column {column.Count} parts");
var adjusted = AdjustColumn(pair.Value, column, token);
@@ -56,25 +47,11 @@ namespace OpenNest.Engine.Fill
adjusted = column;
}
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = adjusted,
WorkArea = workArea,
Description = $"Extents: column {adjusted.Count} parts",
});
reportProgress?.Invoke(adjusted, $"Extents: column {adjusted.Count} parts");
var result = RepeatColumns(adjusted, token);
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = result,
WorkArea = workArea,
Description = $"Extents: {result.Count} parts total",
});
reportProgress?.Invoke(result, $"Extents: {result.Count} parts total");
return result;
}
@@ -96,7 +73,7 @@ namespace OpenNest.Engine.Fill
var boundary2 = new PartBoundary(part2, halfSpacing);
// Position part2 to the right of part1 at bounding box width distance.
var startOffset = part1.BoundingBox.Width + part2.BoundingBox.Width + partSpacing;
var startOffset = part1.BoundingBox.Length + part2.BoundingBox.Length + partSpacing;
part2.Offset(startOffset, 0);
part2.UpdateBounds();
@@ -135,7 +112,7 @@ namespace OpenNest.Engine.Fill
// Compute vertical copy distance using bounding boxes as starting point,
// then slide down to find true geometry distance.
var pairHeight = pair.Bbox.Length;
var pairHeight = pair.Bbox.Width;
var testOffset = new Vector(0, pairHeight);
// Create test parts for slide distance measurement.
@@ -218,7 +195,7 @@ namespace OpenNest.Engine.Fill
private List<Part> AdjustColumn(PartPair pair, List<Part> column, CancellationToken token)
{
var originalPairWidth = pair.Bbox.Width;
var originalPairWidth = pair.Bbox.Length;
for (var iteration = 0; iteration < MaxIterations; iteration++)
{
@@ -294,7 +271,7 @@ namespace OpenNest.Engine.Fill
// Check if the pair got wider.
var newBbox = PairBbox(p1, p2);
if (newBbox.Width > originalPairWidth + Tolerance.Epsilon)
if (newBbox.Length > originalPairWidth + Tolerance.Epsilon)
return null;
return AnchorToWorkArea(p1, p2);
+7 -6
View File
@@ -11,7 +11,7 @@ namespace OpenNest.Engine.Fill
public FillLinear(Box workArea, double partSpacing)
{
PartSpacing = partSpacing;
WorkArea = new Box(workArea.X, workArea.Y, workArea.Width, workArea.Length);
WorkArea = new Box(workArea.X, workArea.Y, workArea.Length, workArea.Width);
}
public Box WorkArea { get; }
@@ -41,7 +41,7 @@ namespace OpenNest.Engine.Fill
private static double GetDimension(Box box, NestDirection direction)
{
return direction == NestDirection.Horizontal ? box.Width : box.Length;
return direction == NestDirection.Horizontal ? box.Length : box.Width;
}
private static double GetStart(Box box, NestDirection direction)
@@ -119,10 +119,11 @@ namespace OpenNest.Engine.Fill
var maxCopyDistance = FindMaxPairDistance(
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
if (maxCopyDistance < Tolerance.Epsilon)
return bboxDim + PartSpacing;
return maxCopyDistance;
// The copy distance must be at least bboxDim + PartSpacing to prevent
// bounding box overlap. Cross-pair slides can underestimate when the
// circumscribed polygon boundary overshoots the true arc, creating
// spurious contacts between diagonal parts in adjacent copies.
return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing);
}
/// <summary>
+11 -18
View File
@@ -45,9 +45,8 @@ namespace OpenNest.Engine.Fill
}
public PairFillResult Fill(NestItem item, Box workArea,
int plateNumber = 0,
CancellationToken token = default,
IProgress<NestProgress> progress = null)
Action<List<Part>, string> reportProgress = null)
{
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, plateSize.Length, plateSize.Width, partSpacing);
@@ -58,7 +57,7 @@ namespace OpenNest.Engine.Fill
var targetCount = item.Quantity > 0 ? item.Quantity : 0;
var parts = EvaluateCandidates(candidates, item.Drawing, workArea, targetCount,
plateNumber, token, progress);
token, reportProgress);
return new PairFillResult { Parts = parts, BestFits = bestFits };
}
@@ -66,7 +65,7 @@ namespace OpenNest.Engine.Fill
private List<Part> EvaluateCandidates(
List<BestFitResult> candidates, Drawing drawing,
Box workArea, int targetCount,
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
CancellationToken token, Action<List<Part>, string> reportProgress)
{
List<Part> best = null;
var sinceImproved = 0;
@@ -112,14 +111,8 @@ namespace OpenNest.Engine.Fill
sinceImproved++;
}
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Pairs,
PlateNumber = plateNumber,
Parts = best,
WorkArea = workArea,
Description = $"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts",
});
reportProgress?.Invoke(best,
$"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts");
}
if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
@@ -175,8 +168,8 @@ namespace OpenNest.Engine.Fill
var newTop = remaining.Max(p => p.BoundingBox.Top);
return new Box(workArea.X, workArea.Y,
workArea.Width,
System.Math.Min(newTop - workArea.Y, workArea.Length));
workArea.Length,
System.Math.Min(newTop - workArea.Y, workArea.Width));
}
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing,
@@ -271,8 +264,8 @@ namespace OpenNest.Engine.Fill
var topHeight = System.Math.Max(0, workArea.Top - gridBox.Top);
var rightWidth = System.Math.Max(0, workArea.Right - gridBox.Right);
var topArea = workArea.Width * topHeight;
var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Length);
var topArea = workArea.Length * topHeight;
var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Width);
var remnantArea = topArea + rightArea;
return (int)(remnantArea * maxUtilization / partArea) + 1;
@@ -292,7 +285,7 @@ namespace OpenNest.Engine.Fill
var topLength = workArea.Top - topY;
if (topLength >= minDim)
{
var topBox = new Box(workArea.X, topY, workArea.Width, topLength);
var topBox = new Box(workArea.X, topY, workArea.Length, topLength);
var parts = FillRemnantBox(drawing, topBox, token);
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
bestRemnant = parts;
@@ -303,7 +296,7 @@ namespace OpenNest.Engine.Fill
var rightWidth = workArea.Right - rightX;
if (rightWidth >= minDim)
{
var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Length);
var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Width);
var parts = FillRemnantBox(drawing, rightBox, token);
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
bestRemnant = parts;
+1 -1
View File
@@ -24,7 +24,7 @@ namespace OpenNest.Engine.Fill
public PartBoundary(Part part, double spacing)
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.Where(e => e.Layer == SpecialLayers.Cut)
.ToList();
var definedShape = new ShapeProfile(entities);
+7 -7
View File
@@ -13,15 +13,15 @@ namespace OpenNest.Engine.Fill
var cellBox = cell.GetBoundingBox();
var halfSpacing = partSpacing / 2;
var cellWidth = cellBox.Width + partSpacing;
var cellHeight = cellBox.Length + partSpacing;
var cellW = cellBox.Width + partSpacing;
var cellL = cellBox.Length + partSpacing;
if (cellWidth <= 0 || cellHeight <= 0)
if (cellW <= 0 || cellL <= 0)
return new List<Part>();
// Size.Width = X-axis, Size.Length = Y-axis
var cols = (int)System.Math.Floor(plateSize.Width / cellWidth);
var rows = (int)System.Math.Floor(plateSize.Length / cellHeight);
// Width = Y axis, Length = X axis
var cols = (int)System.Math.Floor(plateSize.Length / cellL);
var rows = (int)System.Math.Floor(plateSize.Width / cellW);
if (cols <= 0 || rows <= 0)
return new List<Part>();
@@ -37,7 +37,7 @@ namespace OpenNest.Engine.Fill
{
for (var col = 0; col < cols; col++)
{
var tileOffset = baseOffset + new Vector(col * cellWidth, row * cellHeight);
var tileOffset = baseOffset + new Vector(col * cellL, row * cellW);
foreach (var part in cell)
{
+1 -1
View File
@@ -106,7 +106,7 @@ namespace OpenNest.Engine.Fill
// rectangular obstacle boundary. Without this, gaps between
// individual bounding boxes cause the next drawing to fill
// into inter-row spaces, producing an interleaved layout.
if (placed.Count > 1)
if (placed.Count > 2)
RemoveTopmostPart(placed);
allParts.AddRange(placed);
+2 -2
View File
@@ -304,10 +304,10 @@ namespace OpenNest.Engine.Fill
// Edge extensions (priority 1).
if (remnant.Right > envelope.Right + eps)
TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, remnant.Length, 1, minDim);
TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, remnant.Width, 1, minDim);
if (remnant.Left < envelope.Left - eps)
TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, remnant.Length, 1, minDim);
TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, remnant.Width, 1, minDim);
if (remnant.Top > envelope.Top + eps)
TryAdd(results, innerLeft, envelope.Top, innerRight - innerLeft, remnant.Top - envelope.Top, 1, minDim);
+11 -17
View File
@@ -95,14 +95,8 @@ public class StripeFiller
}
}
NestEngineBase.ReportProgress(_context.Progress, new ProgressReport
{
Phase = NestPhase.Custom,
PlateNumber = _context.PlateNumber,
Parts = bestParts,
WorkArea = workArea,
Description = $"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts",
});
_context.ReportProgress(bestParts,
$"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts");
}
return bestParts ?? new List<Part>();
@@ -201,8 +195,8 @@ public class StripeFiller
private static Box MakeStripeBox(Box workArea, double perpDim, NestDirection primaryAxis)
{
return primaryAxis == NestDirection.Horizontal
? new Box(workArea.X, workArea.Y, workArea.Width, perpDim)
: new Box(workArea.X, workArea.Y, perpDim, workArea.Length);
? new Box(workArea.X, workArea.Y, workArea.Length, perpDim)
: new Box(workArea.X, workArea.Y, perpDim, workArea.Width);
}
private List<Part> FillRemnant(List<Part> gridParts, NestDirection primaryAxis)
@@ -224,7 +218,7 @@ public class StripeFiller
var remnantLength = workArea.Top - remnantY;
if (remnantLength < minDim)
return null;
remnantBox = new Box(workArea.X, remnantY, workArea.Width, remnantLength);
remnantBox = new Box(workArea.X, remnantY, workArea.Length, remnantLength);
}
else
{
@@ -232,7 +226,7 @@ public class StripeFiller
var remnantWidth = workArea.Right - remnantX;
if (remnantWidth < minDim)
return null;
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Length);
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Width);
}
Debug.WriteLine($"[StripeFiller] Remnant box: {remnantBox.Width:F2}x{remnantBox.Length:F2}");
@@ -324,7 +318,7 @@ public class StripeFiller
{
var box = FillHelpers.BuildRotatedPattern(patternParts, 0).BoundingBox;
var span0 = GetDimension(box, axis);
var perpSpan0 = axis == NestDirection.Horizontal ? box.Length : box.Width;
var perpSpan0 = axis == NestDirection.Horizontal ? box.Width : box.Length;
if (span0 <= perpSpan0)
return 0;
@@ -388,7 +382,7 @@ public class StripeFiller
var rotated = FillHelpers.BuildRotatedPattern(patternParts, currentAngle);
var pairSpan = GetDimension(rotated.BoundingBox, axis);
var perpDim = axis == NestDirection.Horizontal
? rotated.BoundingBox.Length : rotated.BoundingBox.Width;
? rotated.BoundingBox.Width : rotated.BoundingBox.Length;
if (pairSpan + spacing <= 0)
break;
@@ -472,13 +466,13 @@ public class StripeFiller
{
var rotated = FillHelpers.BuildRotatedPattern(patternParts, angle);
return axis == NestDirection.Horizontal
? rotated.BoundingBox.Width
: rotated.BoundingBox.Length;
? rotated.BoundingBox.Length
: rotated.BoundingBox.Width;
}
private static double GetDimension(Box box, NestDirection axis)
{
return axis == NestDirection.Horizontal ? box.Width : box.Length;
return axis == NestDirection.Horizontal ? box.Length : box.Width;
}
private static bool HasOverlappingParts(List<Part> parts) =>
+1 -1
View File
@@ -38,7 +38,7 @@ namespace OpenNest
var bb = item.Drawing.Program.BoundingBox();
var cos = System.Math.Abs(System.Math.Cos(angle));
var sin = System.Math.Abs(System.Math.Sin(angle));
return bb.Length * cos + bb.Width * sin;
return bb.Width * cos + bb.Length * sin;
}
}
}
+3 -3
View File
@@ -47,7 +47,7 @@ namespace OpenNest.Engine.ML
{
Area = drawing.Area,
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
AspectRatio = bb.Width / (bb.Length > 0 ? bb.Length : 1.0),
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
VertexCount = polygon.Vertices.Count,
Bitmask = GenerateBitmask(polygon, 32)
@@ -72,8 +72,8 @@ namespace OpenNest.Engine.ML
for (int x = 0; x < size; x++)
{
// Map grid coordinate (0..size) to bounding box coordinate
var px = bb.Left + (x + 0.5) * (bb.Width / size);
var py = bb.Bottom + (y + 0.5) * (bb.Length / size);
var px = bb.Left + (x + 0.5) * (bb.Length / size);
var py = bb.Bottom + (y + 0.5) * (bb.Width / size);
if (polygon.ContainsPoint(new Vector(px, py)))
{
+661
View File
@@ -0,0 +1,661 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
public enum PartClass
{
Large,
Medium,
Small,
}
public class MultiPlateNester
{
private readonly Plate _template;
private readonly List<PlateOption> _plateOptions;
private readonly List<PlateOption> _sortedOptions;
private readonly double _salvageRate;
private readonly double _minRemnantSize;
private readonly List<PlateResult> _platePool;
private readonly IProgress<NestProgress> _progress;
private readonly CancellationToken _token;
private readonly MultiPlateNestOptions _options;
private bool HasPlateOptions => _plateOptions != null && _plateOptions.Count > 0;
private MultiPlateNester(
MultiPlateNestOptions options,
List<Plate> existingPlates,
IProgress<NestProgress> progress, CancellationToken token)
{
_options = options;
_template = options.Template;
_plateOptions = options.PlateOptions;
_sortedOptions = options.PlateOptions?.OrderBy(o => o.Cost).ToList();
_salvageRate = options.SalvageRate;
_minRemnantSize = options.MinRemnantSize;
_platePool = InitializePlatePool(existingPlates);
_progress = progress;
_token = token;
}
// --- Static Utility Methods ---
public static bool FitsBounds(Box container, Box part)
{
var fitsNormal = container.Width >= part.Width - Tolerance.Epsilon
&& container.Length >= part.Length - Tolerance.Epsilon;
var fitsRotated = container.Width >= part.Length - Tolerance.Epsilon
&& container.Length >= part.Width - Tolerance.Epsilon;
return fitsNormal || fitsRotated;
}
public static List<NestItem> SortItems(List<NestItem> items, PartSortOrder sortOrder)
{
var withBounds = items.Select(i => (Item: i, Bounds: i.Drawing.Program.BoundingBox())).ToList();
switch (sortOrder)
{
case PartSortOrder.BoundingBoxArea:
return withBounds
.OrderByDescending(x => x.Bounds.Width * x.Bounds.Length)
.Select(x => x.Item)
.ToList();
case PartSortOrder.Size:
return withBounds
.OrderByDescending(x => System.Math.Max(x.Bounds.Width, x.Bounds.Length))
.Select(x => x.Item)
.ToList();
default:
return items.ToList();
}
}
public static PartClass Classify(Box partBounds, Box workArea)
{
var halfWidth = workArea.Width / 2.0;
var halfLength = workArea.Length / 2.0;
if (partBounds.Width > halfWidth || partBounds.Length > halfLength)
return PartClass.Large;
var workAreaArea = workArea.Width * workArea.Length;
var partArea = partBounds.Width * partBounds.Length;
if (partArea > workAreaArea / 9.0)
return PartClass.Medium;
return PartClass.Small;
}
public static bool IsScrapRemnant(Box remnant, double minRemnantSize)
{
return remnant.Width < minRemnantSize && remnant.Length < minRemnantSize;
}
public static List<Box> FindRemnants(Plate plate, double minRemnantSize, bool scrapOnly)
{
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
return remnants.Where(r => IsScrapRemnant(r, minRemnantSize) == scrapOnly).ToList();
}
public struct UpgradeDecision
{
public bool ShouldUpgrade;
public double UpgradeCost;
public double NewPlateCost;
}
public static Plate CreatePlate(Plate template, List<PlateOption> options, Box minBounds)
{
var plate = new Plate(template.Size)
{
PartSpacing = template.PartSpacing,
Quadrant = template.Quadrant,
};
plate.EdgeSpacing = new Spacing
{
Left = template.EdgeSpacing.Left,
Right = template.EdgeSpacing.Right,
Top = template.EdgeSpacing.Top,
Bottom = template.EdgeSpacing.Bottom,
};
if (options == null || options.Count == 0 || minBounds == null)
return plate;
var sorted = options.OrderBy(o => o.Cost).ToList();
foreach (var option in sorted)
{
if (FitsBounds(OptionWorkArea(option, template), minBounds))
{
plate.Size = new Size(option.Width, option.Length);
return plate;
}
}
return plate;
}
public static UpgradeDecision EvaluateUpgradeVsNew(
PlateOption currentSize,
PlateOption upgradeSize,
PlateOption newPlateSize,
double salvageRate,
double estimatedNewPlateUtilization)
{
var upgradeCost = upgradeSize.Cost - currentSize.Cost;
var newPlateCost = newPlateSize.Cost;
var remnantFraction = 1.0 - estimatedNewPlateUtilization;
var salvageCredit = remnantFraction * newPlateSize.Cost * salvageRate;
var netNewCost = newPlateCost - salvageCredit;
return new UpgradeDecision
{
ShouldUpgrade = upgradeCost <= netNewCost,
UpgradeCost = upgradeCost,
NewPlateCost = netNewCost,
};
}
// --- Main Entry Point ---
public static MultiPlateResult Nest(
List<NestItem> items,
MultiPlateNestOptions options,
List<Plate> existingPlates = null,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
var nester = new MultiPlateNester(options, existingPlates, progress, token);
return nester.Run(items, options.SortOrder, options.AllowPlateCreation);
}
// --- Private Helpers ---
private static Box OptionWorkArea(PlateOption option, Plate template)
{
var w = option.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right;
var h = option.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom;
return new Box(0, 0, w, h);
}
private static double ScoreZone(Box zone, Box partBounds)
{
if (!FitsBounds(zone, partBounds))
return -1;
var cols = (int)(zone.Width / partBounds.Width);
var rows = (int)(zone.Length / partBounds.Length);
var colsR = (int)(zone.Width / partBounds.Length);
var rowsR = (int)(zone.Length / partBounds.Width);
var estimatedCount = System.Math.Max(cols * rows, colsR * rowsR);
var utilization = (estimatedCount * partBounds.Width * partBounds.Length) / zone.Area();
var zoneAspect = zone.Width / zone.Length;
var partAspect = partBounds.Width / partBounds.Length;
var aspectMatch = System.Math.Min(zoneAspect, partAspect) / System.Math.Max(zoneAspect, partAspect);
return utilization * 0.7 + aspectMatch * 0.3;
}
private static void DecrementQuantity(NestItem item, int placed)
{
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
private int FillAndPlace(PlateResult pr, Box zone, NestItem item)
{
var engine = NestEngineRegistry.Create(pr.Plate);
var clonedItem = CloneItem(item);
var parts = engine.Fill(clonedItem, zone, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
DecrementQuantity(item, parts.Count);
}
return parts.Count;
}
private PlateResult CreateNewPlateResult(Plate plate)
{
var pr = new PlateResult { Plate = plate, IsNew = true };
if (HasPlateOptions)
{
pr.ChosenSize = _plateOptions.FirstOrDefault(o =>
o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length));
}
return pr;
}
private static NestItem CloneItem(NestItem item)
{
return new NestItem
{
Drawing = item.Drawing,
Priority = item.Priority,
Quantity = item.Quantity,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
};
}
private static List<PlateResult> InitializePlatePool(List<Plate> existingPlates)
{
var pool = new List<PlateResult>();
if (existingPlates != null)
{
foreach (var plate in existingPlates)
pool.Add(new PlateResult { Plate = plate, IsNew = false });
}
return pool;
}
private bool TryWithUpgradedSize(PlateResult pr, PlateOption upgradeOption, Func<List<Box>, bool> tryFill)
{
var oldSize = pr.Plate.Size;
var oldChosenSize = pr.ChosenSize;
pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
pr.ChosenSize = upgradeOption;
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
if (remnants.Count > 0 && tryFill(remnants))
return true;
pr.Plate.Size = oldSize;
pr.ChosenSize = oldChosenSize;
return false;
}
private PlateOption FindSmallestFittingOption(Box partBounds)
{
return _sortedOptions?.FirstOrDefault(o => FitsBounds(OptionWorkArea(o, _template), partBounds));
}
// --- Orchestration ---
private MultiPlateResult Run(List<NestItem> items, PartSortOrder sortOrder, bool allowPlateCreation)
{
var result = new MultiPlateResult();
if (items == null || items.Count == 0)
return result;
var sorted = SortItems(items.Where(i => i.Quantity > 0).ToList(), sortOrder);
foreach (var item in sorted)
{
if (_token.IsCancellationRequested || item.Quantity <= 0)
continue;
var bb = item.Drawing.Program.BoundingBox();
TryPlaceOnExistingPlates(item, bb);
var templateClass = Classify(bb, _template.WorkArea());
if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small)
{
PlaceOnNewPlates(item, bb);
if (item.Quantity > 0 && HasPlateOptions)
TryUpgradeOrNewPlate(item, bb);
}
}
var leftovers = sorted.Where(i => i.Quantity > 0).ToList();
if (leftovers.Count > 0 && allowPlateCreation && !_token.IsCancellationRequested)
{
PackIntoExistingRemnants(leftovers);
CreateSharedPlates(leftovers);
}
if (HasPlateOptions && !_token.IsCancellationRequested)
TryConsolidateTailPlates();
foreach (var item in sorted.Where(i => i.Quantity > 0))
result.UnplacedItems.Add(item);
result.Plates.AddRange(_platePool.Where(p => p.Parts.Count > 0 || p.IsNew));
return result;
}
private void PackIntoExistingRemnants(List<NestItem> leftovers)
{
foreach (var pr in _platePool)
{
if (_token.IsCancellationRequested)
break;
var anyPlaced = true;
while (anyPlaced && !_token.IsCancellationRequested)
{
anyPlaced = false;
var remaining = leftovers.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
if (remnants.Count == 0)
break;
var engine = NestEngineRegistry.Create(pr.Plate);
foreach (var remnant in remnants)
{
remaining = leftovers.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var cloned = remaining.Select(CloneItem).ToList();
var parts = engine.PackArea(remnant, cloned, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
anyPlaced = true;
foreach (var item in remaining)
{
var placed = parts.Count(p => p.BaseDrawing == item.Drawing);
DecrementQuantity(item, placed);
}
}
}
}
}
}
private void CreateSharedPlates(List<NestItem> leftovers)
{
leftovers.RemoveAll(i => i.Quantity <= 0);
while (leftovers.Count > 0 && !_token.IsCancellationRequested)
{
var plate = CreatePlate(_template, _plateOptions, null);
var pr = CreateNewPlateResult(plate);
var placedAny = false;
foreach (var item in leftovers)
{
if (item.Quantity <= 0 || _token.IsCancellationRequested)
continue;
var remnants = !placedAny
? new List<Box> { plate.WorkArea() }
: RemnantFinder.FromPlate(plate).FindRemnants();
if (remnants.Count == 0)
break;
var engine = NestEngineRegistry.Create(plate);
foreach (var remnant in remnants)
{
if (item.Quantity <= 0)
break;
var clonedItem = CloneItem(item);
var parts = engine.Fill(clonedItem, remnant, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
DecrementQuantity(item, parts.Count);
placedAny = true;
}
}
}
if (!placedAny)
break;
_platePool.Add(pr);
leftovers.RemoveAll(i => i.Quantity <= 0);
}
}
private bool TryPlaceOnExistingPlates(NestItem item, Box partBounds)
{
var anyPlaced = false;
var remnantCache = new Dictionary<PlateResult, List<Box>>();
PlateResult lastModified = null;
while (item.Quantity > 0 && !_token.IsCancellationRequested)
{
PlateResult bestPlate = null;
Box bestZone = null;
var bestScore = double.MinValue;
foreach (var pr in _platePool)
{
if (_token.IsCancellationRequested)
break;
if (pr == lastModified || !remnantCache.ContainsKey(pr))
{
var workArea = pr.Plate.WorkArea();
var classification = Classify(partBounds, workArea);
remnantCache[pr] = classification == PartClass.Small
? FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: true)
: FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: false);
}
foreach (var zone in remnantCache[pr])
{
var score = ScoreZone(zone, partBounds);
if (score > bestScore)
{
bestPlate = pr;
bestZone = zone;
bestScore = score;
}
}
}
if (bestPlate == null || bestZone == null)
break;
if (FillAndPlace(bestPlate, bestZone, item) == 0)
break;
lastModified = bestPlate;
anyPlaced = true;
}
return anyPlaced;
}
private bool PlaceOnNewPlates(NestItem item, Box partBounds)
{
var anyPlaced = false;
while (item.Quantity > 0 && !_token.IsCancellationRequested)
{
var plate = CreatePlate(_template, _plateOptions, partBounds);
var workArea = plate.WorkArea();
if (!FitsBounds(workArea, partBounds))
break;
var pr = CreateNewPlateResult(plate);
if (FillAndPlace(pr, workArea, item) == 0)
break;
_platePool.Add(pr);
anyPlaced = true;
}
return anyPlaced;
}
private bool TryUpgradeOrNewPlate(NestItem item, Box partBounds)
{
if (!HasPlateOptions)
return false;
foreach (var pr in _platePool.Where(p => p.IsNew && p.ChosenSize != null))
{
var currentOption = pr.ChosenSize;
var currentIdx = _sortedOptions.FindIndex(o =>
o.Width.IsEqualTo(currentOption.Width) && o.Length.IsEqualTo(currentOption.Length));
if (currentIdx < 0 || currentIdx >= _sortedOptions.Count - 1)
continue;
for (var i = currentIdx + 1; i < _sortedOptions.Count; i++)
{
var upgradeOption = _sortedOptions[i];
if (upgradeOption.Width < currentOption.Width - Tolerance.Epsilon
|| upgradeOption.Length < currentOption.Length - Tolerance.Epsilon)
continue;
var smallestNew = FindSmallestFittingOption(partBounds);
if (smallestNew == null)
continue;
var utilEst = pr.Plate.Utilization();
var decision = EvaluateUpgradeVsNew(currentOption, upgradeOption, smallestNew,
_salvageRate, utilEst);
if (decision.ShouldUpgrade)
{
var placed = TryWithUpgradedSize(pr, upgradeOption, remnants =>
{
foreach (var remnant in remnants)
{
if (FillAndPlace(pr, remnant, item) > 0)
return true;
}
return false;
});
if (placed)
return true;
}
}
}
return false;
}
private void TryConsolidateTailPlates()
{
var consolidated = true;
while (consolidated)
{
consolidated = false;
var activePlates = _platePool.Where(p => p.Parts.Count > 0 && p.IsNew).ToList();
if (activePlates.Count < 2)
return;
var donors = activePlates.OrderBy(p => p.Plate.Utilization()).ToList();
foreach (var donor in donors)
{
if (donor.Parts.Count == 0)
continue;
var donorParts = donor.Parts.ToList();
var absorbed = false;
foreach (var target in activePlates)
{
if (target == donor || target.ChosenSize == null || target.Parts.Count == 0)
continue;
var currentOption = target.ChosenSize;
foreach (var upgradeOption in _sortedOptions.Where(o =>
o.Width >= currentOption.Width - Tolerance.Epsilon
&& o.Length >= currentOption.Length - Tolerance.Epsilon
&& (o.Width > currentOption.Width + Tolerance.Epsilon
|| o.Length > currentOption.Length + Tolerance.Epsilon)))
{
absorbed = TryWithUpgradedSize(target, upgradeOption, remnants =>
{
var engine = NestEngineRegistry.Create(target.Plate);
var tempItems = donorParts
.GroupBy(p => p.BaseDrawing)
.Select(g => new NestItem
{
Drawing = g.Key,
Quantity = g.Count(),
})
.ToList();
var totalPlaced = new List<Part>();
foreach (var remnant in remnants)
{
var placed = engine.PackArea(remnant, tempItems, _progress, _token);
totalPlaced.AddRange(placed);
foreach (var ti in tempItems)
{
var count = placed.Count(p => p.BaseDrawing == ti.Drawing);
ti.Quantity = System.Math.Max(0, ti.Quantity - count);
}
if (tempItems.All(ti => ti.Quantity <= 0))
break;
}
if (totalPlaced.Count >= donorParts.Count)
{
target.AddParts(totalPlaced);
foreach (var p in donorParts)
donor.Plate.Parts.Remove(p);
donor.Parts.Clear();
_platePool.Remove(donor);
return true;
}
return false;
});
if (absorbed)
break;
}
if (absorbed)
break;
}
if (absorbed)
{
consolidated = true;
break;
}
}
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace OpenNest
{
public class MultiPlateNestOptions
{
public Plate Template { get; set; }
public List<PlateOption> PlateOptions { get; set; }
public double SalvageRate { get; set; } = 0.5;
public PartSortOrder SortOrder { get; set; } = PartSortOrder.BoundingBoxArea;
public double MinRemnantSize { get; set; } = 12.0;
public bool AllowPlateCreation { get; set; } = true;
}
public class MultiPlateResult
{
public List<PlateResult> Plates { get; set; } = new();
public List<NestItem> UnplacedItems { get; set; } = new();
}
public class PlateResult
{
public Plate Plate { get; set; }
public List<Part> Parts { get; set; } = new();
public PlateOption ChosenSize { get; set; }
public bool IsNew { get; set; }
public void AddParts(IList<Part> parts)
{
Plate.Parts.AddRange(parts);
Parts.AddRange(parts);
}
}
}
+37 -26
View File
@@ -333,45 +333,56 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
var bestFit = bestFits.FirstOrDefault(r => r.Keep);
if (bestFit == null) continue;
var parts = bestFit.BuildParts(item.Drawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width;
var pairL = pairBbox.Length;
var minDim = System.Math.Min(pairW, pairL);
List<Part> bestPlacement = null;
Box bestTarget = null;
var remnants = finder.FindRemnants(minDim);
Box target = null;
foreach (var r in remnants)
foreach (var fit in bestFits)
{
if (pairW <= r.Width + Tolerance.Epsilon &&
pairL <= r.Length + Tolerance.Epsilon)
if (!fit.Keep)
continue;
var parts = fit.BuildParts(item.Drawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width;
var pairL = pairBbox.Length;
var minDim = System.Math.Min(pairW, pairL);
var remnants = finder.FindRemnants(minDim);
foreach (var r in remnants)
{
target = r;
break;
if (pairW <= r.Width + Tolerance.Epsilon &&
pairL <= r.Length + Tolerance.Epsilon)
{
var offset = r.Location - pairBbox.Location;
foreach (var p in parts)
{
p.Offset(offset);
p.UpdateBounds();
}
if (bestPlacement == null || IsBetterFill(parts, bestPlacement, r))
{
bestPlacement = parts;
bestTarget = r;
}
break;
}
}
}
if (target == null) continue;
if (bestPlacement == null) continue;
var offset = target.Location - pairBbox.Location;
foreach (var p in parts)
{
p.Offset(offset);
p.UpdateBounds();
}
result.AddRange(parts);
result.AddRange(bestPlacement);
item.Quantity = 0;
var envelope = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var envelope = ((IEnumerable<IBoundable>)bestPlacement).GetBoundingBox();
finder.AddObstacle(envelope.Offset(Plate.PartSpacing));
Debug.WriteLine($"[Nest] Placed best-fit pair for {item.Drawing.Name} " +
$"at ({target.X:F1},{target.Y:F1}), size {pairW:F1}x{pairL:F1}");
$"at ({bestTarget.X:F1},{bestTarget.Y:F1}), " +
$"size {envelope.Width:F1}x{envelope.Length:F1}");
}
return result;
+8
View File
@@ -0,0 +1,8 @@
namespace OpenNest
{
public enum PartSortOrder
{
BoundingBoxArea,
Size,
}
}
+189
View File
@@ -0,0 +1,189 @@
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
namespace OpenNest
{
public static class PlateOptimizer
{
public static PlateOptimizerResult Optimize(
List<NestItem> items,
List<PlateOption> plateOptions,
double salvageRate,
Plate templatePlate,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
if (items == null || items.Count == 0 || plateOptions == null || plateOptions.Count == 0)
return null;
// Find the minimum dimension needed to fit the largest part,
// skipping items that are too large for every plate option.
var minPartWidth = 0.0;
var minPartLength = 0.0;
foreach (var item in items)
{
if (item.Quantity <= 0) continue;
var bb = item.Drawing.Program.BoundingBox();
var shortSide = System.Math.Min(bb.Width, bb.Length);
var longSide = System.Math.Max(bb.Width, bb.Length);
if (!plateOptions.Any(o => FitsPart(o, shortSide, longSide, templatePlate.EdgeSpacing)))
{
Debug.WriteLine($"[PlateOptimizer] Skipping oversized item '{item.Drawing.Name}' " +
$"({shortSide:F1}x{longSide:F1}) — does not fit any plate option");
continue;
}
if (shortSide > minPartWidth) minPartWidth = shortSide;
if (longSide > minPartLength) minPartLength = longSide;
}
// Sort candidates by cost ascending — try cheapest first.
var candidates = plateOptions
.Where(o => FitsPart(o, minPartWidth, minPartLength, templatePlate.EdgeSpacing))
.OrderBy(o => o.Cost)
.ToList();
if (candidates.Count == 0)
return null;
// Pre-compute best fits for all candidate plate sizes at once.
// This runs the expensive GPU evaluation once on the largest plate
// and filters the results for each smaller size.
var plateSizes = candidates
.Select(o => (Width: o.Length, Height: o.Width))
.ToList();
foreach (var item in items)
{
if (item.Quantity <= 0) continue;
BestFitCache.ComputeForSizes(item.Drawing, templatePlate.PartSpacing, plateSizes);
}
PlateOptimizerResult best = null;
foreach (var option in candidates)
{
if (token.IsCancellationRequested)
break;
var result = TryPlateSize(option, items, salvageRate, templatePlate, progress, token);
if (result == null)
continue;
if (IsBetter(result, best))
best = result;
// Early exit: when all items fit, larger plates can only have
// worse utilization and higher cost. With salvage < 100%, the
// remnant credit never offsets the extra plate cost, so skip.
if (salvageRate < 1.0)
{
var allPlaced = items.All(i => i.Quantity <= 0 ||
result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) >= i.Quantity);
if (allPlaced)
{
Debug.WriteLine($"[PlateOptimizer] Early exit: {option.Width}x{option.Length} placed all items");
break;
}
}
}
return best;
}
private static bool FitsPart(PlateOption option, double minWidth, double minLength, Spacing edgeSpacing)
{
var workW = option.Width - edgeSpacing.Left - edgeSpacing.Right;
var workL = option.Length - edgeSpacing.Top - edgeSpacing.Bottom;
// Part fits in either orientation.
var fitsNormal = workW >= minWidth - Tolerance.Epsilon && workL >= minLength - Tolerance.Epsilon;
var fitsRotated = workW >= minLength - Tolerance.Epsilon && workL >= minWidth - Tolerance.Epsilon;
return fitsNormal || fitsRotated;
}
private static PlateOptimizerResult TryPlateSize(
PlateOption option,
List<NestItem> items,
double salvageRate,
Plate templatePlate,
IProgress<NestProgress> progress,
CancellationToken token)
{
// Create a temporary plate with candidate size + settings from template.
var tempPlate = new Plate(option.Width, option.Length)
{
PartSpacing = templatePlate.PartSpacing,
EdgeSpacing = new Spacing
{
Left = templatePlate.EdgeSpacing.Left,
Right = templatePlate.EdgeSpacing.Right,
Top = templatePlate.EdgeSpacing.Top,
Bottom = templatePlate.EdgeSpacing.Bottom,
},
};
// Clone items so the dry run doesn't mutate originals.
var clonedItems = items.Select(i => new NestItem
{
Drawing = i.Drawing, // share Drawing reference for BestFitCache compatibility
Priority = i.Priority,
Quantity = i.Quantity,
StepAngle = i.StepAngle,
RotationStart = i.RotationStart,
RotationEnd = i.RotationEnd,
}).ToList();
var engine = NestEngineRegistry.Create(tempPlate);
var parts = engine.Nest(clonedItems, progress, token);
if (parts == null || parts.Count == 0)
return null;
var workArea = tempPlate.WorkArea();
var plateArea = workArea.Width * workArea.Length;
var partsArea = 0.0;
foreach (var part in parts)
partsArea += part.BoundingBox.Area();
var remnantArea = plateArea - partsArea;
var costPerSqUnit = option.Cost / option.Area;
var netCost = option.Cost - (remnantArea * costPerSqUnit * salvageRate);
Debug.WriteLine($"[PlateOptimizer] {option.Width}x{option.Length} ${option.Cost}: " +
$"{parts.Count} parts, util={partsArea / plateArea:P1}, net=${netCost:F2}");
return new PlateOptimizerResult
{
Parts = parts,
ChosenSize = option,
NetCost = netCost,
Utilization = plateArea > 0 ? partsArea / plateArea : 0,
};
}
private static bool IsBetter(PlateOptimizerResult candidate, PlateOptimizerResult current)
{
if (current == null) return true;
// 1. More parts placed is always better.
if (candidate.Parts.Count != current.Parts.Count)
return candidate.Parts.Count > current.Parts.Count;
// 2. Lower net cost.
if (!candidate.NetCost.IsEqualTo(current.NetCost))
return candidate.NetCost < current.NetCost;
// 3. Higher utilization (tighter density) as tiebreak.
return candidate.Utilization > current.Utilization;
}
}
}
@@ -4,7 +4,7 @@ using System.Collections.Generic;
namespace OpenNest.Engine
{
public class PlateResult
public class PlateProcessingResult
{
public List<ProcessedPart> Parts { get; init; }
}
+2 -2
View File
@@ -14,7 +14,7 @@ namespace OpenNest.Engine
public ContourCuttingStrategy CuttingStrategy { get; set; }
public IRapidPlanner RapidPlanner { get; set; }
public PlateResult Process(Plate plate)
public PlateProcessingResult Process(Plate plate)
{
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
var results = new List<ProcessedPart>(sequenced.Count);
@@ -66,7 +66,7 @@ namespace OpenNest.Engine
currentPoint = ToPlateSpace(lastCutLocal, part);
}
return new PlateResult { Parts = results };
return new PlateProcessingResult { Parts = results };
}
private static Vector ToPartLocal(Vector platePoint, Part part)
@@ -29,11 +29,15 @@ namespace OpenNest.RectanglePacking
Bin.Items.AddRange(bin1.Items);
else
Bin.Items.AddRange(bin2.Items);
}
}
public override void Fill(Item item, int maxCount)
{
throw new NotImplementedException();
Fill(item);
if (Bin.Items.Count > maxCount)
Bin.Items.RemoveRange(maxCount, Bin.Items.Count - maxCount);
}
private Bin BestFitHorizontal(Item item) => BestFitAxis(item, horizontal: true);
@@ -44,14 +48,18 @@ namespace OpenNest.RectanglePacking
{
var bin = Bin.Clone() as Bin;
var primarySize = horizontal ? item.Width : item.Length;
var secondarySize = horizontal ? item.Length : item.Width;
var binPrimary = horizontal ? bin.Width : Bin.Length;
var binSecondary = horizontal ? bin.Length : Bin.Width;
var primarySize = horizontal ? item.Length : item.Width;
var secondarySize = horizontal ? item.Width : item.Length;
var binPrimary = horizontal ? bin.Length : Bin.Width;
var binSecondary = horizontal ? bin.Width : Bin.Length;
if (!BestCombination.FindFrom2(primarySize, secondarySize, binPrimary, out var normalPrimary, out var rotatePrimary))
var combo = BestCombination.FindFrom2(primarySize, secondarySize, binPrimary);
if (!combo.Found)
return bin;
var normalPrimary = combo.Count1;
var rotatePrimary = combo.Count2;
var normalSecondary = (int)System.Math.Floor((binSecondary + Tolerance.Epsilon) / secondarySize);
var rotateSecondary = (int)System.Math.Floor((binSecondary + Tolerance.Epsilon) / primarySize);
@@ -67,9 +75,9 @@ namespace OpenNest.RectanglePacking
bin.Items.AddRange(FillGrid(item, normalRows, normalCols, int.MaxValue));
if (horizontal)
item.Location.X += item.Width * normalPrimary;
item.Location.X += item.Length * normalPrimary;
else
item.Location.Y += item.Length * normalPrimary;
item.Location.Y += item.Width * normalPrimary;
item.Rotate();
@@ -27,8 +27,8 @@ namespace OpenNest.RectanglePacking
{
for (var j = 0; j < innerCount; j++)
{
var x = (columnMajor ? i : j) * item.Width + item.X;
var y = (columnMajor ? j : i) * item.Length + item.Y;
var x = (columnMajor ? i : j) * item.Length + item.X;
var y = (columnMajor ? j : i) * item.Width + item.Y;
var clone = item.Clone() as Item;
clone.Location = new Vector(x, y);
@@ -14,16 +14,16 @@ namespace OpenNest.RectanglePacking
public override void Fill(Item item)
{
var ycount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length);
var xcount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width);
var ycount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width);
var xcount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length);
for (int i = 0; i < xcount; i++)
{
var x = item.Width * i + Bin.X;
var x = item.Length * i + Bin.X;
for (int j = 0; j < ycount; j++)
{
var y = item.Length * j + Bin.Y;
var y = item.Width * j + Bin.Y;
var addedItem = item.Clone() as Item;
addedItem.Location = new Vector(x, y);
@@ -35,8 +35,8 @@ namespace OpenNest.RectanglePacking
public override void Fill(Item item, int maxCount)
{
var ycount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length);
var xcount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width);
var ycount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width);
var xcount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length);
var count = ycount * xcount;
if (count <= maxCount)
@@ -0,0 +1,83 @@
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.RectanglePacking
{
internal class FillSpiral : FillEngine
{
public Box CenterRemnant { get; private set; }
public FillSpiral(Bin bin)
: base(bin)
{
}
public override void Fill(Item item)
{
Fill(item, int.MaxValue);
}
public override void Fill(Item item, int maxCount)
{
if (item == null) return;
// Width = Y axis, Length = X axis
var comboY = BestCombination.FindFrom2(item.Width, item.Length, Bin.Width);
var comboX = BestCombination.FindFrom2(item.Length, item.Width, Bin.Length);
if (!comboY.Found || !comboX.Found)
return;
var q14size = new Size(
item.Width * comboY.Count1,
item.Length * comboX.Count1);
var q23size = new Size(
item.Length * comboY.Count2,
item.Width * comboX.Count2);
if ((q14size.Width > q23size.Width && q14size.Length > q23size.Length) ||
(q23size.Width > q14size.Width && q23size.Length > q14size.Length))
return; // cant do an efficient spiral fill
// Q1: normal orientation at bin origin
item.Location = Bin.Location;
var q1 = FillGrid(item, comboY.Count1, comboX.Count1, maxCount);
Bin.Items.AddRange(q1);
// Q2: rotated, above Q1
item.Rotate();
item.Location = new Vector(Bin.X, Bin.Y + q14size.Width);
var q2 = FillGrid(item, comboY.Count2, comboX.Count2, maxCount - Bin.Items.Count);
Bin.Items.AddRange(q2);
// Q3: rotated, right of Q1
item.Location = new Vector(Bin.X + q14size.Length, Bin.Y);
var q3 = FillGrid(item, comboY.Count2, comboX.Count2, maxCount - Bin.Items.Count);
Bin.Items.AddRange(q3);
// Q4: normal orientation, diagonal from Q1
item.Rotate();
item.Location = new Vector(
Bin.X + q23size.Length,
Bin.Y + q23size.Width);
var q4 = FillGrid(item, comboY.Count1, comboX.Count1, maxCount);
Bin.Items.AddRange(q4);
// Compute center remnant — the rectangular gap between the 4 quadrants
// Only valid when all 4 quadrants have items; otherwise the "center"
// overlaps an occupied quadrant and recursion never terminates.
var centerW = System.Math.Abs(q14size.Length - q23size.Length);
var centerH = System.Math.Abs(q14size.Width - q23size.Width);
if (comboY.Count1 > 0 && comboY.Count2 > 0 && comboX.Count1 > 0 && comboX.Count2 > 0
&& centerW > Tolerance.Epsilon && centerH > Tolerance.Epsilon)
{
CenterRemnant = new Box(
Bin.X + System.Math.Min(q14size.Length, q23size.Length),
Bin.Y + System.Math.Min(q14size.Width, q23size.Width),
centerW,
centerH);
}
}
}
}
+2 -2
View File
@@ -37,8 +37,8 @@ namespace OpenNest.RectanglePacking
double minX = items[0].X;
double minY = items[0].Y;
double maxX = items[0].X + items[0].Width;
double maxY = items[0].Y + items[0].Length;
double maxX = items[0].Right;
double maxY = items[0].Top;
foreach (var box in items)
{
@@ -16,11 +16,11 @@ namespace OpenNest.RectanglePacking
public override void Pack(List<Item> items)
{
items = items.OrderBy(i => -i.Length).ToList();
items = items.OrderBy(i => -i.Width).ToList();
foreach (var item in items)
{
if (item.Length > Bin.Length)
if (item.Width > Bin.Width)
continue;
var level = FindLevel(item);
@@ -36,10 +36,10 @@ namespace OpenNest.RectanglePacking
{
foreach (var level in levels)
{
if (level.Height < item.Length)
if (level.Height < item.Width)
continue;
if (level.RemainingWidth < item.Width)
if (level.RemainingLength < item.Length)
continue;
return level;
@@ -58,12 +58,12 @@ namespace OpenNest.RectanglePacking
var remaining = Bin.Top - y;
if (remaining < item.Length)
if (remaining < item.Width)
return null;
var level = new Level(Bin);
level.Y = y;
level.Height = item.Length;
level.Height = item.Width;
levels.Add(level);
@@ -93,9 +93,9 @@ namespace OpenNest.RectanglePacking
set { NextItemLocation.Y = value; }
}
public double Width
public double LevelLength
{
get { return Parent.Width; }
get { return Parent.Length; }
}
public double Height { get; set; }
@@ -105,9 +105,9 @@ namespace OpenNest.RectanglePacking
get { return Y + Height; }
}
public double RemainingWidth
public double RemainingLength
{
get { return X + Width - NextItemLocation.X; }
get { return X + LevelLength - NextItemLocation.X; }
}
public void AddItem(Item item)
@@ -115,7 +115,7 @@ namespace OpenNest.RectanglePacking
item.Location = NextItemLocation;
Parent.Items.Add(item);
NextItemLocation = new Vector(NextItemLocation.X + item.Width, NextItemLocation.Y);
NextItemLocation = new Vector(NextItemLocation.X + item.Length, NextItemLocation.Y);
}
}
}
@@ -0,0 +1,44 @@
using OpenNest.Geometry;
namespace OpenNest.RectanglePacking
{
internal static class RectFill
{
public static void FillBest(Bin bin, Item item, int maxCount = int.MaxValue)
{
var spiralBin = bin.Clone() as Bin;
var spiral = new FillSpiral(spiralBin);
spiral.Fill(item, maxCount);
// Recursively fill the center remnant of the spiral
if (spiralBin.Items.Count > 0 && spiral.CenterRemnant != null)
{
var center = spiral.CenterRemnant;
var fitsNormal = item.Length <= center.Length && item.Width <= center.Width;
var fitsRotated = item.Width <= center.Length && item.Length <= center.Width;
if (fitsNormal || fitsRotated)
{
var remaining = maxCount - spiralBin.Items.Count;
FillBest(center.Location, center.Size, spiralBin, item, remaining);
}
}
var bestFitBin = bin.Clone() as Bin;
new FillBestFit(bestFitBin).Fill(item, maxCount);
var winner = spiralBin.Items.Count >= bestFitBin.Items.Count ? spiralBin : bestFitBin;
bin.Items.AddRange(winner.Items);
}
public static void FillBest(Vector location, Size size, Bin target, Item item, int maxCount)
{
if (size.Width <= 0 || size.Length <= 0 || maxCount <= 0)
return;
var bin = new Bin { Location = location, Size = size };
FillBest(bin, item, maxCount);
target.Items.AddRange(bin.Items);
}
}
}
@@ -11,6 +11,9 @@ public class ColumnFillStrategy : IFillStrategy
public List<Part> Fill(FillContext context)
{
if (context.PartType == PartType.Rectangle)
return null;
var filler = new StripeFiller(context, NestDirection.Vertical) { CompleteStripesOnly = true };
return filler.Fill();
}
@@ -24,8 +24,8 @@ namespace OpenNest.Engine.Strategies
return FillHelpers.BestOverAngles(context, angles,
angle => filler.Fill(context.Item.Drawing, angle,
context.PlateNumber, context.Token, context.Progress),
NestPhase.Extents, "Extents");
context.Token, context.ReportProgress),
"Extents");
}
}
}
+30
View File
@@ -23,9 +23,39 @@ namespace OpenNest.Engine.Strategies
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
public FillScore CurrentBestScore { get; set; }
public NestPhase WinnerPhase { get; set; }
public NestPhase ActivePhase { get; set; }
public List<PhaseResult> PhaseResults { get; } = new();
public List<AngleResult> AngleResults { get; } = new();
public Dictionary<string, object> SharedState { get; } = new();
/// <summary>
/// Standard progress reporting for strategies and fillers. Reports intermediate
/// results using the current ActivePhase, PlateNumber, and WorkArea.
/// When the reported parts beat the current pipeline best, promotes the
/// result to IsOverallBest so the UI updates immediately.
/// </summary>
public void ReportProgress(List<Part> parts, string description)
{
var isNewBest = parts != null && parts.Count > 0
&& Policy.Comparer.IsBetter(parts, CurrentBest, WorkArea);
if (isNewBest)
{
CurrentBest = parts;
CurrentBestScore = FillScore.Compute(parts, WorkArea);
WinnerPhase = ActivePhase;
}
NestEngineBase.ReportProgress(Progress, new ProgressReport
{
Phase = ActivePhase,
PlateNumber = PlateNumber,
Parts = isNewBest ? parts : CurrentBest,
WorkArea = WorkArea,
Description = description,
IsOverallBest = isNewBest,
});
}
}
}
+3 -10
View File
@@ -113,13 +113,12 @@ namespace OpenNest.Engine.Strategies
/// <summary>
/// Sweeps a list of angles, calling fillAtAngle for each, and returns
/// the best result according to the context's comparer. Handles
/// cancellation and progress reporting.
/// cancellation and progress reporting via context.ReportProgress.
/// </summary>
public static List<Part> BestOverAngles(
FillContext context,
IReadOnlyList<double> angles,
Func<double, List<Part>> fillAtAngle,
NestPhase phase,
string phaseLabel)
{
var workArea = context.WorkArea;
@@ -140,14 +139,8 @@ namespace OpenNest.Engine.Strategies
best = result;
}
NestEngineBase.ReportProgress(context.Progress, new ProgressReport
{
Phase = phase,
PlateNumber = context.PlateNumber,
Parts = best,
WorkArea = workArea,
Description = $"{phaseLabel}: {i + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts",
});
context.ReportProgress(best,
$"{phaseLabel}: {i + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts");
}
return best ?? new List<Part>();
@@ -40,7 +40,7 @@ namespace OpenNest.Engine.Strategies
return result;
},
NestPhase.Linear, "Linear");
"Linear");
}
}
}
@@ -30,7 +30,7 @@ namespace OpenNest.Engine.Strategies
var dedup = GridDedup.GetOrCreate(context.SharedState);
var filler = new PairFiller(context.Plate, comparer, dedup);
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);
context.Token, context.ReportProgress);
context.SharedState["BestFits"] = result.BestFits;
@@ -14,8 +14,7 @@ namespace OpenNest.Engine.Strategies
var binItem = BinConverter.ToItem(context.Item, context.Plate.PartSpacing);
var bin = BinConverter.CreateBin(context.WorkArea, context.Plate.PartSpacing);
var engine = new FillBestFit(bin);
engine.Fill(binItem);
RectFill.FillBest(bin, binItem);
return BinConverter.ToParts(bin, new List<NestItem> { context.Item });
}
@@ -11,6 +11,9 @@ public class RowFillStrategy : IFillStrategy
public List<Part> Fill(FillContext context)
{
if (context.PartType == PartType.Rectangle)
return null;
var filler = new StripeFiller(context, NestDirection.Horizontal) { CompleteStripesOnly = true };
return filler.Fill();
}
+1 -1
View File
@@ -36,7 +36,7 @@ namespace OpenNest
var bb = item.Drawing.Program.BoundingBox();
var cos = System.Math.Abs(System.Math.Cos(angle));
var sin = System.Math.Abs(System.Math.Sin(angle));
return bb.Width * cos + bb.Length * sin;
return bb.Length * cos + bb.Width * sin;
}
}
}
+378
View File
@@ -0,0 +1,378 @@
using ACadSharp;
using ACadSharp.IO;
using CSMath;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
namespace OpenNest.IO
{
using AcadArc = ACadSharp.Entities.Arc;
using AcadCircle = ACadSharp.Entities.Circle;
using AcadLine = ACadSharp.Entities.Line;
using Layer = ACadSharp.Tables.Layer;
public static class Dxf
{
#region Import
/// <summary>
/// Imports a DXF file, returning both converted entities and the raw CadDocument
/// for bend detection. The CadDocument is NOT disposed — caller can use it for
/// additional analysis (e.g., MText extraction for bend notes).
/// </summary>
public static DxfImportResult Import(string path)
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return new DxfImportResult
{
Entities = ConvertEntities(doc),
Document = doc
};
}
public static List<Entity> GetGeometry(string path)
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return ConvertEntities(doc);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(Stream stream)
{
try
{
using var reader = new DxfReader(stream);
var doc = reader.Read();
return ConvertEntities(doc);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
#endregion
#region Export
public static void ExportProgram(Program program, string path)
{
using var stream = File.Create(path);
ExportProgram(program, stream);
}
public static void ExportProgram(Program program, Stream stream)
{
var ctx = new ExportContext();
ctx.AddProgram(program);
using var writer = new DxfWriter(stream, ctx.Document, false);
writer.Write();
}
public static void ExportPlate(Plate plate, string path)
{
using var stream = File.Create(path);
ExportPlate(plate, stream);
}
public static void ExportPlate(Plate plate, Stream stream)
{
var ctx = new ExportContext();
ctx.AddPlateOutline(plate);
foreach (var part in plate.Parts)
{
var endpt = part.Location.ToAcadXYZ();
ctx.AddLine(ctx.CurPos, endpt, ctx.RapidLayer);
ctx.CurPos = part.Location.ToAcadXYZ();
ctx.AddProgram(part.Program);
}
using var writer = new DxfWriter(stream, ctx.Document, false);
writer.Write();
}
#endregion
#region Private
private static List<Entity> ConvertEntities(CadDocument doc)
{
var entities = new List<Entity>();
var lines = new List<Line>();
var arcs = new List<Arc>();
foreach (var entity in doc.Entities)
{
if (IsNonCutLayer(entity.Layer?.Name))
continue;
switch (entity)
{
case ACadSharp.Entities.Line line:
lines.Add(line.ToOpenNest());
break;
case ACadSharp.Entities.Arc arc:
arcs.Add(arc.ToOpenNest());
break;
case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest());
break;
case ACadSharp.Entities.Spline spline:
foreach (var e in spline.ToOpenNest())
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
case ACadSharp.Entities.LwPolyline lwPolyline:
lines.AddRange(lwPolyline.ToOpenNest());
break;
case ACadSharp.Entities.Polyline polyline:
lines.AddRange(polyline.ToOpenNest());
break;
case ACadSharp.Entities.Ellipse ellipse:
foreach (var e in ellipse.ToOpenNest())
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
}
}
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
entities.AddRange(lines);
entities.AddRange(arcs);
return entities;
}
private static bool IsNonCutLayer(string layerName)
{
return string.Equals(layerName, "BEND", StringComparison.OrdinalIgnoreCase)
|| string.Equals(layerName, "ETCH", StringComparison.OrdinalIgnoreCase);
}
private class ExportContext
{
public CadDocument Document { get; }
public XYZ CurPos { get; set; }
public Layer CutLayer { get; }
public Layer RapidLayer { get; }
public Layer PlateLayer { get; }
private Mode mode;
public ExportContext()
{
Document = new CadDocument();
CutLayer = new Layer("Cut") { Color = new Color(1) };
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
PlateLayer = new Layer("Plate") { Color = new Color(4) };
Document.Layers.Add(CutLayer);
Document.Layers.Add(RapidLayer);
Document.Layers.Add(PlateLayer);
}
public void AddLine(XYZ start, XYZ end, Layer layer)
{
var ln = new AcadLine
{
StartPoint = start,
EndPoint = end,
Layer = layer
};
Document.Entities.Add(ln);
}
public void AddPlateOutline(Plate plate)
{
XYZ pt1, pt2, pt3, pt4;
switch (plate.Quadrant)
{
case 1:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, plate.Size.Width, 0);
pt3 = new XYZ(plate.Size.Length, plate.Size.Width, 0);
pt4 = new XYZ(plate.Size.Length, 0, 0);
break;
case 2:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, plate.Size.Width, 0);
pt3 = new XYZ(-plate.Size.Length, plate.Size.Width, 0);
pt4 = new XYZ(-plate.Size.Length, 0, 0);
break;
case 3:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, -plate.Size.Width, 0);
pt3 = new XYZ(-plate.Size.Length, -plate.Size.Width, 0);
pt4 = new XYZ(-plate.Size.Length, 0, 0);
break;
case 4:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, -plate.Size.Width, 0);
pt3 = new XYZ(plate.Size.Length, -plate.Size.Width, 0);
pt4 = new XYZ(plate.Size.Length, 0, 0);
break;
default:
return;
}
AddLine(pt1, pt2, PlateLayer);
AddLine(pt2, pt3, PlateLayer);
AddLine(pt3, pt4, PlateLayer);
AddLine(pt4, pt1, PlateLayer);
var m1 = new XYZ(pt1.X + plate.EdgeSpacing.Left, pt1.Y + plate.EdgeSpacing.Bottom, 0);
var m2 = new XYZ(m1.X, pt2.Y - plate.EdgeSpacing.Top, 0);
var m3 = new XYZ(pt3.X - plate.EdgeSpacing.Right, m2.Y, 0);
var m4 = new XYZ(m3.X, m1.Y, 0);
AddLine(m1, m2, PlateLayer);
AddLine(m2, m3, PlateLayer);
AddLine(m3, m4, PlateLayer);
AddLine(m4, m1, PlateLayer);
}
public void AddProgram(Program program)
{
mode = program.Mode;
for (var i = 0; i < program.Length; ++i)
{
var code = program[i];
switch (code.Type)
{
case CodeType.ArcMove:
AddArcMove((ArcMove)code);
break;
case CodeType.LinearMove:
AddLinearMove((LinearMove)code);
break;
case CodeType.RapidMove:
AddRapidMove((RapidMove)code);
break;
case CodeType.SubProgramCall:
var tmpmode = mode;
AddProgram(((SubProgramCall)code).Program);
mode = tmpmode;
break;
}
}
}
private void AddLinearMove(LinearMove line)
{
var pt = line.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
pt = new XYZ(pt.X + CurPos.X, pt.Y + CurPos.Y, 0);
AddLine(CurPos, pt, CutLayer);
CurPos = pt;
}
private void AddRapidMove(RapidMove rapid)
{
var pt = rapid.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
pt = new XYZ(pt.X + CurPos.X, pt.Y + CurPos.Y, 0);
AddLine(CurPos, pt, RapidLayer);
CurPos = pt;
}
private void AddArcMove(ArcMove arc)
{
var center = arc.CenterPoint.ToAcadXYZ();
var endpt = arc.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
{
endpt = new XYZ(endpt.X + CurPos.X, endpt.Y + CurPos.Y, 0);
center = new XYZ(center.X + CurPos.X, center.Y + CurPos.Y, 0);
}
var startAngle = System.Math.Atan2(
CurPos.Y - center.Y,
CurPos.X - center.X);
var endAngle = System.Math.Atan2(
endpt.Y - center.Y,
endpt.X - center.X);
if (arc.Rotation == RotationType.CW)
Generic.Swap(ref startAngle, ref endAngle);
var dx = endpt.X - center.X;
var dy = endpt.Y - center.Y;
var radius = System.Math.Sqrt(dx * dx + dy * dy);
if (startAngle.IsEqualTo(endAngle))
{
var circle = new AcadCircle
{
Center = center,
Radius = radius,
Layer = CutLayer
};
Document.Entities.Add(circle);
}
else
{
var acadArc = new AcadArc
{
Center = center,
Radius = radius,
StartAngle = startAngle,
EndAngle = endAngle,
Layer = CutLayer
};
Document.Entities.Add(acadArc);
}
CurPos = endpt;
}
}
#endregion
}
}
-297
View File
@@ -1,297 +0,0 @@
using ACadSharp;
using ACadSharp.IO;
using CSMath;
using OpenNest.CNC;
using OpenNest.Math;
using System.Diagnostics;
using System.IO;
namespace OpenNest.IO
{
using AcadArc = ACadSharp.Entities.Arc;
using AcadCircle = ACadSharp.Entities.Circle;
using AcadLine = ACadSharp.Entities.Line;
using Layer = ACadSharp.Tables.Layer;
public class DxfExporter
{
private CadDocument doc;
private XYZ curpos;
private Mode mode;
private readonly Layer cutLayer;
private readonly Layer rapidLayer;
private readonly Layer plateLayer;
public DxfExporter()
{
doc = new CadDocument();
cutLayer = new Layer("Cut");
cutLayer.Color = new Color(1);
rapidLayer = new Layer("Rapid");
rapidLayer.Color = new Color(5);
plateLayer = new Layer("Plate");
plateLayer.Color = new Color(4);
}
public void ExportProgram(Program program, Stream stream)
{
doc = new CadDocument();
EnsureLayers();
AddProgram(program);
using (var writer = new DxfWriter(stream, doc, false))
{
writer.Write();
}
}
public bool ExportProgram(Program program, string path)
{
Stream stream = null;
var success = false;
try
{
stream = File.Create(path);
ExportProgram(program, stream);
success = true;
}
catch
{
Debug.Fail("DxfExporter.ExportProgram failed to write program to file: " + path);
}
finally
{
if (stream != null)
stream.Close();
}
return success;
}
public void ExportPlate(Plate plate, Stream stream)
{
doc = new CadDocument();
EnsureLayers();
AddPlateOutline(plate);
foreach (var part in plate.Parts)
{
var endpt = part.Location.ToAcadXYZ();
AddLine(curpos, endpt, rapidLayer);
curpos = part.Location.ToAcadXYZ();
AddProgram(part.Program);
}
using (var writer = new DxfWriter(stream, doc, false))
{
writer.Write();
}
}
public bool ExportPlate(Plate plate, string path)
{
Stream stream = null;
var success = false;
try
{
stream = File.Create(path);
ExportPlate(plate, stream);
success = true;
}
catch
{
Debug.Fail("DxfExporter.ExportPlate failed to write plate to file: " + path);
}
finally
{
if (stream != null)
stream.Close();
}
return success;
}
private void EnsureLayers()
{
doc.Layers.Add(cutLayer);
doc.Layers.Add(rapidLayer);
doc.Layers.Add(plateLayer);
}
private void AddLine(XYZ start, XYZ end, Layer layer)
{
var ln = new AcadLine();
ln.StartPoint = start;
ln.EndPoint = end;
ln.Layer = layer;
doc.Entities.Add(ln);
}
private void AddPlateOutline(Plate plate)
{
XYZ pt1;
XYZ pt2;
XYZ pt3;
XYZ pt4;
switch (plate.Quadrant)
{
case 1:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, plate.Size.Width, 0);
pt3 = new XYZ(plate.Size.Length, plate.Size.Width, 0);
pt4 = new XYZ(plate.Size.Length, 0, 0);
break;
case 2:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, plate.Size.Width, 0);
pt3 = new XYZ(-plate.Size.Length, plate.Size.Width, 0);
pt4 = new XYZ(-plate.Size.Length, 0, 0);
break;
case 3:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, -plate.Size.Width, 0);
pt3 = new XYZ(-plate.Size.Length, -plate.Size.Width, 0);
pt4 = new XYZ(-plate.Size.Length, 0, 0);
break;
case 4:
pt1 = new XYZ(0, 0, 0);
pt2 = new XYZ(0, -plate.Size.Width, 0);
pt3 = new XYZ(plate.Size.Length, -plate.Size.Width, 0);
pt4 = new XYZ(plate.Size.Length, 0, 0);
break;
default:
return;
}
AddLine(pt1, pt2, plateLayer);
AddLine(pt2, pt3, plateLayer);
AddLine(pt3, pt4, plateLayer);
AddLine(pt4, pt1, plateLayer);
var m1 = new XYZ(pt1.X + plate.EdgeSpacing.Left, pt1.Y + plate.EdgeSpacing.Bottom, 0);
var m2 = new XYZ(m1.X, pt2.Y - plate.EdgeSpacing.Top, 0);
var m3 = new XYZ(pt3.X - plate.EdgeSpacing.Right, m2.Y, 0);
var m4 = new XYZ(m3.X, m1.Y, 0);
AddLine(m1, m2, plateLayer);
AddLine(m2, m3, plateLayer);
AddLine(m3, m4, plateLayer);
AddLine(m4, m1, plateLayer);
}
private void AddProgram(Program program)
{
mode = program.Mode;
for (var i = 0; i < program.Length; ++i)
{
var code = program[i];
switch (code.Type)
{
case CodeType.ArcMove:
var arc = (ArcMove)code;
AddArcMove(arc);
break;
case CodeType.LinearMove:
var line = (LinearMove)code;
AddLinearMove(line);
break;
case CodeType.RapidMove:
var rapid = (RapidMove)code;
AddRapidMove(rapid);
break;
case CodeType.SubProgramCall:
var tmpmode = mode;
var subpgm = (CNC.SubProgramCall)code;
AddProgram(subpgm.Program);
mode = tmpmode;
break;
}
}
}
private void AddLinearMove(LinearMove line)
{
var pt = line.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0);
AddLine(curpos, pt, cutLayer);
curpos = pt;
}
private void AddRapidMove(RapidMove rapid)
{
var pt = rapid.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0);
AddLine(curpos, pt, rapidLayer);
curpos = pt;
}
private void AddArcMove(ArcMove arc)
{
var center = arc.CenterPoint.ToAcadXYZ();
var endpt = arc.EndPoint.ToAcadXYZ();
if (mode == Mode.Incremental)
{
endpt = new XYZ(endpt.X + curpos.X, endpt.Y + curpos.Y, 0);
center = new XYZ(center.X + curpos.X, center.Y + curpos.Y, 0);
}
var startAngle = System.Math.Atan2(
curpos.Y - center.Y,
curpos.X - center.X);
var endAngle = System.Math.Atan2(
endpt.Y - center.Y,
endpt.X - center.X);
if (arc.Rotation == OpenNest.RotationType.CW)
Generic.Swap(ref startAngle, ref endAngle);
var dx = endpt.X - center.X;
var dy = endpt.Y - center.Y;
var radius = System.Math.Sqrt(dx * dx + dy * dy);
if (startAngle.IsEqualTo(endAngle))
{
var circle = new AcadCircle();
circle.Center = center;
circle.Radius = radius;
circle.Layer = cutLayer;
doc.Entities.Add(circle);
}
else
{
var arc2 = new AcadArc();
arc2.Center = center;
arc2.Radius = radius;
arc2.StartAngle = startAngle;
arc2.EndAngle = endAngle;
arc2.Layer = cutLayer;
doc.Entities.Add(arc2);
}
curpos = endpt;
}
}
}
-155
View File
@@ -1,155 +0,0 @@
using ACadSharp;
using ACadSharp.IO;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
namespace OpenNest.IO
{
public class DxfImporter
{
public int SplinePrecision { get; set; }
public DxfImporter()
{
}
private List<Entity> GetGeometry(CadDocument doc)
{
var entities = new List<Entity>();
var lines = new List<Line>();
var arcs = new List<Arc>();
foreach (var entity in doc.Entities)
{
// Skip bend/etch entities — bends are converted to Bend objects
// separately via bend detection, and etch marks are generated from
// bends during DXF export. Neither should be treated as cut geometry.
if (IsNonCutLayer(entity.Layer?.Name))
continue;
switch (entity)
{
case ACadSharp.Entities.Line line:
lines.Add(line.ToOpenNest());
break;
case ACadSharp.Entities.Arc arc:
arcs.Add(arc.ToOpenNest());
break;
case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest());
break;
case ACadSharp.Entities.Spline spline:
foreach (var e in spline.ToOpenNest(SplinePrecision))
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
case ACadSharp.Entities.LwPolyline lwPolyline:
lines.AddRange(lwPolyline.ToOpenNest());
break;
case ACadSharp.Entities.Polyline polyline:
lines.AddRange(polyline.ToOpenNest());
break;
case ACadSharp.Entities.Ellipse ellipse:
foreach (var e in ellipse.ToOpenNest())
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
}
}
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
entities.AddRange(lines);
entities.AddRange(arcs);
return entities;
}
/// <summary>
/// Imports a DXF file, returning both converted entities and the raw CadDocument
/// for bend detection. The CadDocument is NOT disposed — caller can use it for
/// additional analysis (e.g., MText extraction for bend notes).
/// </summary>
public DxfImportResult Import(string path)
{
using var reader = new DxfReader(path);
var doc = reader.Read();
var entities = GetGeometry(doc);
return new DxfImportResult
{
Entities = entities,
Document = doc
};
}
public bool GetGeometry(Stream stream, out List<Entity> geometry)
{
var success = false;
try
{
using (var reader = new DxfReader(stream))
{
var doc = reader.Read();
geometry = GetGeometry(doc);
success = true;
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
geometry = new List<Entity>();
}
finally
{
if (stream != null)
stream.Close();
}
return success;
}
public bool GetGeometry(string path, out List<Entity> geometry)
{
var success = false;
try
{
using (var reader = new DxfReader(path))
{
var doc = reader.Read();
geometry = GetGeometry(doc);
success = true;
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
geometry = new List<Entity>();
}
return success;
}
private static bool IsNonCutLayer(string layerName)
{
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(layerName, "ETCH", System.StringComparison.OrdinalIgnoreCase);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Linq;
using static OpenNest.IO.NestFormat;
namespace OpenNest.IO
{
public static class EntitySerializer
{
public static EntitySetDto ToDto(List<Entity> entities, HashSet<Guid> suppressed)
{
return new EntitySetDto
{
Entities = entities.Select(ToEntityDto).ToList(),
Suppressed = suppressed.Select(id => id.ToString()).ToList()
};
}
public static (List<Entity> entities, HashSet<Guid> suppressed) FromDto(EntitySetDto dto)
{
var entities = dto.Entities.Select(FromEntityDto).ToList();
var suppressed = new HashSet<Guid>(dto.Suppressed.Select(Guid.Parse));
return (entities, suppressed);
}
private static EntityDto ToEntityDto(Entity entity)
{
switch (entity.Type)
{
case EntityType.Line:
var line = (Line)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "line",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
X1 = line.StartPoint.X,
Y1 = line.StartPoint.Y,
X2 = line.EndPoint.X,
Y2 = line.EndPoint.Y
};
case EntityType.Arc:
var arc = (Arc)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "arc",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
CX = arc.Center.X,
CY = arc.Center.Y,
R = arc.Radius,
StartAngle = arc.StartAngle,
EndAngle = arc.EndAngle,
Reversed = arc.IsReversed
};
case EntityType.Circle:
var circle = (Circle)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "circle",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
CX = circle.Center.X,
CY = circle.Center.Y,
R = circle.Radius,
Rotation = circle.Rotation == RotationType.CW ? "CW" : "CCW"
};
default:
throw new NotSupportedException($"Entity type {entity.Type} is not supported for serialization.");
}
}
private static Entity FromEntityDto(EntityDto dto)
{
Entity entity;
switch (dto.Type)
{
case "line":
entity = new Line(
new Vector(dto.X1, dto.Y1),
new Vector(dto.X2, dto.Y2));
break;
case "arc":
entity = new Arc(
new Vector(dto.CX, dto.CY),
dto.R,
dto.StartAngle,
dto.EndAngle,
dto.Reversed);
break;
case "circle":
var circle = new Circle(new Vector(dto.CX, dto.CY), dto.R);
circle.Rotation = dto.Rotation == "CW" ? RotationType.CW : RotationType.CCW;
entity = circle;
break;
default:
throw new NotSupportedException($"Entity type '{dto.Type}' is not supported for deserialization.");
}
entity.Id = Guid.Parse(dto.Id);
entity.Layer = ResolveLayer(dto.Layer);
entity.LineTypeName = dto.LineType;
return entity;
}
private static Layer ResolveLayer(string name)
{
if (string.IsNullOrEmpty(name) || name == "0")
return Layer.Default;
if (string.Equals(name, SpecialLayers.Cut.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Cut;
if (string.Equals(name, SpecialLayers.Rapid.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Rapid;
if (string.Equals(name, SpecialLayers.Display.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Display;
if (string.Equals(name, SpecialLayers.Leadin.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Leadin;
if (string.Equals(name, SpecialLayers.Leadout.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Leadout;
if (string.Equals(name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Scribe;
return new Layer(name);
}
}
}
+2 -2
View File
@@ -57,7 +57,7 @@ namespace OpenNest.IO
return result;
}
public static List<Geometry.Entity> ToOpenNest(this Spline spline, int precision)
public static List<Geometry.Entity> ToOpenNest(this Spline spline)
{
var layer = spline.Layer.ToOpenNest();
var color = spline.ResolveColor();
@@ -67,7 +67,7 @@ namespace OpenNest.IO
List<XYZ> curvePoints;
try
{
curvePoints = spline.PolygonalVertexes(precision > 0 ? precision : 200);
curvePoints = spline.PolygonalVertexes(200);
}
catch (Exception ex)
{
+38
View File
@@ -29,6 +29,8 @@ namespace OpenNest.IO
public PlateDefaultsDto PlateDefaults { get; init; } = new();
public List<DrawingDto> Drawings { get; init; } = new();
public List<PlateDto> Plates { get; init; } = new();
public List<PlateOptionDto> PlateOptions { get; init; } = new();
public double SalvageRate { get; init; } = 0.5;
}
public record PlateDefaultsDto
@@ -153,6 +155,42 @@ namespace OpenNest.IO
public string NoteText { get; init; } = "";
}
public record PlateOptionDto
{
public double Width { get; init; }
public double Length { get; init; }
public double Cost { get; init; }
}
public record EntitySetDto
{
public List<EntityDto> Entities { get; init; } = new();
public List<string> Suppressed { get; init; } = new();
}
public record EntityDto
{
public string Id { get; init; } = "";
public string Type { get; init; } = "";
public string Layer { get; init; } = "";
public string LineType { get; init; } = "";
// Line
public double X1 { get; init; }
public double Y1 { get; init; }
public double X2 { get; init; }
public double Y2 { get; init; }
// Arc / Circle
public double CX { get; init; }
public double CY { get; init; }
public double R { get; init; }
public double StartAngle { get; init; }
public double EndAngle { get; init; }
public bool Reversed { get; init; }
public string Rotation { get; init; } = "";
}
public record BestFitSetDto
{
public double PlateWidth { get; init; }
+39 -2
View File
@@ -36,7 +36,8 @@ namespace OpenNest.IO
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
var programs = ReadPrograms(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs);
var entitySets = ReadEntitySets(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs, entitySets);
ReadBestFits(drawingMap);
var nest = BuildNest(dto, drawingMap);
@@ -74,7 +75,25 @@ namespace OpenNest.IO
return programs;
}
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs)
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(int count)
{
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
for (var i = 1; i <= count; i++)
{
var entry = zipArchive.GetEntry($"entities/entities-{i}");
if (entry == null) continue;
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
var json = reader.ReadToEnd();
var dto = JsonSerializer.Deserialize<EntitySetDto>(json, JsonOptions);
result[i] = EntitySerializer.FromDto(dto);
}
return result;
}
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs,
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> entitySets)
{
var map = new Dictionary<int, Drawing>();
foreach (var d in dto.Drawings)
@@ -112,6 +131,12 @@ namespace OpenNest.IO
if (programs.TryGetValue(d.Id, out var pgm))
drawing.Program = pgm;
if (entitySets.TryGetValue(d.Id, out var entitySet))
{
drawing.SourceEntities = entitySet.entities;
drawing.SuppressedEntityIds = entitySet.suppressed;
}
map[d.Id] = drawing;
}
return map;
@@ -192,6 +217,18 @@ namespace OpenNest.IO
nest.PlateDefaults.PartSpacing = pd.PartSpacing;
nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top);
// Plate optimizer settings
nest.SalvageRate = dto.SalvageRate;
if (dto.PlateOptions != null)
{
nest.PlateOptions = dto.PlateOptions.Select(o => new PlateOption
{
Width = o.Width,
Length = o.Length,
Cost = o.Cost,
}).ToList();
}
// Drawings
foreach (var d in drawingMap.OrderBy(k => k.Key))
nest.Drawings.Add(d.Value);
+64 -16
View File
@@ -41,6 +41,7 @@ namespace OpenNest.IO
WriteNestJson(zipArchive);
WritePrograms(zipArchive);
WriteEntities(zipArchive);
WriteBestFits(zipArchive);
return true;
@@ -88,7 +89,14 @@ namespace OpenNest.IO
},
PlateDefaults = BuildPlateDefaultsDto(),
Drawings = BuildDrawingDtos(),
Plates = BuildPlateDtos()
Plates = BuildPlateDtos(),
PlateOptions = nest.PlateOptions?.Select(o => new PlateOptionDto
{
Width = o.Width,
Length = o.Length,
Cost = o.Cost,
}).ToList() ?? new(),
SalvageRate = nest.SalvageRate,
};
}
@@ -168,9 +176,15 @@ namespace OpenNest.IO
private List<PlateDto> BuildPlateDtos()
{
var list = new List<PlateDto>();
var id = 0;
for (var i = 0; i < nest.Plates.Count; i++)
{
var plate = nest.Plates[i];
if (plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0 && plate.CutOffs.Count == 0)
continue;
id++;
var parts = new List<PartDto>();
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff))
{
@@ -201,7 +215,7 @@ namespace OpenNest.IO
list.Add(new PlateDto
{
Id = i + 1,
Id = id,
Size = new SizeDto { Width = plate.Size.Width, Length = plate.Size.Length },
Quadrant = plate.Quadrant,
Quantity = plate.Quantity,
@@ -299,12 +313,39 @@ namespace OpenNest.IO
}
}
private void WriteEntities(ZipArchive zipArchive)
{
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{
var drawing = kvp.Value;
if (drawing.SourceEntities == null || drawing.SourceEntities.Count == 0)
continue;
var dto = EntitySerializer.ToDto(drawing.SourceEntities, drawing.SuppressedEntityIds);
var json = JsonSerializer.Serialize(dto, JsonOptions);
var entry = zipArchive.CreateEntry($"entities/entities-{kvp.Key}");
using var stream = entry.Open();
using var writer = new StreamWriter(stream, Encoding.UTF8);
writer.Write(json);
}
}
private void WriteDrawing(Stream stream, Drawing drawing)
{
var program = drawing.Program;
var writer = new StreamWriter(stream);
writer.AutoFlush = true;
// Emit variable definitions before G-code
foreach (var v in program.Variables.Values)
{
var line = $"{v.Name} = {v.Expression}";
if (v.Inline) line += " inline";
if (v.Global) line += " global";
writer.WriteLine(line);
}
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
for (var i = 0; i < drawing.Program.Length; ++i)
@@ -316,6 +357,13 @@ namespace OpenNest.IO
stream.Position = 0;
}
private string FormatCoord(double value, string axis, Dictionary<string, string> variableRefs)
{
if (variableRefs != null && variableRefs.TryGetValue(axis, out var varName))
return $"${varName}";
return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat);
}
private string GetCodeString(ICode code)
{
switch (code.Type)
@@ -324,16 +372,16 @@ namespace OpenNest.IO
{
var sb = new StringBuilder();
var arcMove = (ArcMove)code;
var refs = arcMove.VariableRefs;
var x = System.Math.Round(arcMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat);
var y = System.Math.Round(arcMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat);
var i = System.Math.Round(arcMove.CenterPoint.X, OutputPrecision).ToString(CoordinateFormat);
var j = System.Math.Round(arcMove.CenterPoint.Y, OutputPrecision).ToString(CoordinateFormat);
var x = FormatCoord(arcMove.EndPoint.X, "X", refs);
var y = FormatCoord(arcMove.EndPoint.Y, "Y", refs);
var i = FormatCoord(arcMove.CenterPoint.X, "I", refs);
var j = FormatCoord(arcMove.CenterPoint.Y, "J", refs);
if (arcMove.Rotation == RotationType.CW)
sb.Append(string.Format("G02X{0}Y{1}I{2}J{3}", x, y, i, j));
else
sb.Append(string.Format("G03X{0}Y{1}I{2}J{3}", x, y, i, j));
sb.Append(arcMove.Rotation == RotationType.CW
? $"G02X{x}Y{y}I{i}J{j}"
: $"G03X{x}Y{y}I{i}J{j}");
if (arcMove.Layer != LayerType.Cut)
sb.Append(GetLayerString(arcMove.Layer));
@@ -354,10 +402,9 @@ namespace OpenNest.IO
{
var sb = new StringBuilder();
var linearMove = (LinearMove)code;
var refs = linearMove.VariableRefs;
sb.Append(string.Format("G01X{0}Y{1}",
System.Math.Round(linearMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
System.Math.Round(linearMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat)));
sb.Append($"G01X{FormatCoord(linearMove.EndPoint.X, "X", refs)}Y{FormatCoord(linearMove.EndPoint.Y, "Y", refs)}");
if (linearMove.Layer != LayerType.Cut)
sb.Append(GetLayerString(linearMove.Layer));
@@ -371,15 +418,16 @@ namespace OpenNest.IO
case CodeType.RapidMove:
{
var rapidMove = (RapidMove)code;
var refs = rapidMove.VariableRefs;
return string.Format("G00X{0}Y{1}",
System.Math.Round(rapidMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
System.Math.Round(rapidMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat));
return $"G00X{FormatCoord(rapidMove.EndPoint.X, "X", refs)}Y{FormatCoord(rapidMove.EndPoint.Y, "Y", refs)}";
}
case CodeType.SetFeedrate:
{
var setFeedrate = (Feedrate)code;
if (setFeedrate.VariableRef != null)
return $"F${setFeedrate.VariableRef}";
return "F" + setFeedrate.Value;
}
+262 -7
View File
@@ -1,7 +1,11 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenNest.IO
@@ -15,6 +19,7 @@ namespace OpenNest.IO
private CodeSection section;
private Program program;
private StreamReader reader;
private Dictionary<string, double> resolvedVariables;
public ProgramReader(Stream stream)
{
@@ -24,11 +29,38 @@ namespace OpenNest.IO
public Program Read()
{
// First pass: read all lines, collect variable definitions
var allLines = new List<string>();
var variableDefs = new Dictionary<string, (string expression, bool inline, bool global)>(
StringComparer.OrdinalIgnoreCase);
var codeLines = new List<string>();
string line;
while ((line = reader.ReadLine()) != null)
{
block = ParseBlock(line);
allLines.Add(line);
if (TryParseVariableDefinition(line, out var name, out var expression, out var isInline, out var isGlobal))
variableDefs[name] = (expression, isInline, isGlobal);
else
codeLines.Add(line);
}
// Evaluate variables with topological sort for dependency ordering
resolvedVariables = ResolveVariables(variableDefs);
// Store evaluated variables on the program
foreach (var kvp in variableDefs)
{
var name = kvp.Key;
var (expression, isInline, isGlobal) = kvp.Value;
var value = resolvedVariables[name];
program.Variables[name] = new VariableDefinition(name, expression, value, isInline, isGlobal);
}
// Second pass: parse G-code lines with variable substitution
foreach (var codeLine in codeLines)
{
block = ParseBlock(codeLine);
ProcessCurrentBlock();
}
@@ -39,10 +71,43 @@ namespace OpenNest.IO
{
var block = new CodeBlock();
Code code = null;
for (int i = 0; i < line.Length; ++i)
for (var i = 0; i < line.Length; ++i)
{
var c = line[i];
if (char.IsLetter(c))
if (c == '$' && code != null && resolvedVariables != null)
{
// Read the maximal variable name (letters, digits, underscores)
var start = i + 1;
while (start < line.Length && (char.IsLetterOrDigit(line[start]) || line[start] == '_'))
start++;
var maxName = line.Substring(i + 1, start - i - 1);
// Try longest match first, then progressively shorter to handle
// cases like X$widthY0 where "widthY" isn't a variable but "width" is
string lookupKey = null;
var nameLen = maxName.Length;
while (nameLen > 0)
{
var candidate = maxName.Substring(0, nameLen);
lookupKey = resolvedVariables.Keys
.FirstOrDefault(k => string.Equals(k, candidate, StringComparison.OrdinalIgnoreCase));
if (lookupKey != null)
break;
nameLen--;
}
if (lookupKey != null)
{
code.Value = resolvedVariables[lookupKey].ToString(CultureInfo.InvariantCulture);
code.VariableRef = lookupKey;
i += nameLen; // advance past the matched variable name
}
else
{
i = start - 1; // no match, skip the whole thing
}
}
else if (char.IsLetter(c))
block.Add((code = new Code(c)));
else if (c == ':')
{
@@ -125,7 +190,10 @@ namespace OpenNest.IO
break;
case 'F':
program.Codes.Add(new Feedrate() { Value = double.Parse(code.Value) });
var feedrate = new Feedrate() { Value = double.Parse(code.Value) };
if (code.VariableRef != null)
feedrate.VariableRef = code.VariableRef;
program.Codes.Add(feedrate);
code = GetNextCode();
break;
@@ -143,6 +211,7 @@ namespace OpenNest.IO
double y = 0;
var layer = LayerType.Cut;
var suppressed = false;
string xRef = null, yRef = null;
while (section == CodeSection.Line)
{
@@ -157,10 +226,12 @@ namespace OpenNest.IO
{
case 'X':
x = double.Parse(code.Value);
xRef = code.VariableRef;
break;
case 'Y':
y = double.Parse(code.Value);
yRef = code.VariableRef;
break;
case ':':
@@ -200,10 +271,13 @@ namespace OpenNest.IO
break;
}
}
var refs = BuildVariableRefs(("X", xRef), ("Y", yRef));
if (isRapid)
program.Codes.Add(new RapidMove(x, y));
program.Codes.Add(new RapidMove(x, y) { VariableRefs = refs });
else
program.Codes.Add(new LinearMove(x, y) { Layer = layer, Suppressed = suppressed });
program.Codes.Add(new LinearMove(x, y) { Layer = layer, Suppressed = suppressed, VariableRefs = refs });
}
private void ReadArc(RotationType rotation)
@@ -214,6 +288,7 @@ namespace OpenNest.IO
double j = 0;
var layer = LayerType.Cut;
var suppressed = false;
string xRef = null, yRef = null, iRef = null, jRef = null;
while (section == CodeSection.Arc)
{
@@ -229,18 +304,22 @@ namespace OpenNest.IO
{
case 'X':
x = double.Parse(code.Value);
xRef = code.VariableRef;
break;
case 'Y':
y = double.Parse(code.Value);
yRef = code.VariableRef;
break;
case 'I':
i = double.Parse(code.Value);
iRef = code.VariableRef;
break;
case 'J':
j = double.Parse(code.Value);
jRef = code.VariableRef;
break;
case ':':
@@ -286,7 +365,8 @@ namespace OpenNest.IO
CenterPoint = new Vector(i, j),
Rotation = rotation,
Layer = layer,
Suppressed = suppressed
Suppressed = suppressed,
VariableRefs = BuildVariableRefs(("X", xRef), ("Y", yRef), ("I", iRef), ("J", jRef))
});
}
@@ -351,6 +431,179 @@ namespace OpenNest.IO
return block[codeIndex];
}
private static bool TryParseVariableDefinition(string line, out string name, out string expression,
out bool isInline, out bool isGlobal)
{
name = null;
expression = null;
isInline = false;
isGlobal = false;
var trimmed = line.Trim();
if (trimmed.Length == 0)
return false;
// Must start with a letter or underscore (not a G-code letter followed by a digit)
var firstChar = trimmed[0];
if (!char.IsLetter(firstChar) && firstChar != '_')
return false;
// If line starts with a known G-code letter followed by a digit, it's not a variable
if (trimmed.Length >= 2 && char.IsDigit(trimmed[1]))
{
var upper = char.ToUpper(firstChar);
if (upper is 'G' or 'M' or 'N' or 'F' or 'X' or 'Y' or 'I' or 'J' or 'T' or 'S' or 'O' or 'P' or 'R')
return false;
}
// Must contain '='
var eqIndex = trimmed.IndexOf('=');
if (eqIndex < 1)
return false;
// Extract name (everything before '=', trimmed)
var rawName = trimmed.Substring(0, eqIndex).Trim();
// Validate name: must be identifier (letter/underscore followed by alphanumeric/underscore)
if (rawName.Length == 0 || (!char.IsLetter(rawName[0]) && rawName[0] != '_'))
return false;
for (var i = 1; i < rawName.Length; i++)
{
if (!char.IsLetterOrDigit(rawName[i]) && rawName[i] != '_')
return false;
}
// Extract expression and flags from the remainder after '='
var remainder = trimmed.Substring(eqIndex + 1).Trim();
// Check for trailing flags: inline and/or global
// Parse from the end to separate expression from flags
var words = remainder.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var flagStart = words.Length;
for (var i = words.Length - 1; i >= 0; i--)
{
var word = words[i].ToLowerInvariant();
if (word == "inline" || word == "global")
flagStart = i;
else
break;
}
// Build expression from non-flag words
var expressionParts = words.Take(flagStart).ToArray();
if (expressionParts.Length == 0)
return false;
expression = string.Join(" ", expressionParts);
// Parse flags
for (var i = flagStart; i < words.Length; i++)
{
var word = words[i].ToLowerInvariant();
if (word == "inline") isInline = true;
else if (word == "global") isGlobal = true;
}
name = rawName;
return true;
}
private static Dictionary<string, double> ResolveVariables(
Dictionary<string, (string expression, bool inline, bool global)> variableDefs)
{
if (variableDefs.Count == 0)
return new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
// Build dependency graph
var dependencies = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in variableDefs)
{
var deps = new List<string>();
var expr = kvp.Value.expression;
for (var i = 0; i < expr.Length; i++)
{
if (expr[i] == '$')
{
var start = i + 1;
while (start < expr.Length && (char.IsLetterOrDigit(expr[start]) || expr[start] == '_'))
start++;
var refName = expr.Substring(i + 1, start - i - 1);
// Find the canonical name (case-insensitive match)
var canonical = variableDefs.Keys
.FirstOrDefault(k => string.Equals(k, refName, StringComparison.OrdinalIgnoreCase));
if (canonical != null)
deps.Add(canonical);
i = start - 1;
}
}
dependencies[kvp.Key] = deps;
}
// Topological sort (Kahn's algorithm)
var inDegree = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var name in variableDefs.Keys)
inDegree[name] = 0;
foreach (var kvp in dependencies)
{
foreach (var dep in kvp.Value)
{
if (inDegree.ContainsKey(dep))
inDegree[kvp.Key]++;
}
}
var queue = new Queue<string>();
foreach (var kvp in inDegree)
{
if (kvp.Value == 0)
queue.Enqueue(kvp.Key);
}
var resolved = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
var order = new List<string>();
while (queue.Count > 0)
{
var current = queue.Dequeue();
order.Add(current);
// Evaluate this variable
var expr = variableDefs[current].expression;
var value = ExpressionEvaluator.Evaluate(expr, resolved);
resolved[current] = value;
// Reduce in-degree of dependents
foreach (var kvp in dependencies)
{
if (kvp.Value.Contains(current, StringComparer.OrdinalIgnoreCase))
{
inDegree[kvp.Key]--;
if (inDegree[kvp.Key] == 0 && !order.Contains(kvp.Key))
queue.Enqueue(kvp.Key);
}
}
}
if (order.Count != variableDefs.Count)
throw new InvalidOperationException("Circular dependency detected among variables.");
return resolved;
}
private static Dictionary<string, string> BuildVariableRefs(params (string axis, string varRef)[] refs)
{
Dictionary<string, string> result = null;
foreach (var (axis, varRef) in refs)
{
if (varRef != null)
{
result ??= new Dictionary<string, string>();
result[axis] = varRef;
}
}
return result;
}
public void Close()
{
reader.Close();
@@ -374,6 +627,8 @@ namespace OpenNest.IO
public string Value { get; set; }
public string VariableRef { get; set; }
public override string ToString()
{
return Id + Value;

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