Compare commits

102 Commits
Author SHA1 Message Date
695ccc0a3b perf(engine): use cached triangulations in the layout check
The benchmark validator and every engine test re-triangulated both parts
for each nearby pair. NestLayoutCheck now uses TriangulatedRegion, with
Collision.HasOverlap as the fallback when it cannot decide. Verdicts are
unchanged (the frozen-validator equivalence tests still pass); validating
100 discs went from 1,254 ms to 94 ms.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:08:36 -04:00
98d0124172 perf(core): add cached-triangulation collision and an edge-grid prefilter
Collision.HasOverlap re-triangulates both polygons on every call; Qwen
measured that as its dominant cost (over 400 s -> ~110 s on a 219-part
job once cached). TriangulatedRegion (from Qwen's TriSet) triangulates a
part once and takes translation as a parameter; it returns null when it
cannot decide so callers fall back to Collision, which stays the
reference. EdgeGridPolygon (from Qwen's FastPoly) certifies clearly
disjoint shells and never reports Clear for an overlap. A seeded harness
of 100,000 decisions (concave shapes, arcs, holes, touching contacts)
finds 0 mismatches against Collision.HasOverlap.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:08:36 -04:00
4161e6d1c7 feat(core): add a concave no-fit polygon to NoFitPolygon
Core only had a convex NFP, so Opus55 and Gpt6Astra each built concave
NFPs from Clipper's Minkowski sum, and only Opus55 added the terms that
cover one part lying inside or swallowing the other - Gpt6Astra instead
filled every positive path and lost real interlocks. NoFitPolygon.Compute
ports Opus55's construction (boundary sweep united with A + p0 and
-B + a0; convex pairs use the linear edge merge). It works on filled
perimeters only; hole-aware clearance stays with collision testing.
Tests port Opus55's NFP tests and add a notch fit and a seeded property
check against Collision.HasOverlap.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:08:36 -04:00
896ed2026a fix(engine): leave scribe marks out of layout bounds and salvage
The job runner already checked sheet bounds on material contours only,
but the benchmark validator and salvage scoring used Part.BoundingBox,
which includes scribe/etch moves. A PEP bend tick that ends a hair past
the part's edge passed the runner yet failed the benchmark when placed
flush to the sheet edge, and it could shrink the credited offcut. Marks
only mark the surface, so bounds and salvage now use material only.

Benchmark before/after (all five built-in engines, local fixtures,
salvage 0.5): no job changed validity or cost. Regression tests pin the
new rule: a protruding tick flush to the sheet edge is valid in all four
quadrants, and a tick past the parts envelope no longer shrinks salvage
(targeted fixture cost 130 -> 120).

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:56:49 -04:00
7f63c725e6 refactor(engine): expose the layout validation contract to engines
Engines had to reverse-engineer the benchmark validator: Opus55 assumed a
0.01 arc tolerance (the validator uses 0.001), Gpt6Astra added hand-tuned
paddings and copied the validator's check order, Qwen picked its chord
tolerance to stay under a constant it could not reference.

NestTolerances publishes the validator's arc tolerance, the Clipper grid
and SafeClearanceMargin (with its derivation). NestLayoutCheck moves the
benchmark NestValidator's checks into OpenNest.Engine as a public API
(Clears for a part pair, Violations for a whole result); NestValidator is
now a thin wrapper. Verdicts are unchanged: tests compare ordered
violation lists against a frozen copy of the old validator, and a
tangent-disc stress test covers 432 pairs at the safe margin.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:54:54 -04:00
ec5f57171f feat(engine): add RotationPolicy.EnumerateAngles and RotationCandidates
All three plugin engines turned a RotationPolicy into trial angles by hand
(fixed angle, stepped sweep, or right angles plus the minimum-bounding-
rectangle angle for Automatic), each with its own normalization, dedup and
sweep caps. EnumerateAngles gives one deterministic, Allows-checked list;
RotationCandidates.ForShape adds the MBR-aligning angles via the existing
Polygon.FindBestRotation, and DistinctOutlines drops angles where the part
looks identical. A cap of one returns the sweep start rather than throwing,
since engines request a single sample for small orientation budgets.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:33:52 -04:00
dd1a958f5c feat(engine): expose NestJobCost, the benchmark's scoring
Engines optimized guesses at the benchmark cost: Opus55 re-implemented
salvage credit, Qwen used plate area per part area, Gpt6Astra ignored
salvage. NestJobCost moves StockLadder's EstimateNetArea into a public
home (net sheet area, unplaced-part penalty, whole-result Evaluate) and
the benchmark and StockLadder now call it. Scores are unchanged: tests pin
it against a frozen copy of the old computation and real benchmark runs.
Bounds still include marks, as before, so scores do not move.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:12:07 -04:00
ae2b0beb45 feat(engine): expose JobPartGeometry for reading snapshot material
Every plugin engine rebuilt part material from a snapshot by hand and
filtered only rapids, so all three kept counting scribe/etch marks as
material after 1b5e1b1 fixed it in the host. JobPartGeometry is the
validator's own reader made public: SpecialLayers.IsMaterial, validated
closed contours, material area, and TryRead returning null for unreadable
parts. The job validators now use it, so engines and validation agree.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:12:07 -04:00
ajandClaude Opus 5.5 6ab45e6de7 test(engine): seed spacing brute-force sampling deterministically
The seed came from string.GetHashCode, which .NET randomizes per process,
so each run drew different samples and the "ring" case occasionally drew
fewer than six rejections and failed its coverage assertion (1 in 6 runs),
even though every validator decision matched the brute-force reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:12:07 -04:00
029299ccf8 feat(engine): add NestJobResultBuilder for engine result assembly
Engines assembled NestJobResult by hand - instance indices, fulfillment,
stock usage, status and PlateCommitted progress - and Qwen38FlashNext got
PlateIndex wrong (stock index instead of sheet order). The builder assigns
plate and instance indices itself and rejects overproduction and exhausted
stock, so engines only decide placements.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:55:10 -04:00
4cdb39870b feat(engine): expose NestPlateStock.WorkArea, Area and Fits
Every plugin engine re-derived the quadrant/edge-spacing work area by hand
(Gpt6Astra, Opus55 and Qwen each had a copy, as did the placement
validator). One definition on the stock removes that duplication and the
chance of an engine disagreeing with the validator's bounds.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:55:10 -04:00
ajandCodex 95833236cf docs(engine): clarify priority so scarce stock favors lower numbers
Document constructor and property semantics and cover priority zero versus nine on one sheet.

Co-Authored-By: Codex <noreply@openai.com>
2026-09-25 07:53:16 -04:00
ajandClaude Opus 5.5 1b5e1b14a6 fix: leave etch/scribe marks out of nesting geometry
Every nesting-geometry consumer filtered only rapids, so scribe/etch
moves counted as part material. An etch tick that ends a hair outside
the outline (PEP bend ticks start on the notch edge) made the part
"open geometry leaving the material region": the job validator threw
and every built-in engine plus Gpt6Astra crashed on real PEP jobs
(PT75, drawing 4980 A01 PT77). Marks are only on the surface, so they
should never affect placement, collision, area, or validation.

- SpecialLayers.IsMaterial excludes Rapid and Scribe; used by drawing
  area, canonical angle, part collision, PartGeometry, plate perimeter,
  best-fit/pair evaluation, rotation analysis, GPU evaluators, and both
  validators. Timing, display, splitting and posts still see marks.
- ConvertGeometry also maps the saved SCRIBE layer name to Scribe, so
  programs rebuilt from stored entities keep their marks.
- NestReader repairs older files (e.g. PepNestExport output) whose
  programs saved etch as cut moves while source entities kept SCRIBE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:44:29 -04:00
ajandClaude Opus 5.5 e3d10e95ae chore: move plugin engines to the OpenNest-Engines repo
Engines now live in https://git.thecozycat.net/aj/OpenNest-Engines (history
carried over) so they can be published independently, and so a copy of
OpenNest handed to a model for an engine-building run contains no
competing engines. Engines still load at runtime from an Engines/ folder
next to the app/benchmark output; nothing in the solution referenced them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:02:29 -04:00
aj 22d21924b8 feat(astra): add independent nesting engine and fix hole validation
Replace the Terra scaffold with an independent configuration-space contact placer and bounded stock-plan search. Include plugin tests, synthetic and DXF benchmark drivers, results, and deployment documentation.

Correct shared collision clipping and hole subtraction so curved-hole inserts validate consistently. Cover translated layouts, spacing violations, operand order, winding, and independent boolean-area comparisons.

Validation: 1,293 tests passed with 12 fixture skips; all 34 synthetic/generated and four DXF cases are valid and complete.
2026-09-24 00:14:36 -04:00
ajandClaude Sonnet 5 87f7ddad1a docs(engines): allow fill/pattern components in Terra and Qwen scaffolds
Reusable Fill/, BestFit, RectanglePacking and CirclePacking components are
fair game; whole-engine delegation and run-all-pick-best stay banned.
Improvements to shared components go in the engine's own project and are
reported, not applied to OpenNest.Core/OpenNest.Engine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 19:15:19 -04:00
aj 23fcec4b6c docs: compress README to a skimmable overview
Drop deep engine/validator/benchmark internals (covered by CLAUDE.md and
code comments) and per-option reference tables in favor of a quick-start
level guide.
2026-09-23 17:37:07 -04:00
aj d6d819bfed chore: remove tools directory
One-off helper utilities don't belong in the repo. PepNestExport moved to
/home/aj/src/PepNestExport as a standalone companion project; NestDxfJob and
StreamGravographJob are removed (recoverable from history).
2026-09-23 15:15:45 -04:00
aj c45909e397 Merge branch 'perf/fill-pipeline' 2026-09-23 14:30:46 -04:00
ajandClaude Opus 5.5 1c363504f5 perf(engine): key fill caches by source drawing so they hit across trials
Every DefaultPlateFiller.Fill makes a fresh canonical copy of the drawing,
and BestFitCache/FillResultCache keyed by drawing reference, so fills never
shared results and the static caches grew without bound.

CanonicalFrame now records which drawing each canonical copy came from.
Both caches key weakly on that source drawing, so every canonical copy
shares one entry and released drawings can be collected. An entry is
dropped when the drawing's Program instance or canonical angle changes.
Best-fit candidates are computed once per (drawing, spacing) through
BestFitFinder.FindCandidates and filtered per plate size with the same
filter FindBestFits uses. FillResultCache keeps canonical and
non-canonical callers apart.

Adds Debug-only PerfCounters for best-fit runs, offset perimeter builds
and Part.Intersects calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:29:43 -04:00
ajandClaude Opus 5.5 dc9e83ef17 perf(jobs): flatten validator contours once, at a 0.001 chord tolerance
The candidate validator flattened every arc into 1000 segments and rebuilt
both parts' polygons and edge lists for every pair it compared, so spacing
checks on filleted parts cost millions of edge pairs each. Validation, not
the fill pipeline, was nearly all of a solve's wall time.

Placed contours are now flattened once with ToPolygonWithTolerance(0.001),
the tolerance the benchmark NestValidator and Part.Intersects already use,
and each part's shape is built once per candidate. Arcs stay inscribed, so
a layout placed exactly at the spacing still passes.

12-nest PEP corpus, Default + StockLadder, --parallel 1: 2820 s -> 227 s.
Every run that finished before gives the same validity, count, plates and
cost. Three StockLadder runs that used to hit the 5-minute timeout now
finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:29:43 -04:00
aj 1a7ed3c759 Merge branch 'refactor/engines-subfolder' 2026-09-23 11:42:39 -04:00
ajandClaude Opus 5.5 b122298f57 refactor(engines): move plugin engines into an Engines/ subfolder
Plugin engines (Opus55, Qwen, Terra) each add two projects at the repo
root, and more are coming; at a dozen they would outnumber the core
projects. They are also a different kind of thing: out-of-solution,
runtime-loaded plugins. Grouping them under Engines/ keeps the root
readable.

Engines/Directory.Build.props now holds the shared TFM, nullable and
implicit-usings settings and the OpenNest.Engine reference, so a new
engine's csproj is nearly empty. The tests/ compile exclusion lives in
Directory.Build.targets because a removal in .props runs before the SDK
adds its default Compile glob and has no effect.

Build-Engines.ps1 replaces the per-README manual build-and-copy steps
for deploying engines into the benchmark's runtime Engines/ folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:34:18 -04:00
ajandClaude Opus 5.5 2ecd94c705 Merge branch 'feat/pep-nest-export'
Opus55 NFP nesting engine and the PepNestExport tool for benchmarking
against PEP layouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:09:17 -04:00
ajandClaude Opus 5.5 26c17a386b Merge branch 'refactor/clipper-geometry'
Spacing offsets move onto Clipper (ClipperBridge) for CPU preparation,
fixing spikes and inverted loops where features are narrower than the
spacing; Collision stays hand-rolled for the GPU path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:09:17 -04:00
ajandClaude Opus 5.5 4dd00c14c4 feat(tools): PepNestExport converts PEP nests into benchmark .nest files
Converts a PepApi year of PEP nests into .nest files that keep PEP's own
placements, so OpenNest.Benchmark can score PEP as its Baseline row.

Micro-joint tabs: PEP leaves tabs uncut by jumping them with a rapid, at a
contour's seam or partway along it (a cutout cut as two halves 0.02 apart).
The part still occupies that material, so open cut runs are chained end to
start across gaps up to 0.25 and bridged with a cut line, but only where
they close into a loop, so separate contours that lie close together (two
circles 0.25 apart) are never merged. Previously only seam tabs were closed,
leaving mid-contour tabbed cutouts open and the validator reading them as
garbage regions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:09:06 -04:00
ajandClaude Opus 5.5 5064340eb3 fix(benchmark): validate spacing at a 0.001 flattening tolerance
Conservative flattening circumscribes arcs, so at 0.01 the validator was up
to 0.01 too strict along curves: PEP's P260626-03 layout, exactly 0.25 apart
along an arc, failed with a 0.004 sliver. At 0.001 the worst error on either
side is 0.001. Validating the 26-job benchmark set with Opus55 went from
about 2 s to about 5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:08:58 -04:00
ajandClaude Opus 5.5 062c7fa08f fix(geometry): tighten circumscribed flattening; stop padding the validator
The Clipper validator flagged valid Opus55 and PEP layouts (P260805-03,
P260626-03). Two causes:

- Arc.ToPoints(circumscribe) scales every vertex out by 1/cos(step/2),
  endpoints included, so a 0.03125 corner fillet flattened at 0.01 poked
  0.013 past the straight edges it meets. ClipperBridge now flattens itself:
  circumscribed arcs keep their endpoints on the arc and put interior
  vertices on tangent intersections, with the segment count chosen so the
  outward error stays within the tolerance. Arc.ToPoints is unchanged for
  its other callers.
- The conservative padding made a layout exactly at the spacing fail.
  NestValidator now uses OffsetForValidation: the same conservative
  flattening, round joins at a tenth of the tolerance, no padding. Its only
  leniency is that join chord error at convex corners.

With both, Opus55 is valid on all 26 benchmark jobs (25 before the
Clipper migration; the old failure was a spike artifact).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:40:59 -04:00
ajandClaude Opus 5.5 9af97c70b0 docs: document ClipperBridge, Clipper2 dependency and GPU-portable Collision
Spacing offsets now go through Clipper for CPU preparation while the
per-pair Collision test stays hand-rolled for a future GPU port; record
that split, which offset path each caller uses, and the missing Clipper2
entry in the NuGet list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:43:42 -04:00
ajandClaude Opus 5.5 01789c5929 fix(geometry): harden the arc-preserving per-entity offset
GetOffsetPerimeterEntities/GetOffsetPartEntities feed directional-distance
loops (FillLinear, Compactor, RotationSlideStrategy) that handle arcs
natively. Switching them to Clipper line output (plan option B) made
OpenNest.Tests run 48s -> 8m19s, Fill tests ~3x slower, and broke 20
exact-fit tests through tessellation and conservative padding, so they keep
the per-entity offset (option A), hardened:

- Arc, Circle and Line offsets are now side-symmetric. Right on a CCW arc
  shrank instead of growing, Right on a CW circle grew, and Right on a line
  offset to the left and reversed it. Only Left was used on hot paths, so
  this was latent (SimplifierViewer drew both tolerance bands on one side).
- Shape.OffsetEntity closes every gap between consecutive offset pieces:
  convex non-tangent line/arc corners get a round join about the original
  corner, lines across a collapsed fillet are mitered, and any other gap
  (concave arc corner, collapsed entity) is bridged with a line. Before,
  only line-line corners were joined, so a vertex could slip through.
- Zero-area spikes are left in place and documented: they lie inside the
  offset envelope, which is harmless for directional distance.
- OffsetOutward/OffsetInward become internal; PartGeometry is their only
  caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:43:02 -04:00
ajandClaude Opus 5.5 dceb5f7d18 test(geometry): cover Collision with ClipperBridge inputs; document GPU contract
Collision stays hand-rolled because it is the reference for a future GPU
kernel, but its inputs now come from Clipper region offsets. Pin down that
lines-only, round-join, 1e-4-precision polygons keep the contact and
part-in-part semantics: a neighbor inside a collapsed slot, a part inside
a hole that shrank by the spacing, and zero-spacing edge contact.

Document which steps are per-polygon preparation to cache and upload once,
and which are per-pair kernel-shaped work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:25:26 -04:00
ajandClaude Opus 5.5 10fe00d8ab refactor(geometry): delete RemoveSelfIntersections
Its callers now take Clipper region offsets, which never produce the
self-intersections it patched over (and it only caught proper crossings,
so spikes survived it anyway). Polygon.OffsetEntity was its last caller;
the override is required by Entity but has no callers, so it becomes a
Clipper miter offset that keeps the Left/Right semantics and the input
winding. FindCrossing, SplitAtCrossing, SegmentsIntersect and the static
CalculateArea helper go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:24:37 -04:00
ajandClaude Opus 5.5 9b9386b029 refactor(geometry): move polygon offset callers onto ClipperBridge
Per-entity offsetting left spikes and inverted loops wherever a feature is
narrower than the spacing (1.nest), and RemoveSelfIntersections only
caught proper crossings. The callers that already flatten to polygons now
take a single Clipper region offset instead:

- PolygonHelper (BestFit) and PartBoundary use the conservative mode, which
  keeps their never-under-estimate guarantee. PartBoundary also keeps holes
  that appear when a perimeter curls back on itself.
- NestValidator offsets perimeter and cutouts in one region; Clipper drops
  collapsed cutouts, so the collapsed-or-flipped heuristic goes away.
- CutOff.IntersectPerimeter offsets through the bridge. The old
  OffsetEntity(Left) grew CW perimeters but shrank CCW ones, so with the
  plate's perimeter cache a cut-off ran through the part; slots narrower
  than twice the clearance now close up instead of leaving a gap.
- GetOffsetPartLines (3 overloads) and the AddOffset* helpers had no
  callers and are removed, as is EntityView's never-defined DRAW_OFFSET
  block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:21:48 -04:00
ajandClaude Opus 5.5 12f97474b7 refactor(geometry): remove dead concave NFP path
NoFitPolygon.Compute, its triangulate-and-union MinkowskiSum branch and
UnionPolygons had no callers; only ComputeConvex (NfpSlideStrategy) is used.
The Clipper path helpers they relied on now live in ClipperBridge.
ConvexDecomposition.Triangulate stays because Collision uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:13:59 -04:00
ajandClaude Opus 5.5 a6bc9d8be6 feat(geometry): add ClipperBridge for region offsetting
Offsetting entity by entity leaves spikes and inverted loops wherever a
feature is narrower than the spacing, and RemoveSelfIntersections only
catches proper crossings. ClipperBridge flattens a ShapeProfile into one
region (perimeter positive, cutouts negative) and inflates it in a single
Clipper pass with round joins, so narrow features collapse and holes that
close up disappear.

Conservative mode circumscribes perimeter arcs, inscribes cutout arcs and
pads the inflation by the chord tolerance, so the result never
under-estimates the spacing. It replaces the circumscribed-polygon
guarantee the BestFit/PartBoundary callers rely on.

Clipper stays confined to CPU preparation whose output is cached; the
per-pair Collision path remains hand-rolled for GPU portability.
LayoutPart's display offset now goes through the bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:12:34 -04:00
ajandClaude Opus 5.5 7964c87eb9 fix(geometry): fit arc centers to endpoints; Clipper offset for spacing display
PEP-exported programs carry arc centers that are not equidistant from the
start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from
the end radius left its start off the previous move's end, so contours
failed to chain. Project the center onto the chord's perpendicular bisector.

The Draw Offset display offset each entity separately, which left spikes and
inverted loops wherever a feature is narrower than the spacing (1.nest,
P260417-06). Inflate the flattened region with Clipper instead, which
collapses narrow features and drops holes that close up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:56:15 -04:00
ajandClaude Opus 5.5 45a59c6b02 feat(engine): Opus55 frontier-advance NFP nesting engine
Rename the OpenNest.Engine.Sonnet5 scaffold to OpenNest.Engine.Opus55 and
implement an independent whole-job INestingEngine (no built-in engine,
registry, or best-fit internals are called or copied).

- PartCatalog: snapshot perimeter -> polygon per allowed orientation, with
  adaptive chord tolerance and MBR-aligned rotations for Automatic parts.
- NoFitCache: spacing footprints and cached Clipper2 Minkowski NFPs (convex
  fast path; concave sweep plus both containment terms).
- FrontierPacker: per-(type, orientation) free regions (inner-fit rectangle
  minus NFPs), updated incrementally; gap-fill-largest, else least front
  advance per area^beta.
- Engine: look-ahead stock choice by estimated whole-job net area, six
  deterministic variants, tail re-plan of the last 1-3 sheets.
- Tests judged by OpenNest.Benchmark's NestValidator, including an NFP
  containment regression guard.

P260805-10.nest (219 parts), all 9 stock sizes: 219/219 valid, 27 sheets,
91.7% utilization, ~7 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 05:10:50 -04:00
ajandClaude Sonnet 5 1c8305e8a1 feat(engine): scaffold independent nesting engine plugins for Sonnet5, Terra, Qwen
Three standalone INestingEngine plugin projects (Solve() unimplemented,
throws NotImplementedException), each outside OpenNest.sln and discovered
at runtime via NestingEngineRegistry.LoadPlugins, same pattern as
OpenNest.Engine.Aurora. Each README spells out that Solve() must be an
independently designed placement algorithm and must never call/wrap/select
over the built-in engines (StockLadderNestingEngine, FixedStrategyNestingEngine,
PlateNesterFactory, NestingEngineRegistry).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:06:47 -04:00
aj 828500f984 chore: replace CSharpier with dotnet format 2026-09-22 16:45:54 -04:00
aj 8e4735d25d [verified] add benchmark baseline and rotation fixes 2026-09-22 16:35:25 -04:00
aj 5bbb7b7461 Merge branch 'fix/benchmark-scoring' 2026-09-22 15:06:02 -04:00
ajandClaude Opus 5.5 57e9f625b6 fix(benchmark): rank by sheet cost so engines can't game the score
The benchmark is about to be used as the objective for LLM-designed
engines, and several gaps would have rewarded the wrong behavior:

- Ranking was utilization-first, so dropping awkward parts raised the
  score. Rank valid > fully placed > cost > plates, where cost is
  salvage-credited sheet area plus a largest-sheet penalty per unplaced
  part; placing a part is never scored worse than omitting it.
- Salvage rate was ignored in scoring; cost now uses EstimateNetArea,
  recomputed from job geometry rather than trusted from the engine.
- Rotation constraints were never validated. Add RotationPolicy.Allows
  (shared with NestJobPlacementValidator) and check every placement.
- Returned sheets were trusted, so an engine could loosen spacing or
  invent a size. Sheets must now match offered stock.
- Part-in-part placements were flagged as overlaps; spacing now accounts
  for cutouts, with an X-sorted sweep to prune distant pairs.
- Summary averaged per-job percentages; it now sums areas and cost.
- --spacing and sheet sizes parsed with the current culture.
- Warn when .nest jobs offer only their original sheet sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:05:43 -04:00
aj e65f849c7a refactor(engine): remove legacy nesting engine surface 2026-09-22 14:25:46 -04:00
ajandClaude Sonnet 5 98c4929d14 Merge branch 'fix/pairs-overlap'
Fixes Part.Clone() double-counting baked drawing rotation, which caused
overlapping placements when tiling interlocking pairs for drawings needing
canonical-frame axis correction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:46:17 -04:00
ajandClaude Sonnet 5 014c071716 fix(core): stop Part.Clone from double-counting baked drawing rotation
Part.Clone() rebuilt the clone from BaseDrawing and then re-applied the
part's absolute Rotation on top of it. Since BaseDrawing.Program.Rotation
is itself absolute (baked in), this double-counted it whenever the base
drawing already carried a nonzero rotation, silently corrupting the
clone's orientation while its Location stayed unchanged.

This only manifests for drawings needing canonical-frame axis correction
(nonzero Source.Angle), since DefaultPlateFiller wraps every drawing in a
rotated canonical copy before running any fill strategy. FillHelpers.
BuildRotatedPattern clones parts before tiling, so any strategy that
tiles interlocking pairs (Pairs, Strip/Remnant, Column/Row) could produce
overlapping placements for such drawings.

Fix: clone the already-composed Program directly instead of re-deriving
rotation from BaseDrawing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:46:11 -04:00
ajandClaude Sonnet 5 da1f8120f1 Merge branch 'fix/bestfit-overlap-test'
Fixes BestFit overlap check to agree with the geometry BuildParts
actually places, resolving BestFitOverlapTests.KeptPairs_NoOverlap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:58:46 -04:00
ajandClaude Sonnet 5 0512f3f84a fix(engine): check BestFit overlap in the same frame BuildParts places
PairEvaluator checked overlap on the raw candidate geometry before
applying the pair's OptimalRotation, but BestFitResult.BuildParts (and
everything downstream) rotates both parts by -OptimalRotation before
placing them. Re-tessellating a rotated arc at the overlap chord
tolerance samples different chord points than rotating an
already-tessellated polygon, so a few tangent-corner candidates came
out overlap-free in the raw frame but overlapping once actually
placed.

Move the landscape-normalization step before the overlap check and
rotate part1/part2 the same way BuildParts does before tessellating
and running Collision.HasOverlap, so Keep agrees with the geometry
that's actually placed. Fixes
OpenNest.Tests.BestFit.BestFitOverlapTests.KeptPairs_NoOverlap (was
failing 3/1082 candidates).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:57:40 -04:00
aj b14cb9d10a style(io): apply consistent formatting and remove unused usings
Whitespace/indentation cleanup (formatter pass) plus unused using
directives. No behavior change.
2026-09-22 09:51:18 -04:00
aj a0417a6077 fix(ui): avoid double-counting baseline rotation in part label placement
_labelPoint is computed from BaseDrawing.Program's current geometry,
which already carries BaseDrawing.Program.Rotation (non-zero for
canonical-frame drawings). BasePart.Rotation is cumulative from that
same baseline, so it must be re-applied net of the baseline already
baked into _labelPoint, mirroring CanonicalFrame.RebindToOriginal.
2026-09-22 09:51:15 -04:00
aj 56f39556d1 feat(engine): rebind live fill previews to the drawing's original frame
DefaultPlateFiller runs its search in a canonical (MBR-axis-aligned)
copy of the drawing. Intermediate progress reports — the Nesting
Progress dialog, PlateView's active-parts overlay — were showing that
transient canonical orientation instead of the drawing's real one.

FillContext.OriginalDrawing carries the pre-canonicalization drawing
through the pipeline; ReportProgress rebinds reported parts to it via
CanonicalFrame.RebindToOriginal before they reach the UI. Uses a
shallow list copy rather than per-part Part.Clone() — Clone() re-derives
its target rotation from BaseDrawing.Program.Rotation + Rotation, which
would double-count the canonical drawing's baked source angle.
2026-09-22 09:51:11 -04:00
aj 86f6c9efa1 refactor(engine): extract PairFiller candidate selection and remnant filling
Splits PairCandidateSelector (strip-mode candidate ranking) and
PairRemnantFiller (leftover L-shaped area fill) out of PairFiller into
their own classes. Pure extraction — logic is unchanged, just relocated
and given dedicated unit boundaries so each piece can be tested and
reasoned about on its own.
2026-09-22 09:51:06 -04:00
aj 6f250b5730 fix(geometry): correct overlap detection for real-world CNC shapes
Part.Intersects has been silently non-functional everywhere it's used.
Shape.ToPolygon()/ToPolygonWithTolerance() never called UpdateBounds(),
so every freshly-built polygon kept Entity's constructor-default
zero-size bounding box regardless of its actual vertices. Collision.Check's
first step is a bounding-box pre-filter, and a zero-size box can never
overlap anything, so it always short-circuited to "no overlap" no matter
what the real geometry looked like.

That masked a second bug: Part.Intersects built its polygons via the
unconditional 1000-segments-per-arc ToPolygon() default instead of an
adaptive tolerance. For parts with several small fillets/holes this
produced tens of thousands of vertices, making the now-correct bbox
check fall through into a triangulation/clip step too slow to return
in practice. Switched to ToPolygonWithTolerance at a named tolerance
matching PartGeometry's existing convention.

PairEvaluator's own Keep/overlap check had a third, independent bug:
it used Shape.Intersects (edge-crossing detection only) at a coarse
0.01 chord tolerance, which misses containment-style overlaps and can
polygonize rounded corners coarsely enough to hide a genuine sliver
overlap. Switched to Collision.HasOverlap (full polygon clip, handles
containment) at a tighter dedicated tolerance.

Verified against a real nest file: PairFiller was tiling a BestFit
pair that PairEvaluator had incorrectly marked Keep=true, producing
visibly overlapping parts on the plate that no downstream overlap
check ever caught.

Known follow-up: OpenNest.Tests.BestFit.BestFitOverlapTests.KeptPairs_NoOverlap
still fails on 3/1082 synthetic candidates that overlap by a sub-0.001
sliver right at a rounded-corner tangent point — a separate, much
smaller precision edge case in PairEvaluator's raw (pre-transform)
coordinate frame, not a regression from this change.
2026-09-22 09:51:02 -04:00
aj c28bc0da21 refactor(engine): select desktop nesting through app-scoped jobs engine
Replace the MainForm/AutoNestForm engine combo bindings and every
desktop NestEngineRegistry call: selection now lives in app-scoped
EngineSelection addressing jobs engines in NestingEngineRegistry, the
combo lists the four built-in strategies (StockLadder stays out per the
frozen UI decision), and Engines/ plug-ins load through
NestingEngineRegistry.LoadPlugins. Whole-job fill routes through the
public PlateFillService with the selected strategy and plate number;
interactive group fill (PlateView) and area fill (ActionFillArea) go
through PlateFillService with identical accept/cancel preview behavior.
Multi-plate and size-search orchestrators receive the explicit strategy.
No desktop code reads or writes process-global engine state.
2026-09-22 00:30:40 -04:00
aj 88966d118c refactor(engine): route console and MCP nesting through named job engines
Console --engine now names a jobs engine for --autonest (solved once
through NestingEngineRegistry.Create and committed onto the plate) or a
built-in fill strategy for single-plate fill through the public
PlateFillService; unknown names exit with the valid choices instead of
consulting the process-global legacy registry. MCP nesting tools take an
explicit engine argument per call with the session default, never read
process-global active-engine state, and reject whole-job engine names on
single-plate fill tools. NestingEngineRegistry gains an explicit Create
(name) resolution; PlateFillService gains a public ResolveStrategy and a
plate-number Nest overload used by interactive callers.
2026-09-22 00:30:03 -04:00
aj f36e124039 refactor(engine): thread explicit placement strategy through multi-plate orchestrators 2026-09-21 20:22:45 -04:00
aj 856dbfd8af refactor(engine): add public plate fill service 2026-09-21 19:51:35 -04:00
aj eb8fbec1aa refactor(engine): make jobs plate nesters filler-backed 2026-09-21 18:08:25 -04:00
aj 073ead9b79 refactor(engine): share jobs placement identity and progress mechanics 2026-09-21 16:36:24 -04:00
aj bb671cb176 refactor(engine): retarget ML runner to plate filler 2026-09-21 15:52:45 -04:00
aj 6bcea7919a refactor(engine): remove unused stripe engine hook 2026-09-21 15:43:15 -04:00
aj ed908b9706 refactor(engine): extract strip plate filler 2026-09-21 15:40:13 -04:00
aj e69ec07830 refactor(engine): extract default and remnant plate fillers 2026-09-21 15:27:03 -04:00
aj eafa0fab01 refactor(engine): extract plate fill orchestration 2026-09-21 15:01:00 -04:00
aj 9b9e510510 refactor(engine): extract progress reporting seam 2026-09-21 14:43:46 -04:00
aj 451841401a refactor(engine): align namespaces with directory layout under OpenNest.Engine
All 132 OpenNest.Engine source files now declare namespaces matching
their nested directories: Jobs/, Jobs/Placement/, Jobs/Adapters/,
Fill/, RectanglePacking/, CirclePacking/, and engine-root types moved
from 'OpenNest' to 'OpenNest.Engine'. RootNamespace updated accordingly.
Consumers (Api, Console, Mcp, Benchmark, Training, desktop app, tests)
gained the explicit usings the move requires; CLAUDE.md updated.
2026-09-21 13:18:34 -04:00
aj fd3375cde6 test(engine): golden-layout parity fixtures for all four strategies
Pin committed fulfillment and exact placement poses for Default, Strip,
Vertical Remnant, and Horizontal Remnant through the production
PlateNesterFactory + NestJobRunner path, plus deterministic poses for
OrderedPlateNester via its StockLadder wiring (no legacy counterpart) and
authoritative progress-stage sequences for StockLadder and
FixedStrategyNestingEngine. Captured at 42bbde7 (post ShrinkFiller axis
fix); these fixtures are the regression net for the legacy-engine removal.

Note: Strip is pinned on a rectangle-variety job. On dense mixed-shape
jobs the iterative shrink path intermittently proposes overlapping
candidates (pre-existing scheduling nondeterminism), so its mixed-geometry
layout is deliberately not pinned.
2026-09-21 12:17:24 -04:00
aj 42bbde7433 Merge branch 'fix/failing-tests-after-master-pull' 2026-09-21 11:07:49 -04:00
ajandClaude Sonnet 5 64d38c452c fix(engine): route restricted-rotation requirements to OrderedPlateNester
The legacy engine reads RotationStart == RotationEnd == 0 as
unconstrained and its Pairs/RectBestFit strategies rotate freely, so it
returned poses a Fixed/BoundedSweep RotationPolicy forbids and the job
validator threw. DefaultPlateNester now delegates such requests to the
policy-aware OrderedPlateNester.

Fixes RunAsync_FiniteStockExhaustion_PreservesUnplacedRequirementAndLockedRotation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:07:45 -04:00
ajandClaude Sonnet 5 630d514b0e fix(engine): don't drop the topmost part when no other drawing is waiting
RemnantFiller removes the topmost placed part to keep a clean rectangular
obstacle for the next drawing, but the envelope then walls that slot off,
so the part was lost for nothing (4 squares on a 9x9 plate became 3).
Only remove it while another drawing still has demand.

Fixes the mixed-stock NestRunner tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:07:45 -04:00
ajandClaude Sonnet 5 a764a70e52 fix(engine): make canonical-frame fills orientation-invariant
Part.Rotation is cumulative, so rebinding canonical parts with
CreateAtOrigin(original, p.Rotation) double-counted the drawing's own
rotation, and FromCanonical rotated each part about its Location, which
moved it off its slot and out of the work area. Add
CanonicalFrame.RebindToOriginal (rotation = part - original program
rotation, footprint aligned to the canonical part) and use it in the
three places that duplicated the old logic.

The MBR only fixes the frame modulo 90 degrees and nest results are not
90-degree symmetric (an L gave 56/43/42/42 parts by orientation).
CanonicalAngle.Compute now picks one of the four orientations from the
centroid offset; symmetric shapes keep the MBR orientation.

Fixes the three NestInvarianceTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:07:44 -04:00
ajandClaude Sonnet 5 2a855139d2 fix(core): stop Program.BoundingBox including the origin
Min/max were seeded at 0, so any geometry not touching the origin got an
inflated box, and the first move only updated max (else-if). Rotated
canonical drawings are the common trigger: their origin ends up outside
the shape, which skewed Part bounds and bbox-based alignment.

Track the real extents and keep returning a zero box for empty programs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:07:44 -04:00
aj f23f0fa566 feat(tools): headless DXF+workbook job verification tool
NestDxfJob imports a DXF folder plus a quantity workbook, runs a
registered whole-job engine, and only publishes a nest that passes
quantity, bounds, overlap/spacing, and cut-only checks both before and
after save/reload. Writes import and validation reports; partial or
invalid results exit nonzero. Standalone project (not in the solution);
documented in the README.
2026-09-21 10:05:55 -04:00
aj 6f38c11383 feat(io): workbook part-quantity reader and cut-only import coverage
PartQuantityReader parses a Parts worksheet (Part Name / Qty Required)
with exact-name matching and strict rejection of invalid, fractional,
negative, or duplicate quantities. Cut-only import drops case-insensitive
ETCH/SCRIBE mark layers before geometry so bend detection cannot
regenerate them. Tests cover both.
2026-09-21 10:05:39 -04:00
aj f34b3c4449 perf(engine): bounds-based short-circuit in placement validation
Bounding-box distance is a conservative lower bound on true contour
clearance, so pairs far apart can skip the polygon Overlaps/Distance
checks without letting an overlap or spacing violation through. Also
gate the per-contour-pair BoundaryDistance work on a running minimum.
Validation is on the hot path for every candidate placement.
2026-09-21 10:05:39 -04:00
aj 365825bc5a fix(engine): shrink the requested axis in ShrinkFiller estimates
ShrinkAxis.Length must shrink the Y extent and ShrinkAxis.Width the X
extent to agree with MeasureDimension/TrimToCount; Box's constructor
takes the X extent first. The estimate previously shrank the wrong
dimension and returned a mis-ordered box. Add a parameterized regression
test over both axes and both translated-remnant orientations.
2026-09-21 10:05:18 -04:00
ajandClaude Sonnet 5 02fc0ea3db chore: remove unused NFP nesting code
Delete OpenNest.Engine/Nfp (AutoNester, BottomLeftFill, NfpCache,
SimulatedAnnealing, INestOptimizer, PlacedPart, SequenceEntry), the Core
InnerFitPolygon, and the NestPhase.Nfp member. None had callers outside the
folder: console --autonest and MCP autonest_plate call engine.Nest(), not
AutoNester.

Drop the Nfp cases from NestPhaseExtensionsTests, fix the --autonest help
text, and update CLAUDE.md. NoFitPolygon stays; BestFit pair evaluation
still uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:20:30 -04:00
ajandClaude Sonnet 5 451876c02f fix(engine): stop PairFiller toggling the global fill-strategy registry
PairFiller.EvaluateCandidates wrapped its candidate loop in
FillStrategyRegistry.SetEnabled(...) and reset it with SetEnabled(null)
afterwards. Nothing inside that window re-enters the strategy pipeline
(EvaluateCandidate and FillRemnantBox only use FillLinear), so the
restriction had no effect on the running solve. It did mutate process-global
state: concurrent solves (e.g. benchmark --parallel) could observe the
narrowed strategy list, and the reset cleared any filter set elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:20:30 -04:00
ajandClaude Sonnet 5 5061b41a5d feat(benchmark): build jobs from DXF manifests and run solves in parallel
Benchmark jobs could only come from .nest files. A JSON manifest now lists
DXF files with quantities (plus sheet sizes, spacing, edge spacing, quadrant
and per-part allowRotation), imported through CadImporter. DXF paths resolve
relative to the manifest; sheet sizes are required from the manifest or
--sheet-sizes and are read in the DXFs' own units. Folder scans pick up
*.nest and *.manifest.json, and invalid manifests fail loudly.

BenchmarkRunner now runs (job x engine) solves concurrently, capped by
--parallel N (CLI default 3; --parallel 1 is sequential). Results are written
by index so report order is unchanged. Concurrent solves compete for cores,
so Time(ms) is only clean at --parallel 1; the run prints a note when N > 1.

Also fixes --output for manifest jobs, which tried to read the manifest as a
.nest to copy metadata from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 21:43:41 -04:00
aj e7cbd99db6 Separate WinForms tests so main test suite runs on Linux 2026-09-20 17:40:06 -04:00
aj 27684c3782 style: apply CSharpier formatting to files merged from arc-tangency branch 2026-09-20 16:54:16 -04:00
aj 54694f9b17 Merge branch 'chore/csharpier-sweep'
# Conflicts:
#	OpenNest.Core/Geometry/ArcFit.cs
#	OpenNest.Core/Geometry/GeometrySimplifier.cs
#	OpenNest.Posts.GravographIS/GravographISWriter.cs
#	OpenNest.Posts.GravographIS/NestPolylineExtractor.cs
2026-09-20 16:53:52 -04:00
aj de1248589a Merge branch 'feat/outer-profile-only'
# Conflicts:
#	CLAUDE.md
2026-09-20 16:52:18 -04:00
aj a9ebd8bb55 chore: add .git-blame-ignore-revs for the CSharpier sweep
Skip the formatting commit in git blame.
Enable with: git config blame.ignoreRevsFile .git-blame-ignore-revs
2026-09-20 16:42:45 -04:00
aj aec0523062 style: apply CSharpier formatting to all C# sources
Repo-wide sweep with the pinned CSharpier 1.3.0 tool. Whitespace and
line-wrapping only; OpenNest.Engine.Tests (109) and OpenNest.IO.Tests
pass after reformat, full solution builds 0 errors.

Added .csharpierignore so csproj/config XML keeps its existing layout
(CSharpier's XML wrapping churns attributes with zero benefit).

Formatting is now enforceable: dotnet csharpier check . passes.
2026-09-20 16:41:50 -04:00
aj 8e6fa677fb chore: add .editorconfig and pinned CSharpier tool manifest
Mirrors CSharpier conventions (4-space indent, Allman braces,
System-first usings, 100-col wraps) so IDE auto-format and
'dotnet format' agree with the canonical formatter.
Usage: dotnet tool restore && dotnet csharpier format .
2026-09-20 16:04:04 -04:00
aj 589d341455 feat(io): conservative opt-in bend repair with tests and console CLI
Add OpenNest.IO/Bending/BendRepair: opt-in repair of unambiguous paired
ETCH/SCRIBE bend ticks, bounded to <=3.175 mm endpoint movement with
explicit source units. Cut geometry is never modified.

- CadImportOptions.BendRepair configures it; CadImportResult exposes
  per-bend BendRepairReports; CadImporter/Dxf wire it into import.
- Console: --repair-bends-mm <limit> --cad-units inches|mm prints
  per-bend reports for newly imported DXFs.
- New OpenNest.IO.Tests project (net8.0, synthetic DXFs, 30 tests)
  covering bend detection and repair, added to the solution.
- Update README.md and CLAUDE.md for the new pipeline and build/test
  instructions.
2026-09-20 15:22:46 -04:00
aj 1a05391d94 fix(engine): reject small corner overlaps in placement validation
The witness-probe overlap test missed small corner intersections: its
candidate points (crossing-edge midpoints and vertex-centroid midpoints)
can all land on a part boundary or outside the intersection, so two 10x10
parts at (0,0) and (9,9) with zero spacing were accepted despite sharing
a 1x1 unit of material.

Route the overlap decision through Collision, which clips triangulated
polygons and keeps only positive-area regions, catching corner overlaps,
containment, and coincident poses while legal edge/corner contact stays
legal. Collision's hole subtraction was conservative (partially-clipped
triangles were kept whole), so a part inside another part's cutout could
false-positive depending on triangulation alignment; subtract holes
exactly instead: a piece outside a convex hole triangle is the union of
its clips against each edge's outside half-space.
2026-09-20 13:40:45 -04:00
aj f5d27652f4 Merge branch 'fix/simplifier-arc-tangency'
Arc-tangency fitting fix in GeometrySimplifier/ArcFit plus layered
engrave/cut passes for the GravographIS post processor.
2026-09-19 12:02:08 -04:00
aj ea4bd836cd Add tested caller-stock StockLadder baseline with strict geometry validation 2026-09-19 11:24:36 -04:00
ajandClaude Sonnet 5 9b69c67572 fix(engine): use required spacing, not a sampled gap, when resequencing shrink-fill strips
SortStrips measured the gap between only the first two strips in original
placement order and replayed that single value between every strip after
reordering by height/width. Real (non-uniform) geometry produces varying
inter-strip gaps, so resequencing could expand the total footprint beyond
the plate's already-fitted work area, crashing StripPlateNester with
"Candidate placement falls outside the usable stock area." Using the
actual required spacing guarantees the resequenced span never exceeds
the original, since real gaps are always >= spacing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:04:33 -04:00
ajandClaude Sonnet 5 aa88eee484 fix(benchmark): use reference-based drawing identity in NestValidator, fix duplicate-sheet-size crash, document Engines/ plugin contract
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:44:09 -04:00
ajandClaude Sonnet 5 e0e3b96bed fix(benchmark): match drawing identity across materialization boundary in NestValidator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:29:48 -04:00
aj 424ff15ebc docs: describe INestingEngine-based benchmark comparison 2026-09-19 08:25:57 -04:00
aj 9888fe6083 feat(benchmark): switch CLI to NestingEngineRegistry and its Engines/ plugin directory
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 08:23:31 -04:00
ajandClaude Sonnet 5 a2dcfc7484 refactor(benchmark): drive engines through INestingEngine.Solve instead of a hand-rolled multi-plate loop
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:19:08 -04:00
aj ae704478af feat(engine): add NestingEngineRegistry for whole-job INestingEngine plugin discovery 2026-09-19 08:15:40 -04:00
ajandClaude Haiku 4.5 ecca71e185 feat(engine): add FixedStrategyNestingEngine adapting IPlateNester strategies to INestingEngine
Implements a sealed adapter class that forces a fixed IPlateNester strategy onto
any NestJob, overriding the job's own PlacementStrategy while preserving MaxPlates.
Delegates all multi-plate allocation and stock selection to NestJobRunner.

This allows single-plate nesting strategies to compete as full whole-job
INestingEngine solvers in benchmarks, enabling comparative performance testing
of placement algorithms across various job configurations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 08:12:10 -04:00
aj f0fe79f0f1 Merge remote-tracking branch 'origin/master' 2026-09-19 07:44:40 -04:00
aj a9e0f8a1d4 Rework OpenNest.Benchmark into a full multi-plate, multi-size nest
Previously each job fixed one plate size and ran a single Nest() call,
which doesn't reflect the actual problem: a real job is fulfilled
across however many plates are needed, drawn from a pool of standard
sheet sizes, not forced onto one fixed sheet.

NestEngineBase.Nest() has no way to pick its own plate's size - it
fills whatever Plate it's given - so size selection now lives in the
harness itself, applied identically to every engine:

- BenchmarkJob carries the full candidate size pool (CandidateSizes)
  instead of one fixed PlateSize; one job per file, not one per size.
- BenchmarkRunner drives a loop: while items remain, pick the smallest
  candidate size that fits the largest still-unplaced drawing (reusing
  the codebase's own MultiPlateNester.CreatePlate/FitsBounds), build a
  fresh plate of that size, and run one Nest() call to fill it. Repeat
  until everything is placed, no candidate size fits what's left, or a
  safety cap (40 plates) is hit.
- NestValidator now validates bounds/spacing per plate but the
  quantity cap once globally across all plates, since that limit
  belongs to the whole order, not any one sheet.
- JobResult/Report report PlatesUsed and a per-size breakdown instead
  of a single-plate bounding-box compactness metric; utilization is
  now aggregated across every plate the engine used. Ranking keeps the
  same rule (utilization first), with fewer plates as the tie-break
  when both are fully placed and tied - the natural multi-plate
  analogue of the old single-plate compactness tie-break.

Smoke-tested against the synthetic sample across 5 candidate sizes:
correctly builds one job, picks the smallest fitting size, uses
however many plates each engine needs (1-2 here), and still catches
StripNestEngine's pre-existing out-of-bounds bug.
2026-09-15 21:36:31 -04:00
aj 20da5477b6 Fix NestValidator: add area-budget backstop, cheaper polygon conversion
Collision.HasOverlap (and Part.Intersects, which shares the same
underlying algorithm) was observed to return false negatives on real,
complex production geometry: a layout with a combined placed area over
7x the plate's work area passed the polygon-based spacing/overlap
check with zero flagged pairs. This is a pre-existing gap in
OpenNest.Core's Collision detection, not something introduced here,
but it let an obviously-invalid layout score as "valid".

ValidateAreaBudget adds a hard mathematical backstop that does not
depend on Collision at all: non-overlapping parts confined to the work
area can never have a combined area greater than the work area itself.

Also switch WorldPolygon from Shape.ToPolygon() (default up to 1000
segments per arc) to ToPolygonWithTolerance(0.01), matching the
convention already used elsewhere in the codebase (e.g.
BestFit.PolygonHelper) - arc-heavy real parts were producing
thousands-of-vertex polygons for a simple spacing check.
2026-09-15 20:54:12 -04:00
aj 6a0fba0fec Add OpenNest.Benchmark: generic head-to-head engine comparison harness
Loads any .nest file (or folder of them) via NestReader and nests every
drawing with quantity > 0 using each registered NestEngineBase, so it
works sight-unseen against arbitrary real jobs without any hardcoded
geometry. Optionally sweeps a fixed --sheet-sizes list instead of each
file's own plate size.

- BenchmarkJob/JobLoader build immutable job specs; a fresh Plate and
  NestItem list is created per (job, engine) run so state never leaks
  between engines or jobs.
- NestValidator rejects a layout if any part falls outside the work
  area, any two parts are closer than PartSpacing (checked via each
  part's own world-space polygon inflated by the spacing, so it holds
  for arbitrary concave/holed geometry, not just bounding boxes), or a
  drawing gets more parts than requested.
- Scoring matches Plate.Utilization() (placed area / full sheet area);
  ties among fully-placed layouts break on the smaller used bounding
  box (more usable remnant).
- Report prints a per-job ranked breakdown plus a per-engine summary
  (wins, avg utilization, time), and can write a flat CSV.

Verified end-to-end against a synthetic .nest file (not committed)
against the four built-in engines; caught a genuine out-of-work-area
bug in StripNestEngine in the process.
2026-09-15 18:31:36 -04:00
ajandClaude Opus 4.6 a085339ba9 fix: improve arc-tangency fitting and add layered engrave/cut passes for GravographIS
GeometrySimplifier/ArcFit now fit arcs that pass exactly through run
endpoints while balancing tangency error between trusted and estimated
directions, fixing arcs that previously bulged or broke tangent
continuity at fillet/compound-curve junctions.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-06 23:15:18 -04:00
577 changed files with 31660 additions and 11594 deletions
+68
View File
@@ -0,0 +1,68 @@
# Unified code style for OpenNest.
# Canonical formatter: dotnet format.
# Format only the files or directories being changed:
# dotnet format OpenNest.sln --include path/to/changed-file.cs
# dotnet format OpenNest.sln --include path/to/changed-file.cs --verify-no-changes
# These settings are used by dotnet format and IDE auto-formatting
# (VS / Rider / VS Code).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{csproj,props,targets,xml,config,manifest}]
indent_style = space
indent_size = 2
[*.{json,yml,yaml}]
indent_style = space
indent_size = 2
[*.{cs,csx}]
indent_style = space
indent_size = 4
# dotnet format cannot re-wrap source to this limit, but IDEs can surface it
# as a visual guide and analyzers can flag hard violations.
max_line_length = 100
# --- Using directives (System first, outside the namespace) ---
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning
# --- Brace placement: Allman (opening brace on its own line) ---
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# --- Spacing ---
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_after_cast = false
csharp_space_after_comma = true
csharp_space_before_comma = false
# --- Code style preferences ---
# Project rule: always use var for locals (see CLAUDE.md).
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = true:suggestion
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:silent
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+3
View File
@@ -0,0 +1,3 @@
# Commits whose changes git blame should skip (whitespace-only sweeps).
# Enable locally: git config blame.ignoreRevsFile .git-blame-ignore-revs
aec052306234e3c4313c0ee8905e2557d3c3671b
+36 -16
View File
@@ -8,26 +8,30 @@ OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts
## Build ## Build
This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-windows`. Build with: This is a .NET 8 solution using SDK-style `.csproj` files. The desktop app and Windows-dependent projects target `net8.0-windows`; the core libraries and `OpenNest.Console` target `net8.0`. Build the full solution on Windows with:
```bash ```bash
dotnet build OpenNest.sln dotnet build OpenNest.sln
``` ```
Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The existing `OpenNest.Tests` suite targets `net8.0-windows` and requires a Windows runner; cross-compiling on Linux is not Windows runtime verification. Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The main `OpenNest.Tests` suite also targets `net8.0`: run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj` independently on Linux/macOS/Windows. It must not reference the WinForms `OpenNest` project. The API, Data, Cincinnati, and GravographIS libraries target `net8.0`; post-processor build deployment still targets the desktop app's `net8.0-windows/Posts` directory. Optional CHR-font fixtures are configured through `OpenNest.Tests/test-config.json` and skip when absent.
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). `OpenNest.WinForms.Tests` contains the desktop-assembly-dependent `CadBendNoteTests` (`CadText`) and `CuttingParametersSerializerTests` (`CuttingParametersSerializer`). It targets `net8.0-windows`, references `OpenNest`, and requires a Windows runner: `dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj`. Keep future desktop-dependent tests here rather than in `OpenNest.Tests`. Linux cross-compilation uses `dotnet build OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj -p:EnableWindowsTargeting=true`; cross-compilation is not Windows runtime verification.
Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `Clipper2` 2.0.0 (region offsetting, in OpenNest.Core), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
## Architecture ## Architecture
Eight projects form a layered architecture: Nine projects form a layered architecture:
### OpenNest.Core (class library) ### OpenNest.Core (class library)
Domain model, geometry, and CNC primitives organized into namespaces: Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`. - **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) 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. - **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). - **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` (convex NFP only), `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `ClipperBridge` (Clipper2 region offsetting for CPU preparation only; see Key Patterns), and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction; deliberately hand-rolled as the reference for a future GPU kernel, with the port contract in its class summary).
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental). - **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `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. - **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration. - **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
@@ -37,19 +41,19 @@ Domain model, geometry, and CNC primitives organized into namespaces:
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning. - **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
### OpenNest.Engine (class library, depends on Core) ### OpenNest.Engine (class library, depends on Core)
Nesting algorithms provide both a legacy single-plate API and a whole-job API. The legacy path centers on `NestEngineBase`, `DefaultNestEngine` (formerly `NestEngine`), and the global `NestEngineRegistry`. New job callers use immutable, ID-based contracts in `Jobs/`: `INestingEngine.Solve(NestJob)` returns `NestJobResult`; `NestJobRunner` alone commits demand and finite/unlimited stock accounting; `IPlateNester` only proposes a one-sheet candidate; and `PlateNesterFactory` resolves a named strategy without reading or changing the process-global registry. Nesting algorithms use the jobs-only API. `INestingEngine.Solve(NestJob)` returns `NestJobResult`; `NestJobRunner` alone commits demand and finite/unlimited stock accounting; `IPlateNester` only proposes a one-sheet candidate; and `PlateNesterFactory` resolves a named built-in placement strategy.
- **Whole-job API (`Jobs/`)**: `NestJob` owns part requirements, physical stock, and options for one material/thickness/unit system. `PartGeometrySnapshot` contains owned flat rapid/line/arc geometry; results contain stock IDs and placement poses (radians), not mutable desktop models. `NestJobPlacementValidator` validates contours, rotation, usable work area, overlap, and spacing before accounting commits. The runner selects valid trial candidates greedily by priority vector, sheet area, envelope, and input order; an incomplete result reports why but does not prove geometric impossibility. `DrawingJobMapper` and `NestResultMaterializer` are the domain-boundary adapters. - **Whole-job API (`Jobs/`)**: `NestJob` owns part requirements, physical stock, and options for one material/thickness/unit system. `PartGeometrySnapshot` contains owned flat rapid/line/arc geometry; results contain stock IDs and placement poses (radians), not mutable desktop models. `NestJobPlacementValidator` validates contours, rotation, usable work area, overlap, and spacing before accounting commits. The runner selects valid trial candidates greedily by priority vector, sheet area, envelope, and input order; an incomplete result reports why but does not prove geometric impossibility. `DrawingJobMapper` and `NestResultMaterializer` are the domain-boundary adapters.
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester` and `StripPlateNester` are migrated built-ins with run-scoped private geometry; `LegacyPlateNesterAdapter` remains for remnant strategies and legacy plugins/callers during rollout. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` and NFP/`AutoNester` retain legacy name-based helpers and are deliberately outside the runner path. - **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester`, `StripPlateNester`, and `RemnantPlateNester` are built-ins with run-scoped private geometry. `PlateFillService` is the public single-plate proposal service for interactive fill/group/pack flows; it returns parts without mutating caller-owned plates. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` retains name-based helpers and remains outside the runner path.
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`. Existing desktop, CLI, and MCP callers remain on this compatibility path until separate migrations preserve their existing-plate, preview, and accept/cancel semantics. - **Filler pipeline (`Jobs/Placement/Fillers/`)**: internal `DefaultPlateFiller`, `StripPlateFiller`, and policy-backed `RemnantPlateFiller` implement the standard single-plate geometry pipeline. `Default` runs the Linear, Pairs, RectBestFit, and Extents phases; remnant variants preserve their distinct comparer, direction, trim-axis, and angle-ordering policies.
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`. - **Engine registration**: `NestingEngineRegistry` holds whole-job `INestingEngine` implementations including the four fixed strategies and `StockLadder`. It loads plug-ins that implement `INestingEngine` and have a public parameterless constructor. Plug-ins for the removed single-plate inheritance API are not binary compatible.
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency. - **Plugin engines**: independent `INestingEngine` plugins are class libraries that reference `OpenNest.Engine` and are built outside `OpenNest.sln`. The desktop app and `OpenNest.Benchmark` load them from an `Engines/` folder next to their build output (e.g. `OpenNest.Benchmark/bin/<Config>/net8.0/Engines/`). Do not add engine projects to this repo.
- **IFillComparer**: Interface enabling filler-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), and `HorizontalRemnantComparer` (minimize Y-extent) are grouped into `FillPolicy` on `FillContext`.
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`. - **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`. - **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups. - **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
- **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions. - **RectanglePacking/** (`namespace OpenNest.Engine.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts. - **CirclePacking/** (`namespace OpenNest.Engine.CirclePacking`): Alternative packing for circular parts.
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data). - **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints. - `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback. - `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
@@ -63,9 +67,10 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types. - `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers. - `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`. - `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
- `Bending/BendRepair` — conservative opt-in repair configured by `CadImportOptions.BendRepair`. Requires explicit inches/mm source units and an endpoint movement limit above 0.001 and at most 3.175 physical mm. Only unambiguous paired ETCH/SCRIBE ticks may move along the existing bend axis; cut geometry and unrelated marks must remain unchanged. Opt-in imports preserve source marks without blanket etch regeneration and expose per-bend outcomes in `CadImportResult.BendRepairReports`.
### OpenNest.Console (console app, depends on Core + Engine + IO) ### OpenNest.Console (console app, depends on Core + Engine + IO)
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`). Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and multi-drawing auto-nesting through the active engine's `Nest()` (`--autonest`). `--repair-bends-mm <limit> --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests.
### OpenNest.Gpu (class library, depends on Core + Engine) ### OpenNest.Gpu (class library, depends on Core + Engine)
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods. GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
@@ -73,6 +78,18 @@ GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` impleme
### OpenNest.Training (console app, depends on Core + Engine) ### OpenNest.Training (console app, depends on Core + Engine)
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training. Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
### OpenNest.Benchmark (console app, depends on Core + Engine + IO)
Compares registered `INestingEngine` implementations against each other on real `.nest` files. Each engine solves the whole job — it owns its own multi-plate/size strategy rather than being handed one already-sized plate at a time. Fully generic — it never hardcodes drawing geometry, just reads whatever drawings/quantities/plate settings each input file already has.
- `JobLoader` builds `BenchmarkJob`s from a `.nest` file or a folder of them via `NestReader`, using every drawing with `Quantity.Required > 0`. `--sheet-sizes` can sweep a fixed list of plate sizes instead of each file's own.
- `DxfManifestLoader` builds a `BenchmarkJob` from a JSON manifest (`sheetSizes`, `spacing`, `edgeSpacing`, `quadrant`, `parts[] { dxf, quantity, allowRotation }`) instead of a `.nest`, importing each DXF with `CadImporter.ImportDrawing`. DXF paths resolve relative to the manifest; sheet sizes are required (manifest or `--sheet-sizes`, which overrides). `allowRotation: false` locks rotation the same way `NestRunner` does. `JobLoader.Load` routes `*.json` inputs to it, and folder scans pick up `*.nest` plus `*.manifest.json` (plain `*.json` is ignored so `--output` reports are never read as manifests). Invalid manifests throw rather than being skipped.
- `BenchmarkJob.BuildNestJob(maxPlates)` converts the job into a `NestJob`: one `NestJobPart` per requested drawing (via `DrawingJobMapper.FromDrawing`) and one `NestPlateStock` per candidate sheet size (unlimited quantity — the engine decides how many of each size it uses).
- `BenchmarkRunner` fans the (job × engine) pairs out with `Parallel.ForEach` (`NoBuffering`, `MaxDegreeOfParallelism` from `--parallel`, CLI default 3, `Run`'s own default 1) and writes results by index so report order stays job-then-engine. Each solve builds its own `NestJob` snapshot and materialized drawings, so solves share no mutable drawing state. Concurrent solves compete for cores, so `Time(ms)` is only clean at `--parallel 1`. It calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring.
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring.
- Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`).
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report.
- `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins.
### OpenNest.Mcp (console app, depends on Core + Engine + IO) ### OpenNest.Mcp (console app, depends on Core + Engine + IO)
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`. MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
@@ -114,13 +131,16 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
## Key Patterns ## Key Patterns
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`. - OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
- OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.Nfp` (NFP-based nesting, not yet integrated), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`. - OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.Jobs` (whole-job API, with `.Placement` and `.Adapters`), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`, `OpenNest.Engine.RectanglePacking`, `OpenNest.Engine.CirclePacking`. All Engine types live in namespaces matching their directory under `OpenNest.Engine/` (project files use `namespace X;` file-scoped or block style); consumers reference them via explicit `using OpenNest.Engine[.Sub];` directives.
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings. - `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion). - Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. - Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, so a layout exactly at the spacing passes. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops.
- **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load). - **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity. - **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata. - **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
+3
View File
@@ -6,17 +6,20 @@ namespace OpenNest.Api;
public class NestRequest public class NestRequest
{ {
public IReadOnlyList<NestRequestPart> Parts { get; init; } = []; public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
/// <summary> /// <summary>
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback; /// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
/// an empty list deliberately means no stock is available. /// an empty list deliberately means no stock is available.
/// </summary> /// </summary>
public IReadOnlyList<NestRequestPlate> Plates { get; init; } public IReadOnlyList<NestRequestPlate> Plates { get; init; }
public Size SheetSize { get; init; } = new(60, 120); public Size SheetSize { get; init; } = new(60, 120);
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary> /// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
public string PlacementStrategy { get; init; } = "Default"; public string PlacementStrategy { get; init; } = "Default";
public string Material { get; init; } = "Steel, A1011 HR"; public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06; public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1; public double Spacing { get; init; } = 0.1;
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary> /// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
public NestStrategy Strategy { get; init; } = NestStrategy.Auto; public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default; public CutParameters Cutting { get; init; } = CutParameters.Default;
+1
View File
@@ -7,6 +7,7 @@ public class NestRequestPlate
{ {
public string Id { get; init; } public string Id { get; init; }
public Size Size { get; init; } public Size Size { get; init; }
/// <summary>Available physical sheets; null means unlimited.</summary> /// <summary>Available physical sheets; null means unlimited.</summary>
public int? Quantity { get; init; } public int? Quantity { get; init; }
public double PartSpacing { get; init; } public double PartSpacing { get; init; }
+42 -25
View File
@@ -6,6 +6,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.IO; using OpenNest.IO;
using OpenNest.Engine.Jobs;
namespace OpenNest.Api; namespace OpenNest.Api;
@@ -25,10 +26,12 @@ public class NestResponse
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary> /// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
public int SchemaVersion { get; init; } = CurrentSchemaVersion; public int SchemaVersion { get; init; } = CurrentSchemaVersion;
public int SheetCount { get; init; } public int SheetCount { get; init; }
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary> /// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
public double Utilization { get; init; } public double Utilization { get; init; }
public TimeSpan CutTime { get; init; } public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; } public TimeSpan Elapsed { get; init; }
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary> /// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
public NestJobStatus? Status { get; init; } public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; } public NestJobStopReason? StopReason { get; init; }
@@ -43,7 +46,7 @@ public class NestResponse
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true, WriteIndented = true,
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields. IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
Converters = { new JsonStringEnumConverter() } Converters = { new JsonStringEnumConverter() },
}; };
public async Task SaveAsync(string path) public async Task SaveAsync(string path)
@@ -61,19 +64,27 @@ public class NestResponse
var responseEntry = zip.CreateEntry("response.json"); var responseEntry = zip.CreateEntry("response.json");
await using (var stream = responseEntry.Open()) await using (var stream = responseEntry.Open())
{ {
await JsonSerializer.SerializeAsync(stream, new NestResponseArchiveDto await JsonSerializer.SerializeAsync(
{ stream,
SchemaVersion = CurrentSchemaVersion, new NestResponseArchiveDto
SheetCount = SheetCount, {
Utilization = Utilization, SchemaVersion = CurrentSchemaVersion,
CutTimeTicks = CutTime.Ticks, SheetCount = SheetCount,
ElapsedTicks = Elapsed.Ticks, Utilization = Utilization,
Status = Status, CutTimeTicks = CutTime.Ticks,
StopReason = StopReason, ElapsedTicks = Elapsed.Ticks,
Fulfillment = Fulfillment is null ? [] : new List<NestPartFulfillment>(Fulfillment), Status = Status,
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage), StopReason = StopReason,
PlateStockMappings = PlateStockMappings is null ? [] : new List<NestPlateStockMapping>(PlateStockMappings) Fulfillment = Fulfillment is null
}, JsonOptions); ? []
: new List<NestPartFulfillment>(Fulfillment),
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
PlateStockMappings = PlateStockMappings is null
? []
: new List<NestPlateStockMapping>(PlateStockMappings),
},
JsonOptions
);
} }
var nestEntry = zip.CreateEntry("nest.nest"); var nestEntry = zip.CreateEntry("nest.nest");
@@ -91,16 +102,19 @@ public class NestResponse
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(fs, ZipArchiveMode.Read); using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
var requestEntry = zip.GetEntry("request.json") var requestEntry =
zip.GetEntry("request.json")
?? throw new InvalidOperationException("Missing request.json in .nestquote file"); ?? throw new InvalidOperationException("Missing request.json in .nestquote file");
NestRequest request; NestRequest request;
await using (var stream = requestEntry.Open()) await using (var stream = requestEntry.Open())
{ {
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions) request =
await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
?? throw new InvalidOperationException("Invalid request.json in .nestquote file"); ?? throw new InvalidOperationException("Invalid request.json in .nestquote file");
} }
var responseEntry = zip.GetEntry("response.json") var responseEntry =
zip.GetEntry("response.json")
?? throw new InvalidOperationException("Missing response.json in .nestquote file"); ?? throw new InvalidOperationException("Missing response.json in .nestquote file");
NestResponseArchiveDto archive; NestResponseArchiveDto archive;
var hasSchemaVersion = false; var hasSchemaVersion = false;
@@ -110,16 +124,19 @@ public class NestResponse
{ {
var root = document.RootElement; var root = document.RootElement;
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _); hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
hasStatusMetadata = root.TryGetProperty("status", out _) || hasStatusMetadata =
root.TryGetProperty("stopReason", out _) || root.TryGetProperty("status", out _)
root.TryGetProperty("fulfillment", out _) || || root.TryGetProperty("stopReason", out _)
root.TryGetProperty("stockUsage", out _) || || root.TryGetProperty("fulfillment", out _)
root.TryGetProperty("plateStockMappings", out _); || root.TryGetProperty("stockUsage", out _)
archive = root.Deserialize<NestResponseArchiveDto>(JsonOptions) || root.TryGetProperty("plateStockMappings", out _);
archive =
root.Deserialize<NestResponseArchiveDto>(JsonOptions)
?? throw new InvalidOperationException("Invalid response.json in .nestquote file"); ?? throw new InvalidOperationException("Invalid response.json in .nestquote file");
} }
var nestEntry = zip.GetEntry("nest.nest") var nestEntry =
zip.GetEntry("nest.nest")
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file"); ?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
Nest nest; Nest nest;
using (var nestMs = new MemoryStream()) using (var nestMs = new MemoryStream())
@@ -145,7 +162,7 @@ public class NestResponse
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [], StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [], PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
Nest = nest, Nest = nest,
Request = request Request = request,
}; };
} }
+107 -46
View File
@@ -6,6 +6,9 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.IO; using OpenNest.IO;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Api; namespace OpenNest.Api;
@@ -16,10 +19,13 @@ public static class NestRunner
public static Task<NestResponse> RunAsync( public static Task<NestResponse> RunAsync(
NestRequest request, NestRequest request,
IProgress<NestProgress> progress = null, IProgress<NestProgress> progress = null,
CancellationToken token = default) CancellationToken token = default
)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
var requestParts = request.Parts ?? throw new ArgumentException("Request parts must not be null.", nameof(request)); var requestParts =
request.Parts
?? throw new ArgumentException("Request parts must not be null.", nameof(request));
if (requestParts.Count == 0) if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request)); throw new ArgumentException("Request must contain at least one part.", nameof(request));
@@ -32,22 +38,32 @@ public static class NestRunner
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (!File.Exists(part.Request.DxfPath)) if (!File.Exists(part.Request.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.Request.DxfPath}", part.Request.DxfPath); throw new FileNotFoundException(
$"DXF file not found: {part.Request.DxfPath}",
part.Request.DxfPath
);
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing)) if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
{ {
try try
{ {
drawing = CadImporter.ImportDrawing(part.Request.DxfPath, drawing = CadImporter.ImportDrawing(
new CadImportOptions { Quantity = part.Request.Quantity }); part.Request.DxfPath,
new CadImportOptions { Quantity = part.Request.Quantity }
);
} }
catch (Exception exception) catch (Exception exception)
{ {
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}", exception); throw new InvalidOperationException(
$"Failed to import DXF: {part.Request.DxfPath}",
exception
);
} }
if (drawing.Program == null || drawing.Program.Codes.Count == 0) if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}"); throw new InvalidOperationException(
$"Failed to import DXF: {part.Request.DxfPath}"
);
importedByPath.Add(part.Request.DxfPath, drawing); importedByPath.Add(part.Request.DxfPath, drawing);
} }
@@ -56,8 +72,11 @@ public static class NestRunner
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity)); jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
} }
var job = new NestJob(jobParts, CreateStock(request), var job = new NestJob(
new NestJobOptions(ResolvePlacementStrategy(request))); jobParts,
CreateStock(request),
new NestJobOptions(ResolvePlacementStrategy(request))
);
var jobProgress = progress == null ? null : new JobProgressBridge(progress); var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token); var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
@@ -71,35 +90,56 @@ public static class NestRunner
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting); var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
sw.Stop(); sw.Stop();
return Task.FromResult(new NestResponse return Task.FromResult(
{ new NestResponse
SheetCount = nest.Plates.Count, {
Utilization = CalculateUtilization(nest), SheetCount = nest.Plates.Count,
CutTime = cutTime, Utilization = CalculateUtilization(nest),
Elapsed = sw.Elapsed, CutTime = cutTime,
Status = result.Status, Elapsed = sw.Elapsed,
StopReason = result.StopReason, Status = result.Status,
Fulfillment = result.Fulfillment StopReason = result.StopReason,
.Select(value => new NestPartFulfillment(value.PartId, value.Requested, value.Placed, value.Unplaced)) Fulfillment = result
.ToArray(), .Fulfillment.Select(value => new NestPartFulfillment(
StockUsage = result.StockUsage value.PartId,
.Select(value => new NestStockUsage(value.StockId, value.Used, value.Remaining)) value.Requested,
.ToArray(), value.Placed,
PlateStockMappings = result.Plates value.Unplaced
.Select(value => new NestPlateStockMapping(value.PlateIndex, value.StockId)) ))
.ToArray(), .ToArray(),
Nest = nest, StockUsage = result
Request = request .StockUsage.Select(value => new NestStockUsage(
}); value.StockId,
value.Used,
value.Remaining
))
.ToArray(),
PlateStockMappings = result
.Plates.Select(value => new NestPlateStockMapping(
value.PlateIndex,
value.StockId
))
.ToArray(),
Nest = nest,
Request = request,
}
);
} }
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(IReadOnlyList<NestRequestPart> requestParts) private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(
IReadOnlyList<NestRequestPart> requestParts
)
{ {
var identified = new List<IdentifiedRequestPart>(requestParts.Count); var identified = new List<IdentifiedRequestPart>(requestParts.Count);
var ids = new HashSet<string>(StringComparer.Ordinal); var ids = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < requestParts.Count; index++) for (var index = 0; index < requestParts.Count; index++)
{ {
var part = requestParts[index] ?? throw new ArgumentException("Request parts must not contain null entries.", nameof(requestParts)); var part =
requestParts[index]
?? throw new ArgumentException(
"Request parts must not contain null entries.",
nameof(requestParts)
);
var id = part.Id ?? $"part-{index}"; var id = part.Id ?? $"part-{index}";
if (string.IsNullOrWhiteSpace(id)) if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts)); throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
@@ -117,8 +157,12 @@ public static class NestRunner
{ {
return return
[ [
new NestPlateStock(LegacyStockId, request.SheetSize, quantity: null, new NestPlateStock(
partSpacing: request.Spacing) LegacyStockId,
request.SheetSize,
quantity: null,
partSpacing: request.Spacing
),
]; ];
} }
@@ -126,9 +170,20 @@ public static class NestRunner
foreach (var plate in request.Plates) foreach (var plate in request.Plates)
{ {
if (plate is null) if (plate is null)
throw new ArgumentException("Request plates must not contain null entries.", nameof(request)); throw new ArgumentException(
stock.Add(new NestPlateStock(plate.Id, plate.Size, plate.Quantity, plate.PartSpacing, "Request plates must not contain null entries.",
plate.EdgeSpacing, plate.Quadrant)); nameof(request)
);
stock.Add(
new NestPlateStock(
plate.Id,
plate.Size,
plate.Quantity,
plate.PartSpacing,
plate.EdgeSpacing,
plate.Quadrant
)
);
} }
return stock; return stock;
@@ -147,25 +202,31 @@ public static class NestRunner
} }
} }
private static string ResolvePlacementStrategy(NestRequest request) => request.PlacementStrategy ?? request.Strategy switch private static string ResolvePlacementStrategy(NestRequest request) =>
{ request.PlacementStrategy
NestStrategy.Auto => "Default", ?? request.Strategy switch
_ => throw new NotSupportedException($"Unknown legacy nesting strategy: {request.Strategy}.") {
}; NestStrategy.Auto => "Default",
_ => throw new NotSupportedException(
$"Unknown legacy nesting strategy: {request.Strategy}."
),
};
private static double CalculateUtilization(Nest nest) private static double CalculateUtilization(Nest nest)
{ {
var sheetArea = nest.Plates.Sum(plate => plate.Area()); var sheetArea = nest.Plates.Sum(plate => plate.Area());
if (sheetArea == 0) return 0; if (sheetArea == 0)
var placedArea = nest.Plates.Sum(plate => plate.Parts return 0;
.Where(part => !part.BaseDrawing.IsCutOff) var placedArea = nest.Plates.Sum(plate =>
.Sum(part => part.BaseDrawing.Area)); plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
);
return placedArea / sheetArea; return placedArea / sheetArea;
} }
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request); private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
private sealed class JobProgressBridge(IProgress<NestProgress> progress) : IProgress<NestJobProgress> private sealed class JobProgressBridge(IProgress<NestProgress> progress)
: IProgress<NestJobProgress>
{ {
public void Report(NestJobProgress value) public void Report(NestJobProgress value)
{ {
+4 -1
View File
@@ -1,3 +1,6 @@
namespace OpenNest.Api; namespace OpenNest.Api;
public enum NestStrategy { Auto } public enum NestStrategy
{
Auto,
}
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Api</RootNamespace> <RootNamespace>OpenNest.Api</RootNamespace>
<AssemblyName>OpenNest.Api</AssemblyName> <AssemblyName>OpenNest.Api</AssemblyName>
</PropertyGroup> </PropertyGroup>
+94
View File
@@ -0,0 +1,94 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Benchmark
{
/// <summary>
/// One request to nest a specific drawing, with the quantity and rotation
/// constraints pulled from its source .nest file.
/// </summary>
public class DrawingRequest
{
public Drawing Drawing { get; init; }
public int Quantity { get; init; }
public int Priority { get; init; }
public double StepAngle { get; init; }
public double RotationStart { get; init; }
public double RotationEnd { get; init; }
}
/// <summary>
/// An immutable specification for one benchmark job: the full set of
/// drawings/quantities that must be nested, and the pool of sheet sizes the
/// engine may draw from while doing it. A single run may use several
/// plates - possibly of different sizes - to place everything, the same
/// way a real production job spreads across whatever plates it needs
/// rather than being handed one fixed-size sheet.
/// </summary>
public class BenchmarkJob
{
public string SourceFile { get; init; }
public List<Size> CandidateSizes { get; init; }
public Spacing EdgeSpacing { get; init; }
public double PartSpacing { get; init; }
public int Quadrant { get; init; }
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
public double SalvageRate { get; init; }
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
public List<DrawingRequest> Requests { get; init; }
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
/// <summary>Sheet area charged per unplaced part: the largest candidate
/// sheet. Any single part that fits the stock at all fits on one such
/// sheet, so placing a part is never scored worse than leaving it out.</summary>
public double UnplacedPartPenalty =>
CandidateSizes.Count == 0 ? 0 : CandidateSizes.Max(s => s.Width * s.Length);
/// <summary>
/// Builds the whole-job request this job represents: one NestJobPart per
/// requested drawing, and one NestPlateStock per candidate sheet size
/// (unlimited quantity - the engine under test decides how many of each
/// size it actually uses, and how demand splits across plates). The
/// engine owns its own multi-plate/size strategy; this harness no
/// longer picks plate sizes on the engine's behalf.
/// </summary>
public NestJob BuildNestJob(
int maxPlates,
double? salvageRate = null,
double? minimumSalvageDimension = null
)
{
var parts = Requests.Select(r =>
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
);
var stock = CandidateSizes.Select(size => new NestPlateStock(
size.ToString(1),
size,
null,
PartSpacing,
EdgeSpacing,
Quadrant
));
return new NestJob(
parts,
stock,
new NestJobOptions(
"Default",
maxPlates,
salvageRate ?? SalvageRate,
minimumSalvageDimension ?? 0
)
);
}
}
}
+387
View File
@@ -0,0 +1,387 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Benchmark
{
/// <summary>
/// Runs every candidate engine against every job. Each engine is a full
/// INestingEngine: it owns its own plate/size selection and multi-plate
/// strategy for the whole job, rather than being handed one already-sized
/// plate at a time by this harness. A per-run timeout guards against a
/// runaway or hanging engine — cooperative cancellation, so it reliably
/// stops engines built on NestJobRunner (all four built-ins) but can't
/// forcibly interrupt an engine that never checks its token.
/// </summary>
public static class BenchmarkRunner
{
/// <summary>Physical-sheet cap passed to every job's NestJobOptions.MaxPlates.</summary>
private const int MaxPlates = 40;
/// <summary>Wall-clock budget for one engine solving one job.</summary>
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
public static List<JobResult> Run(
List<BenchmarkJob> jobs,
IReadOnlyList<NestingEngineInfo> engines,
double? salvageRate = null,
double? minimumSalvageDimension = null,
string outputDirectory = null,
int maxParallelism = 1
)
{
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
.ToList();
var results = new JobResult[pairs.Count];
var options = new ParallelOptions
{
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
};
var baselineResults = new JobResult[jobs.Count];
Parallel.ForEach(
Partitioner.Create(
Enumerable.Range(0, jobs.Count),
EnumerablePartitionerOptions.NoBuffering
),
options,
i => baselineResults[i] = RunBaseline(jobs[i], salvageRate, minimumSalvageDimension)
);
// NoBuffering hands out one pair at a time: solves run for seconds to minutes,
// so chunked partitioning would leave workers idle behind a slow engine.
Parallel.ForEach(
Partitioner.Create(
Enumerable.Range(0, pairs.Count),
EnumerablePartitionerOptions.NoBuffering
),
options,
i =>
results[i] = RunOne(
pairs[i].Job,
pairs[i].Engine,
salvageRate,
minimumSalvageDimension,
outputDirectory
)
);
// Indexed writes keep the report in job-then-engine order whatever finishes first.
var ordered = new List<JobResult>(
results.Length + baselineResults.Count(result => result != null)
);
for (var jobIndex = 0; jobIndex < jobs.Count; jobIndex++)
{
if (baselineResults[jobIndex] != null)
ordered.Add(baselineResults[jobIndex]);
var firstResult = jobIndex * engines.Count;
for (var engineIndex = 0; engineIndex < engines.Count; engineIndex++)
ordered.Add(results[firstResult + engineIndex]);
}
return ordered;
}
private static JobResult RunBaseline(
BenchmarkJob job,
double? salvageRate,
double? minimumSalvageDimension
)
{
if (job.BaselinePlateRuns == null)
return null;
var requested = job.TotalRequestedQuantity;
try
{
var requirements = job.Requests.ToDictionary<
DrawingRequest,
Drawing,
(string Name, int Quantity)
>(
request => request.Drawing,
request => (request.Drawing.Name, request.Quantity),
ReferenceEqualityComparer.Instance
);
var partIds = job.Requests.ToDictionary<DrawingRequest, Drawing, string>(
request => request.Drawing,
request => request.Drawing.Id.ToString(),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(job.BaselinePlateRuns, requirements);
var benchmarkJob = job.BuildNestJob(
MaxPlates,
salvageRate,
minimumSalvageDimension
);
var instanceIndices = new Dictionary<string, int>(StringComparer.Ordinal);
var plateResults = job
.BaselinePlateRuns.Select(
(run, index) =>
{
var stock = new NestPlateStock(
$"baseline-{index}",
run.Plate.Size,
1,
run.Plate.PartSpacing,
run.Plate.EdgeSpacing,
run.Plate.Quadrant
);
var placements = run
.Parts.Select(part =>
{
var partId = partIds[part.BaseDrawing];
instanceIndices.TryGetValue(partId, out var instanceIndex);
instanceIndices[partId] = instanceIndex + 1;
return new NestJobPlacement(
partId,
instanceIndex,
part.Location.X,
part.Location.Y,
part.Rotation
);
})
.ToList();
return new NestJobPlateResult(index, stock, placements);
}
)
.ToList();
var baselineJob = new NestJob(
benchmarkJob.Parts,
plateResults.Select(result => result.Stock),
benchmarkJob.Options
);
var baselineJobResult = new NestJobResult(
NestJobStatus.Complete,
NestJobStopReason.Completed,
plateResults,
Array.Empty<PartFulfillment>(),
Array.Empty<StockUsage>()
);
NestValidator.ValidateAgainstJob(
baselineJob,
baselineJobResult,
job.Requests.ToDictionary(
request => request.Drawing.Id.ToString(),
request => request.Drawing.Name
),
validation
);
var plateRuns = job.BaselinePlateRuns;
var placedArea = validation.Valid
? plateRuns.Sum(run => run.Parts.Sum(part => part.BaseDrawing.Area))
: 0;
var plateArea = plateRuns.Sum(run => run.Plate.Area());
var netSheetArea = validation.Valid
? plateResults.Sum(result =>
NestJobCost.NetSheetArea(baselineJob, result)
)
: 0;
var sizeBreakdown = plateRuns
.GroupBy(run => run.Plate.Size.ToString(1))
.OrderByDescending(group => group.Count())
.ToDictionary(group => group.Key, group => group.Count());
return new JobResult
{
EngineName = "Baseline",
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = plateRuns.Sum(run => run.Parts.Count),
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
NetSheetArea = netSheetArea,
UnplacedPartPenalty = job.UnplacedPartPenalty,
PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = 0,
};
}
catch (Exception ex)
{
return new JobResult
{
EngineName = "Baseline",
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
Error = $"{ex.GetType().Name}: {ex.Message}",
};
}
}
private static JobResult RunOne(
BenchmarkJob job,
NestingEngineInfo engineInfo,
double? salvageRate,
double? minimumSalvageDimension,
string outputDirectory
)
{
var requested = job.TotalRequestedQuantity;
var sw = Stopwatch.StartNew();
try
{
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(SolveTimeout);
var jobResult = engine.Solve(nestJob, null, cts.Token);
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
var plateRuns = materialized
.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
.ToList();
var requirements = job.Requests.ToDictionary<
DrawingRequest,
Drawing,
(string Name, int Quantity)
>(
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
r => (r.Drawing.Name, r.Quantity),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(plateRuns, requirements);
NestValidator.ValidateAgainstJob(
nestJob,
jobResult,
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing.Name),
validation
);
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
var placedArea = validation.Valid
? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area))
: 0;
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
// Salvage credit is recomputed from the job's own geometry, never taken from the engine.
var netSheetArea = validation.Valid
? jobResult.Plates.Sum(p =>
NestJobCost.NetSheetArea(nestJob, p)
)
: 0;
var sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
if (validation.Valid && outputDirectory != null)
{
System.IO.Directory.CreateDirectory(outputDirectory);
// Keep names and job metadata for a useful inspectable output; never modify source.
// Manifest jobs have no source nest to copy from, so they keep the job's name.
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
{
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
materialized.Nest.Name = source.Name;
materialized.Nest.Units = source.Units;
materialized.Nest.Material = source.Material;
materialized.Nest.Thickness = source.Thickness;
}
else
{
materialized.Nest.Name = job.Name;
}
materialized.Nest.SalvageRate = nestJob.Options.SalvageRate;
foreach (var request in job.Requests)
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request
.Drawing
.Name;
var path = System.IO.Path.Combine(
outputDirectory,
$"{job.Name}-{engineInfo.Name}.nest"
);
if (
System.IO.Path.GetFullPath(path)
== System.IO.Path.GetFullPath(job.SourceFile)
)
throw new InvalidOperationException(
"Output must not overwrite the source nest."
);
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
var report = new
{
Source = job.SourceFile,
Engine = engineInfo.Name,
jobResult.Status,
jobResult.StopReason,
Requested = requested,
Placed = totalPlaced,
SheetArea = plateArea,
PlacedArea = placedArea,
SalvageRate = nestJob.Options.SalvageRate,
MinimumSalvageDimension = nestJob.Options.MinimumSalvageDimension,
EstimatedNetArea = netSheetArea,
Fulfillment = jobResult.Fulfillment,
StockUsage = jobResult.StockUsage,
Plates = jobResult.Plates,
validation.Violations,
};
System.IO.File.WriteAllText(
System.IO.Path.ChangeExtension(path, ".json"),
System.Text.Json.JsonSerializer.Serialize(
report,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }
)
);
}
sw.Stop();
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = totalPlaced,
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
NetSheetArea = netSheetArea,
UnplacedPartPenalty = job.UnplacedPartPenalty,
PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = sw.ElapsedMilliseconds,
};
}
catch (OperationCanceledException)
{
sw.Stop();
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
};
}
catch (Exception ex)
{
sw.Stop();
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"{ex.GetType().Name}: {ex.Message}",
};
}
}
}
}
+175
View File
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Benchmark
{
/// <summary>
/// Builds a BenchmarkJob from a JSON manifest that lists DXF files and the
/// quantity of each to nest. DXF paths resolve relative to the manifest.
/// Sheet sizes come from the manifest or the caller's override; unlike a
/// .nest file there is no plate to inherit them from, so a job with none is
/// an error. Sheet sizes must use the same units as the DXFs.
/// </summary>
public static class DxfManifestLoader
{
/// <summary>Suffix a manifest needs to be picked up when scanning a folder.</summary>
public const string FolderSuffix = ".manifest.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static BenchmarkJob Load(
string manifestPath,
IReadOnlyList<Size> sheetSizeOverrides = null,
double? partSpacingOverride = null
)
{
var manifest = ReadManifest(manifestPath);
var baseDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath));
if (manifest.Parts == null || manifest.Parts.Count == 0)
throw new InvalidOperationException(
$"Manifest '{manifestPath}' has no parts. Add entries to \"parts\"."
);
var sizes = ResolveSheetSizes(manifest, sheetSizeOverrides, manifestPath);
var requests = manifest.Parts.Select(p => BuildRequest(p, baseDir)).ToList();
return new BenchmarkJob
{
SourceFile = manifestPath,
CandidateSizes = sizes,
EdgeSpacing = new Spacing(manifest.EdgeSpacing, manifest.EdgeSpacing),
PartSpacing = partSpacingOverride ?? manifest.Spacing,
Quadrant = manifest.Quadrant,
Requests = requests,
};
}
private static Manifest ReadManifest(string manifestPath)
{
try
{
return JsonSerializer.Deserialize<Manifest>(
File.ReadAllText(manifestPath),
JsonOptions
) ?? throw new InvalidOperationException("The manifest is empty.");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"Manifest '{manifestPath}' is not valid JSON: {ex.Message}",
ex
);
}
}
private static List<Size> ResolveSheetSizes(
Manifest manifest,
IReadOnlyList<Size> overrides,
string manifestPath
)
{
if (overrides != null && overrides.Count > 0)
return overrides.ToList();
var sizes = new List<Size>();
foreach (var text in manifest.SheetSizes ?? new List<string>())
{
if (!JobLoader.TryParseSheetSize(text, out var size))
throw new InvalidOperationException(
$"Manifest '{manifestPath}': could not parse sheet size '{text}' (expected e.g. \"48x96\")."
);
sizes.Add(size);
}
if (sizes.Count == 0)
throw new InvalidOperationException(
$"Manifest '{manifestPath}' has no sheet sizes. Set \"sheetSizes\" or pass --sheet-sizes."
);
return sizes.Distinct().ToList();
}
private static DrawingRequest BuildRequest(ManifestPart part, string baseDir)
{
if (string.IsNullOrWhiteSpace(part.Dxf))
throw new InvalidOperationException("A manifest part is missing \"dxf\".");
if (part.Quantity <= 0)
throw new InvalidOperationException(
$"Manifest part '{part.Dxf}': quantity must be greater than 0 (was {part.Quantity})."
);
var dxfPath = Path.GetFullPath(Path.Combine(baseDir, part.Dxf));
if (!File.Exists(dxfPath))
throw new FileNotFoundException($"DXF file not found: {dxfPath}", dxfPath);
Drawing drawing;
try
{
drawing = CadImporter.ImportDrawing(
dxfPath,
new CadImportOptions { Quantity = part.Quantity }
);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}", ex);
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
if (!part.AllowRotation)
{
drawing.Constraints ??= new NestConstraints();
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
drawing.Constraints.StartAngle = 0;
drawing.Constraints.EndAngle = 0;
}
var constraints = drawing.Constraints;
return new DrawingRequest
{
Drawing = drawing,
Quantity = part.Quantity,
Priority = drawing.Priority,
StepAngle = constraints?.StepAngle ?? 0,
RotationStart = constraints?.StartAngle ?? 0,
RotationEnd = constraints?.EndAngle ?? 0,
};
}
private class Manifest
{
public List<string> SheetSizes { get; set; }
public double Spacing { get; set; }
public double EdgeSpacing { get; set; }
public int Quadrant { get; set; } = 1;
public List<ManifestPart> Parts { get; set; }
}
private class ManifestPart
{
public string Dxf { get; set; }
public int Quantity { get; set; }
public bool AllowRotation { get; set; } = true;
}
}
}
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Benchmark
{
/// <summary>
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
/// any valid .nest file, using whatever drawings/quantities/plate settings
/// it contains. One job per file, carrying the full pool of candidate
/// sheet sizes the engine may use across the whole nest - by default the
/// distinct sizes already present in that file, or a fixed override list
/// (e.g. a standard sheet-size lineup) applied to every file.
/// </summary>
public static class JobLoader
{
public static List<BenchmarkJob> Load(
string inputPath,
IReadOnlyList<Size> sheetSizeOverrides = null,
double? partSpacingOverride = null
)
{
var files = ResolveFiles(inputPath);
var jobs = new List<BenchmarkJob>();
foreach (var file in files)
{
if (file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
// Hand-written manifests fail loudly rather than being skipped like unreadable .nest files.
jobs.Add(DxfManifestLoader.Load(file, sheetSizeOverrides, partSpacingOverride));
continue;
}
Nest nest;
try
{
nest = new NestReader(file).Read();
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"[JobLoader] Skipping '{file}': failed to read ({ex.Message})"
);
continue;
}
var requests = BuildRequests(nest);
if (requests.Count == 0)
{
Console.Error.WriteLine(
$"[JobLoader] Skipping '{file}': no drawings with quantity > 0"
);
continue;
}
var template = ResolvePlateTemplate(nest);
var sizes =
sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
? sheetSizeOverrides.ToList()
: ResolveSheetSizes(nest);
jobs.Add(
new BenchmarkJob
{
SourceFile = file,
CandidateSizes = sizes,
EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant,
SalvageRate = nest.SalvageRate,
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
Requests = requests,
}
);
}
return jobs;
}
/// <summary>Parses "WxL" with invariant-culture numbers, so "48.5x96" means the
/// same thing on every machine (Size.Parse follows the current culture).</summary>
public static bool TryParseSheetSize(string text, out Size size)
{
size = default;
var dims = text?.Split('x', 'X');
if (dims == null || dims.Length != 2)
return false;
var style = System.Globalization.NumberStyles.Float;
var culture = System.Globalization.CultureInfo.InvariantCulture;
if (
!double.TryParse(dims[0].Trim(), style, culture, out var width)
|| !double.TryParse(dims[1].Trim(), style, culture, out var length)
)
return false;
size = new Size(width, length);
return true;
}
private static List<string> ResolveFiles(string inputPath)
{
if (Directory.Exists(inputPath))
{
return Directory
.EnumerateFiles(inputPath, "*", SearchOption.AllDirectories)
.Where(f =>
f.EndsWith(".nest", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(
DxfManifestLoader.FolderSuffix,
StringComparison.OrdinalIgnoreCase
)
)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
}
if (File.Exists(inputPath))
return new List<string> { inputPath };
throw new FileNotFoundException($"Benchmark input not found: {inputPath}");
}
private static List<DrawingRequest> BuildRequests(Nest nest)
{
var requests = new List<DrawingRequest>();
foreach (var drawing in nest.Drawings)
{
var qty = drawing.Quantity.Required;
if (qty <= 0)
continue;
var constraints = drawing.Constraints;
requests.Add(
new DrawingRequest
{
Drawing = drawing,
Quantity = qty,
Priority = drawing.Priority,
StepAngle = constraints?.StepAngle ?? 0,
RotationStart = constraints?.StartAngle ?? 0,
RotationEnd = constraints?.EndAngle ?? 0,
}
);
}
return requests;
}
private static List<(Plate Plate, List<Part> Parts)> BuildBaselinePlateRuns(
Nest nest,
double? partSpacingOverride
)
{
var runs = new List<(Plate Plate, List<Part> Parts)>();
foreach (var plate in nest.Plates ?? Enumerable.Empty<Plate>())
{
var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
if (parts.Count == 0)
continue;
var validationPlate = new Plate(new Size(plate.Size.Width, plate.Size.Length))
{
Quantity = 1,
Quadrant = plate.Quadrant,
PartSpacing = partSpacingOverride ?? plate.PartSpacing,
EdgeSpacing = new Spacing(
plate.EdgeSpacing.Left,
plate.EdgeSpacing.Bottom,
plate.EdgeSpacing.Right,
plate.EdgeSpacing.Top
),
};
for (var copy = 0; copy < plate.Quantity; copy++)
runs.Add((validationPlate, parts));
}
return runs.Count > 0 ? runs : null;
}
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
Nest nest
)
{
var source = nest.Plates?.FirstOrDefault();
if (source != null)
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
var defaults = nest.PlateDefaults;
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
}
private static List<Size> ResolveSheetSizes(Nest nest)
{
var sizes = (nest.Plates ?? Enumerable.Empty<Plate>())
.Select(p => p.Size)
.Distinct()
.ToList();
if (sizes.Count == 0)
sizes.Add(nest.PlateDefaults.Size);
return sizes;
}
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Collections.Generic;
namespace OpenNest.Benchmark
{
/// <summary>
/// Outcome of running one engine against one job. A job may span several
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
/// one sheet - possibly of different sizes - to place everything asked of
/// it. An invalid or crashed run places nothing as far as scoring is
/// concerned: it earns no area and pays the unplaced penalty on every
/// requested part.
/// </summary>
public class JobResult
{
public string EngineName { get; init; }
public string JobName { get; init; }
public bool Valid { get; init; }
public List<string> Violations { get; init; } = new();
public string Error { get; init; }
public int PartsPlaced { get; init; }
public int PartsRequested { get; init; }
public double PlacedArea { get; init; }
public double PlateArea { get; init; }
/// <summary>Sheet area consumed after crediting salvageable offcuts
/// (NestJobCost.NetSheetArea summed over every plate).
/// Equals PlateArea when salvage credit is disabled.</summary>
public double NetSheetArea { get; init; }
/// <summary>Sheet area charged for each requested part that was not
/// placed: the largest candidate sheet's area, so leaving a part out
/// always costs at least as much as the extra sheet it would need.</summary>
public double UnplacedPartPenalty { get; init; }
public int PlatesUsed { get; init; }
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
public long ElapsedMs { get; init; }
public bool Crashed => Error != null;
public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested;
/// <summary>Aggregate utilization across every plate the engine used:
/// total placed drawing area over total plate area, matching
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
/// <summary>Placed area over salvage-credited sheet area.</summary>
public double NetUtilization =>
Valid && NetSheetArea > 0 ? PlacedArea / NetSheetArea : 0;
public int PartsUnplaced =>
Valid ? System.Math.Max(0, PartsRequested - PartsPlaced) : PartsRequested;
/// <summary>
/// The ranking score, in sheet area (lower is better): net sheet area
/// consumed plus the unplaced penalty. An engine cannot improve it by
/// dropping awkward parts, and it sums honestly across jobs of
/// different sizes. Invalid runs consume no sheet but pay the penalty
/// on every requested part.
/// </summary>
public double Cost => (Valid ? NetSheetArea : 0) + PartsUnplaced * UnplacedPartPenalty;
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using OpenNest.Engine.Jobs;
namespace OpenNest.Benchmark;
/// <summary>Benchmark validation outcome.</summary>
public class ValidationResult
{
public bool Valid => Violations.Count == 0;
public List<string> Violations { get; } = new();
}
/// <summary>Compatibility wrapper over the shared layout validation contract.</summary>
public static class NestValidator
{
/// <summary>Checks materialized plates using drawing-reference requirement identity.</summary>
public static ValidationResult Validate(
List<(Plate Plate, List<Part> Parts)> plateRuns,
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
{
var result = new ValidationResult();
result.Violations.AddRange(NestLayoutCheck.Validate(plateRuns, requirements));
return result;
}
/// <summary>Appends offered-stock, finite-stock and rotation-policy violations.</summary>
public static void ValidateAgainstJob(NestJob job, NestJobResult jobResult,
IReadOnlyDictionary<string, string> displayNames, ValidationResult result) =>
NestLayoutCheck.ValidateAgainstJob(job, jobResult, displayNames, result.Violations);
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Benchmark</RootNamespace>
<AssemblyName>OpenNest.Benchmark</AssemblyName>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+340
View File
@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.Benchmark;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
return BenchmarkConsole.Run(args);
static class BenchmarkConsole
{
public static int Run(string[] args)
{
var options = ParseArgs(args);
if (options == null)
return 0; // --help was requested
if (options.InputPath == null)
{
PrintUsage();
return 1;
}
List<BenchmarkJob> jobs;
try
{
jobs = JobLoader.Load(options.InputPath, options.SheetSizes, options.PartSpacing);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
if (jobs.Count == 0)
{
Console.Error.WriteLine(
"No benchmark jobs found (no .nest files with any drawing quantity > 0, or *.manifest.json files)."
);
return 1;
}
var enginesDir = Path.Combine(AppContext.BaseDirectory, "Engines");
NestingEngineRegistry.LoadPlugins(enginesDir);
var engines = NestingEngineRegistry.AvailableEngines;
if (options.EngineNames.Count > 0)
{
engines = engines
.Where(e =>
options.EngineNames.Any(n =>
n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)
)
)
.ToList();
if (engines.Count == 0)
{
Console.Error.WriteLine(
"None of the requested engines are registered. Available: "
+ string.Join(
", ",
NestingEngineRegistry.AvailableEngines.Select(e => e.Name)
)
);
return 1;
}
}
Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'");
foreach (var job in jobs)
{
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
Console.WriteLine(
$" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}"
);
}
if (
options.SheetSizes.Count == 0
&& jobs.Any(j => j.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
)
{
Console.Error.WriteLine(
"Warning: no --sheet-sizes given, so each .nest job only offers the sheet sizes its "
+ "original layout used - a hint toward that answer. Pass --sheet-sizes with the "
+ "sizes you actually stock for an unbiased comparison."
);
}
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
var effectiveSalvageRates = jobs.Select(job => options.SalvageRate ?? job.SalvageRate);
if (
effectiveSalvageRates.Any(rate => rate > 0)
&& (options.MinimumSalvageDimension ?? 0) <= 0
)
{
Console.Error.WriteLine(
"Warning: salvage credit is disabled because --min-salvage-dimension was not set to a positive value."
);
}
var solves = jobs.Count * engines.Count;
if (options.Parallel > 1 && solves > 1)
{
Console.WriteLine(
$"Running up to {options.Parallel} solves at a time; Time(ms) is measured under that "
+ "concurrent load. Use --parallel 1 for strictly isolated timings."
);
}
var results = BenchmarkRunner.Run(
jobs,
engines,
options.SalvageRate,
options.MinimumSalvageDimension,
options.OutputDirectory,
options.Parallel
);
Report.PrintDetailed(results);
Report.PrintSummary(results);
if (options.CsvPath != null)
{
Report.WriteCsv(options.CsvPath, results);
Console.WriteLine();
Console.WriteLine($"Wrote CSV report to {options.CsvPath}");
}
return 0;
}
private static Options ParseArgs(string[] args)
{
var o = new Options();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--sheet-sizes" when i + 1 < args.Length:
o.SheetSizes = ParseSheetSizes(args[++i]);
break;
case "--spacing" when i + 1 < args.Length:
o.PartSpacing = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--engines" when i + 1 < args.Length:
o.EngineNames = args[++i]
.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
.ToList();
break;
case "--csv" when i + 1 < args.Length:
o.CsvPath = args[++i];
break;
case "--salvage-rate" when i + 1 < args.Length:
o.SalvageRate = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--min-salvage-dimension" when i + 1 < args.Length:
o.MinimumSalvageDimension = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--output" when i + 1 < args.Length:
o.OutputDirectory = args[++i];
break;
case "--parallel" when i + 1 < args.Length:
if (int.TryParse(args[++i], out var parallel) && parallel >= 1)
o.Parallel = parallel;
else
Console.Error.WriteLine(
$"Warning: --parallel needs a whole number >= 1, using {o.Parallel}"
);
break;
case "--help":
PrintUsage();
return null;
default:
if (!args[i].StartsWith("--"))
o.InputPath = args[i];
break;
}
}
return o;
}
private static List<Size> ParseSheetSizes(string arg)
{
var sizes = new List<Size>();
foreach (
var token in arg.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
)
{
if (JobLoader.TryParseSheetSize(token, out var size))
sizes.Add(size);
else
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
}
return sizes.Distinct().ToList();
}
private static void PrintUsage()
{
Console.Error.WriteLine(
"OpenNest.Benchmark - compare registered whole-job nesting engines on a set of .nest files"
);
Console.Error.WriteLine();
Console.Error.WriteLine(
"For each .nest file, every drawing with quantity > 0 is nested (mixed together),"
);
Console.Error.WriteLine(
"once per registered INestingEngine. Each engine is handed the full job - every"
);
Console.Error.WriteLine(
"requested part and the whole pool of candidate sheet sizes - and owns its own"
);
Console.Error.WriteLine(
"multi-plate/size strategy: how many plates it uses, of which sizes, and how"
);
Console.Error.WriteLine(
"demand splits across them. Ranking: a run that places every requested part beats"
);
Console.Error.WriteLine(
"one that does not; then lower cost = sheet area consumed (minus salvage credit for a"
);
Console.Error.WriteLine(
"usable offcut) + the largest candidate sheet's area per unplaced part; then fewer"
);
Console.Error.WriteLine(
"plates. An invalid layout (out of bounds, overlapping, over-quantity, off-stock, or"
);
Console.Error.WriteLine(
"breaking a rotation constraint), a thrown exception, or a timeout places nothing."
);
Console.Error.WriteLine();
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(
" OpenNest.Benchmark <file.nest | manifest.json | folder> [options]"
);
Console.Error.WriteLine();
Console.Error.WriteLine(
"A manifest.json builds a job straight from DXF files (paths relative to the manifest):"
);
Console.Error.WriteLine(
" { \"sheetSizes\": [\"48x96\"], \"spacing\": 0.25, \"edgeSpacing\": 0.25, \"quadrant\": 1,"
);
Console.Error.WriteLine(
" \"parts\": [ { \"dxf\": \"a.dxf\", \"quantity\": 12 }, { \"dxf\": \"b.dxf\", \"quantity\": 4, \"allowRotation\": false } ] }"
);
Console.Error.WriteLine(
"Sheet sizes must use the same units as the DXFs. A folder is scanned for *.nest and"
);
Console.Error.WriteLine(
"*.manifest.json files. --sheet-sizes and --spacing override the manifest."
);
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(
" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest"
);
Console.Error.WriteLine(
" (default: the distinct sizes already in each file,"
);
Console.Error.WriteLine(
" which hints engines toward the original layout)"
);
Console.Error.WriteLine(
" --spacing <value> Override part spacing for every job"
);
Console.Error.WriteLine(
" --engines Name1,Name2,... Only benchmark these registered engines (default: all)"
);
Console.Error.WriteLine(
" --csv <path> Write a flat CSV of all results"
);
Console.Error.WriteLine(
" --salvage-rate <0..1> Fraction of eligible offcut area credited (default: saved .nest rate;"
);
Console.Error.WriteLine(
" manifests 0; needs positive --min-salvage-dimension)"
);
Console.Error.WriteLine(
" --min-salvage-dimension <value> Both offcut dimensions must qualify; positive value enables credit (default 0)"
);
Console.Error.WriteLine(
" --output <directory> Save valid layouts as .nest plus detailed JSON reports"
);
Console.Error.WriteLine(
" --parallel <n> Solves to run at once (default 3; 1 = strictly sequential,"
);
Console.Error.WriteLine(
" which gives the cleanest per-engine timings)"
);
Console.Error.WriteLine(" --help Show this message");
}
private class Options
{
public string InputPath;
public List<Size> SheetSizes = new();
public double? PartSpacing;
public List<string> EngineNames = new();
public string CsvPath;
public string OutputDirectory;
public double? SalvageRate;
public double? MinimumSalvageDimension;
public int Parallel = 3;
}
}
+203
View File
@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenNest.Benchmark
{
/// <summary>
/// Console + CSV reporting for benchmark results. Ranking rule per job:
/// valid beats invalid; placing every requested part beats not; then lower
/// JobResult.Cost wins - salvage-credited sheet area consumed plus a
/// largest-sheet penalty per unplaced part, so dropping awkward parts can
/// never buy a better score; then fewer plates. Ties beyond that are a
/// shared win. Across jobs, costs and areas are summed (not averaged), so
/// a big job weighs more than a three-part one.
/// </summary>
public static class Report
{
private const double Epsilon = 1e-6;
public static void PrintDetailed(List<JobResult> results)
{
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
Console.WriteLine();
Console.WriteLine($"=== {jobGroup.Key} ===");
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
var best = ranked.Count > 0 ? ranked[0] : null;
Console.WriteLine(
$"{"Engine", -16} {"Result", -9} {"Parts", -10} {"Util%", -7} {"Net%", -7} {"Cost", -12} {"Plates", -18} {"Time(ms)", -9} Notes"
);
foreach (var r in ranked)
{
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
var marker = isWinner ? "*" : " ";
var status =
r.Crashed ? "CRASH"
: r.Valid ? "ok"
: "INVALID";
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
var netCol = r.Valid ? $"{r.NetUtilization * 100:F1}" : "-";
var platesCol =
r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
Console.WriteLine(
$"{marker}{r.EngineName, -15} {status, -9} {partsCol, -10} {utilCol, -7} {netCol, -7} {r.Cost, -12:F1} {platesCol, -18} {r.ElapsedMs, -9} {notes}"
);
}
}
}
public static void PrintSummary(List<JobResult> results)
{
Console.WriteLine();
Console.WriteLine("=== Summary ===");
var byEngine = results
.GroupBy(r => r.EngineName)
.Select(g => new
{
Engine = g.Key,
Jobs = g.Count(),
Valid = g.Count(r => r.Valid),
Crashed = g.Count(r => r.Crashed),
FullyPlaced = g.Count(r => r.FullyPlaced),
Unplaced = g.Sum(r => r.PartsUnplaced),
PlacedArea = g.Where(r => r.Valid).Sum(r => r.PlacedArea),
PlateArea = g.Where(r => r.Valid).Sum(r => r.PlateArea),
NetSheetArea = g.Where(r => r.Valid).Sum(r => r.NetSheetArea),
TotalCost = g.Sum(r => r.Cost),
TotalPlates = g.Sum(r => r.PlatesUsed),
TotalTimeMs = g.Sum(r => r.ElapsedMs),
})
.OrderBy(e => e.TotalCost)
.ToList();
var wins = CountWins(results);
// Util% and Net% are area-weighted over valid runs (sum placed / sum
// sheet), not a mean of per-job percentages. TotalCost sums across
// jobs, so it is only meaningful when every job uses the same units.
Console.WriteLine(
$"{"Engine", -16} {"Jobs", -6} {"Valid", -7} {"Complete", -9} {"Unplaced", -9} {"Wins", -6} {"Util%", -7} {"Net%", -7} {"TotalCost", -14} {"Plates", -8} {"TotalTime(ms)", -14}"
);
foreach (var e in byEngine)
{
var util = e.PlateArea > 0 ? e.PlacedArea / e.PlateArea * 100 : 0;
var netUtil = e.NetSheetArea > 0 ? e.PlacedArea / e.NetSheetArea * 100 : 0;
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
Console.WriteLine(
$"{e.Engine, -16} {e.Jobs, -6} {e.Valid, -7} {e.FullyPlaced, -9} {e.Unplaced, -9} {winCount, -6} {util, -7:F1} {netUtil, -7:F1} {e.TotalCost, -14:F1} {e.TotalPlates, -8} {e.TotalTimeMs, -14}"
);
}
}
public static void WriteCsv(string path, List<JobResult> results)
{
var sb = new StringBuilder();
sb.AppendLine(
"Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes"
);
foreach (var r in results)
{
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
sb.AppendLine(
string.Join(
",",
Csv(r.JobName),
Csv(r.EngineName),
r.Valid,
r.Crashed,
r.FullyPlaced,
r.PartsPlaced,
r.PartsRequested,
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
r.NetUtilization.ToString("F4", CultureInfo.InvariantCulture),
r.PlateArea.ToString("F2", CultureInfo.InvariantCulture),
r.NetSheetArea.ToString("F2", CultureInfo.InvariantCulture),
r.Cost.ToString("F2", CultureInfo.InvariantCulture),
r.PlatesUsed,
Csv(SizeSummary(r.SizeBreakdown)),
r.ElapsedMs,
Csv(notes)
)
);
}
File.WriteAllText(path, sb.ToString());
}
private static string SizeSummary(Dictionary<string, int> breakdown)
{
if (breakdown == null || breakdown.Count == 0)
return "-";
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
private static Dictionary<string, int> CountWins(List<JobResult> results)
{
var wins = new Dictionary<string, int>();
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
if (ranked.Count == 0 || !ranked[0].Valid)
continue;
foreach (var r in ranked.TakeWhile(r => Compare(r, ranked[0]) == 0))
wins[r.EngineName] = wins.GetValueOrDefault(r.EngineName) + 1;
}
return wins;
}
/// <summary>Lower sorts first (better). Valid beats invalid, complete beats
/// incomplete, then lower cost (relative tolerance, since costs are areas
/// in whatever units the job uses), then fewer plates.</summary>
public static int Compare(JobResult a, JobResult b)
{
if (a.Valid != b.Valid)
return a.Valid ? -1 : 1;
if (!a.Valid)
return 0;
if (a.FullyPlaced != b.FullyPlaced)
return a.FullyPlaced ? -1 : 1;
var costDiff = a.Cost - b.Cost;
var scale = System.Math.Max(1, System.Math.Max(a.Cost, b.Cost));
if (System.Math.Abs(costDiff) > Epsilon * scale)
return costDiff > 0 ? 1 : -1;
if (a.PlatesUsed != b.PlatesUsed)
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
return 0;
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Console</RootNamespace> <RootNamespace>OpenNest.Console</RootNamespace>
<AssemblyName>OpenNest.Console</AssemblyName> <AssemblyName>OpenNest.Console</AssemblyName>
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants> <DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
+308 -60
View File
@@ -1,13 +1,19 @@
using OpenNest;
using OpenNest.Geometry;
using OpenNest.IO;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading; using System.Threading;
using OpenNest;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bending;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
return NestConsole.Run(args); return NestConsole.Run(args);
@@ -20,12 +26,58 @@ static class NestConsole
if (options == null) if (options == null)
return 0; // --help was requested return 0; // --help was requested
if (
options.RepairBendsMillimeters.HasValue
&& (
options.CadUnits == BendRepairUnits.Unspecified
|| !double.IsFinite(options.RepairBendsMillimeters.Value)
|| options.RepairBendsMillimeters <= 0.001
|| options.RepairBendsMillimeters > 3.175
)
)
{
Console.Error.WriteLine(
"Error: --repair-bends-mm requires a limit > 0.001 and <= 3.175 mm and --cad-units inches|mm."
);
return 1;
}
if (options.ListPosts) if (options.ListPosts)
{ {
ListPostProcessors(options); ListPostProcessors(options);
return 0; return 0;
} }
// Validate --engine up front: autonest names a jobs engine, plain fill names a
// single-plate placement strategy. Unknown names exit with the valid choices.
if (options.AutoNest)
{
var isJobsEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
e.Name.Equals(options.Engine, StringComparison.OrdinalIgnoreCase)
);
if (!isJobsEngine)
{
Console.Error.WriteLine(
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
);
return 1;
}
}
else
{
try
{
PlateFillService.ResolveStrategy(options.Engine);
}
catch (NotSupportedException)
{
Console.Error.WriteLine(
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
);
return 1;
}
}
if (options.InputFiles.Count == 0) if (options.InputFiles.Count == 0)
{ {
PrintUsage(); PrintUsage();
@@ -82,6 +134,26 @@ static class NestConsole
{ {
switch (args[i]) switch (args[i])
{ {
case "--repair-bends-mm":
o.RepairBendsMillimeters =
i + 1 < args.Length
&& double.TryParse(
args[++i],
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var limit
)
? limit
: double.NaN;
break;
case "--cad-units" when i + 1 < args.Length:
o.CadUnits = args[++i] switch
{
"inches" => BendRepairUnits.Inches,
"mm" => BendRepairUnits.Millimeters,
_ => BendRepairUnits.Unspecified,
};
break;
case "--drawing" when i + 1 < args.Length: case "--drawing" when i + 1 < args.Length:
o.DrawingName = args[++i]; o.DrawingName = args[++i];
break; break;
@@ -117,7 +189,7 @@ static class NestConsole
o.AutoNest = true; o.AutoNest = true;
break; break;
case "--engine" when i + 1 < args.Length: case "--engine" when i + 1 < args.Length:
NestEngineRegistry.ActiveEngineName = args[++i]; o.Engine = args[++i];
break; break;
case "--post" when i + 1 < args.Length: case "--post" when i + 1 < args.Length:
o.PostName = args[++i]; o.PostName = args[++i];
@@ -149,10 +221,14 @@ static class NestConsole
{ {
var nestFile = options.InputFiles.FirstOrDefault(f => var nestFile = options.InputFiles.FirstOrDefault(f =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase) f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); || f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
var dxfFiles = options.InputFiles.Where(f => );
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) || var dxfFiles = options
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList(); .InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
.ToList();
// If we have a nest file, load it and optionally add DXFs. // If we have a nest file, load it and optionally add DXFs.
if (nestFile != null) if (nestFile != null)
@@ -167,13 +243,15 @@ static class NestConsole
if (options.PlateIndex >= nest.Plates.Count) if (options.PlateIndex >= nest.Plates.Count)
{ {
Console.Error.WriteLine($"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})"); Console.Error.WriteLine(
$"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})"
);
return null; return null;
} }
foreach (var dxf in dxfFiles) foreach (var dxf in dxfFiles)
{ {
var drawing = ImportDxf(dxf); var drawing = ImportDxf(dxf, options);
if (drawing == null) if (drawing == null)
return null; return null;
@@ -194,7 +272,9 @@ static class NestConsole
if (!options.PlateSize.HasValue) if (!options.PlateSize.HasValue)
{ {
Console.Error.WriteLine("Error: --size WxL is required when importing DXF files without a nest"); Console.Error.WriteLine(
"Error: --size WxL is required when importing DXF files without a nest"
);
return null; return null;
} }
@@ -204,7 +284,7 @@ static class NestConsole
foreach (var dxf in dxfFiles) foreach (var dxf in dxfFiles)
{ {
var drawing = ImportDxf(dxf); var drawing = ImportDxf(dxf, options);
if (drawing == null) if (drawing == null)
return null; return null;
@@ -216,11 +296,28 @@ static class NestConsole
return newNest; return newNest;
} }
static Drawing ImportDxf(string path) static Drawing ImportDxf(string path, Options options)
{ {
try try
{ {
return CadImporter.ImportDrawing(path); var result = CadImporter.Import(
path,
new CadImportOptions
{
BendRepair = options.RepairBendsMillimeters.HasValue
? new BendRepairOptions
{
DrawingUnits = options.CadUnits,
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
}
: null,
}
);
foreach (var report in result.BendRepairReports)
Console.WriteLine(
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
);
return CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null);
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
@@ -256,7 +353,8 @@ static class NestConsole
// Only apply size override when it wasn't already used to create the plate. // Only apply size override when it wasn't already used to create the plate.
var hasDxfOnly = !options.InputFiles.Any(f => var hasDxfOnly = !options.InputFiles.Any(f =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase) f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); || f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
);
if (options.PlateSize.HasValue && !hasDxfOnly) if (options.PlateSize.HasValue && !hasDxfOnly)
plate.Size = options.PlateSize.Value; plate.Size = options.PlateSize.Value;
@@ -264,33 +362,51 @@ static class NestConsole
static Drawing ResolveDrawing(Nest nest, Options options) static Drawing ResolveDrawing(Nest nest, Options options)
{ {
var drawing = options.DrawingName != null var drawing =
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName) options.DrawingName != null
: nest.Drawings.FirstOrDefault(); ? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
: nest.Drawings.FirstOrDefault();
if (drawing != null) if (drawing != null)
return drawing; return drawing;
Console.Error.WriteLine(options.DrawingName != null Console.Error.WriteLine(
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}" options.DrawingName != null
: "Error: nest file contains no drawings"); ? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
: "Error: nest file contains no drawings"
);
return null; return null;
} }
static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options) static void PrintHeader(
Nest nest,
Plate plate,
Drawing drawing,
int existingCount,
Options options
)
{ {
Console.WriteLine($"Nest: {nest.Name}"); Console.WriteLine($"Nest: {nest.Name}");
var wa = plate.WorkArea(); var wa = plate.WorkArea();
Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}"); Console.WriteLine(
$"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}"
);
Console.WriteLine($"Drawing: {drawing.Name}"); Console.WriteLine($"Drawing: {drawing.Name}");
Console.WriteLine(options.KeepParts Console.WriteLine(
? $"Keeping {existingCount} existing parts" options.KeepParts
: $"Cleared {existingCount} existing parts"); ? $"Keeping {existingCount} existing parts"
: $"Cleared {existingCount} existing parts"
);
Console.WriteLine("---"); Console.WriteLine("---");
} }
static (bool success, long elapsedMs) Fill(Nest nest, Plate plate, Drawing drawing, Options options) static (bool success, long elapsedMs) Fill(
Nest nest,
Plate plate,
Drawing drawing,
Options options
)
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
bool success; bool success;
@@ -310,33 +426,113 @@ static class NestConsole
nestItems.Add(new NestItem { Drawing = d, Quantity = qty }); nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
} }
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); Console.WriteLine(
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
);
var engine = NestEngineRegistry.Create(plate); success = AutoNestJob(plate, nestItems, options.Engine);
var nestParts = engine.Nest(nestItems, null, CancellationToken.None);
plate.Parts.AddRange(nestParts);
success = nestParts.Count > 0;
} }
else else
{ {
var engine = NestEngineRegistry.Create(plate); // Single-plate fill: explicit placement strategy through the public service;
// the process-global engine registry is never consulted.
var strategy = ResolveFillStrategy(options.Engine);
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity }; var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
success = engine.Fill(item); var parts = PlateFillService.FillItem(
strategy,
plate,
item,
plate.WorkArea(),
null,
CancellationToken.None
);
if (parts.Count > 0)
plate.Parts.AddRange(parts);
success = parts.Count > 0;
} }
sw.Stop(); sw.Stop();
return (success, sw.ElapsedMilliseconds); return (success, sw.ElapsedMilliseconds);
} }
/// <summary>
/// Solves the drawings as one whole job against this single plate using the named jobs
/// engine, then commits the returned placements onto the plate. Placements are mapped back
/// onto the caller's original drawings (same pose semantics as NestResultMaterializer), so
/// the saved nest keeps its existing drawing identities.
/// </summary>
static bool AutoNestJob(Plate plate, List<NestItem> nestItems, string engineName)
{
var engine = NestingEngineRegistry.Create(engineName);
var parts = new List<NestJobPart>(nestItems.Count);
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
for (var i = 0; i < nestItems.Count; i++)
{
var partId = $"part-{i}";
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
drawingsByPartId[partId] = nestItems[i].Drawing;
}
// One physical sheet: this plate, this solve — the runner owns stock accounting.
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
var job = new NestJob(parts, [stock]);
var result = engine.Solve(job, null, CancellationToken.None);
var committed = 0;
foreach (var plateResult in result.Plates)
{
foreach (var pose in plateResult.Placements)
{
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
continue;
var part = new Part(drawing);
part.Rotate(pose.Rotation);
part.Location = new Vector(pose.X, pose.Y);
part.UpdateBounds();
plate.Parts.Add(part);
committed++;
}
}
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
return committed > 0;
}
static string ResolveFillStrategy(string engineName)
{
try
{
return PlateFillService.ResolveStrategy(engineName);
}
catch (NotSupportedException)
{
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
);
Console.Error.WriteLine(
isJobEngine
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
);
Environment.Exit(1);
throw; // unreachable
}
}
static int CheckOverlaps(Plate plate, Options options) static int CheckOverlaps(Plate plate, Options options)
{ {
if (!options.CheckOverlaps || plate.Parts.Count == 0) if (!options.CheckOverlaps || plate.Parts.Count == 0)
return 0; return 0;
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts); var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
Console.WriteLine(hasOverlaps Console.WriteLine(
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points" hasOverlaps
: "Overlap check: PASS"); ? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
: "Overlap check: PASS"
);
return overlapPts.Count; return overlapPts.Count;
} }
@@ -355,9 +551,12 @@ static class NestConsole
return; return;
var firstInput = options.InputFiles[0]; var firstInput = options.InputFiles[0];
var outputFile = options.OutputFile ?? Path.Combine( var outputFile =
Path.GetDirectoryName(firstInput), options.OutputFile
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"); ?? Path.Combine(
Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
);
new NestWriter(nest).Write(outputFile); new NestWriter(nest).Write(outputFile);
Console.WriteLine($"Saved: {outputFile}"); Console.WriteLine($"Saved: {outputFile}");
@@ -368,8 +567,8 @@ static class NestConsole
if (options.PostsDir != null) if (options.PostsDir != null)
return options.PostsDir; return options.PostsDir;
var exePath = Assembly.GetEntryAssembly()?.Location var exePath =
?? typeof(NestConsole).Assembly.Location; Assembly.GetEntryAssembly()?.Location ?? typeof(NestConsole).Assembly.Location;
return Path.Combine(Path.GetDirectoryName(exePath), "Posts"); return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
} }
@@ -388,7 +587,11 @@ static class NestConsole
foreach (var type in assembly.GetTypes()) foreach (var type in assembly.GetTypes())
{ {
if (!typeof(IPostProcessor).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract) if (
!typeof(IPostProcessor).IsAssignableFrom(type)
|| type.IsInterface
|| type.IsAbstract
)
continue; continue;
if (Activator.CreateInstance(type) is IPostProcessor processor) if (Activator.CreateInstance(type) is IPostProcessor processor)
@@ -397,7 +600,9 @@ static class NestConsole
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine($"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}"); Console.Error.WriteLine(
$"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}"
);
} }
} }
@@ -418,7 +623,7 @@ static class NestConsole
Console.WriteLine($"Post processors ({postsDir}):"); Console.WriteLine($"Post processors ({postsDir}):");
foreach (var p in processors) foreach (var p in processors)
Console.WriteLine($" {p.Name,-30} {p.Description}"); Console.WriteLine($" {p.Name, -30} {p.Description}");
} }
static void PostProcess(Nest nest, Options options) static void PostProcess(Nest nest, Options options)
@@ -429,14 +634,17 @@ static class NestConsole
var postsDir = ResolvePostsDir(options); var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir); var processors = LoadPostProcessors(postsDir);
var post = processors.FirstOrDefault(p => var post = processors.FirstOrDefault(p =>
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase)); p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase)
);
if (post == null) if (post == null)
{ {
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found"); Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
if (processors.Count > 0) if (processors.Count > 0)
Console.Error.WriteLine($"Available: {string.Join(", ", processors.Select(p => p.Name))}"); Console.Error.WriteLine(
$"Available: {string.Join(", ", processors.Select(p => p.Name))}"
);
else else
Console.Error.WriteLine($"No post processors found in: {postsDir}"); Console.Error.WriteLine($"No post processors found in: {postsDir}");
@@ -450,7 +658,8 @@ static class NestConsole
var firstInput = options.InputFiles[0]; var firstInput = options.InputFiles[0];
outputFile = Path.Combine( outputFile = Path.Combine(
Path.GetDirectoryName(firstInput), Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc"); $"{Path.GetFileNameWithoutExtension(firstInput)}.cnc"
);
} }
post.Post(nest, outputFile); post.Post(nest, outputFile);
@@ -462,28 +671,64 @@ static class NestConsole
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]"); Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Arguments:"); Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files"); Console.Error.WriteLine(
" input-files One or more .nest nest files or .dxf/.dwg drawing files"
);
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Modes:"); Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)"); Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill"); Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill");
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings"); Console.Error.WriteLine(
" <nest.nest> <part.dxf> Load nest and add imported DXF drawings"
);
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Options:"); Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)"); Console.Error.WriteLine(
" --repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm"
);
Console.Error.WriteLine(
" --cad-units inches|mm Explicit source coordinate units required for bend repair"
);
Console.Error.WriteLine(
" --drawing <name> Drawing name to fill with (default: first drawing)"
);
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)"); Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)"); Console.Error.WriteLine(
" --quantity <n> Max parts to place (default: 0 = unlimited)"
);
Console.Error.WriteLine(" --spacing <value> Override part spacing"); Console.Error.WriteLine(" --spacing <value> Override part spacing");
Console.Error.WriteLine(" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode"); Console.Error.WriteLine(
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.nest)"); " --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode"
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)"); );
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); Console.Error.WriteLine(
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); " --output <path> Output nest file path (default: <input>-result.nest)"
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); );
Console.Error.WriteLine(
" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)"
);
Console.Error.WriteLine(
" --autonest Whole-job nesting via the jobs engine (--engine) instead of single-plate fill"
);
Console.Error.WriteLine(
" --engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...)."
);
Console.Error.WriteLine(
" Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)"
);
Console.Error.WriteLine(
" --keep-parts Don't clear existing parts before filling"
);
Console.Error.WriteLine(
" --check-overlaps Run overlap detection after fill (exit code 1 if found)"
);
Console.Error.WriteLine(" --no-save Skip saving output file"); Console.Error.WriteLine(" --no-save Skip saving output file");
Console.Error.WriteLine(" --post <name> Run a post processor after nesting"); Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)"); Console.Error.WriteLine(
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)"); " --post-output <path> Output file for post processor (default: <input>.cnc)"
);
Console.Error.WriteLine(
" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)"
);
Console.Error.WriteLine(" --list-posts List available post processors and exit"); Console.Error.WriteLine(" --list-posts List available post processors and exit");
Console.Error.WriteLine(" -h, --help Show this help"); Console.Error.WriteLine(" -h, --help Show this help");
} }
@@ -501,10 +746,13 @@ static class NestConsole
public bool NoSave; public bool NoSave;
public bool KeepParts; public bool KeepParts;
public bool AutoNest; public bool AutoNest;
public string Engine = "Default";
public string TemplateFile; public string TemplateFile;
public string PostName; public string PostName;
public string PostOutput; public string PostOutput;
public string PostsDir; public string PostsDir;
public bool ListPosts; public bool ListPosts;
public double? RepairBendsMillimeters;
public BendRepairUnits CadUnits;
} }
} }
+35 -12
View File
@@ -1,5 +1,5 @@
using OpenNest.Geometry; using System.Collections.Generic;
using System.Collections.Generic; using OpenNest.Geometry;
namespace OpenNest namespace OpenNest
{ {
@@ -7,7 +7,10 @@ namespace OpenNest
{ {
public static void Vertically(Entity fixedEntity, Entity movableEntity) public static void Vertically(Entity fixedEntity, Entity movableEntity)
{ {
movableEntity.Offset(fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X, 0); movableEntity.Offset(
fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X,
0
);
} }
public static void Vertically(Entity fixedEntity, List<Entity> entities) public static void Vertically(Entity fixedEntity, List<Entity> entities)
@@ -17,7 +20,10 @@ namespace OpenNest
public static void Vertically(Part fixedPart, Part movablePart) public static void Vertically(Part fixedPart, Part movablePart)
{ {
movablePart.Offset(fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X, 0); movablePart.Offset(
fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X,
0
);
} }
public static void Vertically(Part fixedPart, List<Part> parts) public static void Vertically(Part fixedPart, List<Part> parts)
@@ -27,7 +33,10 @@ namespace OpenNest
public static void Horizontally(Entity fixedEntity, Entity movableEntity) public static void Horizontally(Entity fixedEntity, Entity movableEntity)
{ {
movableEntity.Offset(0, fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y); movableEntity.Offset(
0,
fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y
);
} }
public static void Horizontally(Entity fixedEntity, List<Entity> entities) public static void Horizontally(Entity fixedEntity, List<Entity> entities)
@@ -37,7 +46,10 @@ namespace OpenNest
public static void Horizontally(Part fixedPart, Part movablePart) public static void Horizontally(Part fixedPart, Part movablePart)
{ {
movablePart.Offset(0, fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y); movablePart.Offset(
0,
fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y
);
} }
public static void Horizontally(Part fixedPart, List<Part> parts) public static void Horizontally(Part fixedPart, List<Part> parts)
@@ -67,7 +79,10 @@ namespace OpenNest
public static void Right(Entity fixedEntity, Entity movableEntity) public static void Right(Entity fixedEntity, Entity movableEntity)
{ {
movableEntity.Offset(fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right, 0); movableEntity.Offset(
fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right,
0
);
} }
public static void Right(Entity fixedEntity, List<Entity> entities) public static void Right(Entity fixedEntity, List<Entity> entities)
@@ -107,7 +122,10 @@ namespace OpenNest
public static void Bottom(Entity fixedEntity, Entity movableEntity) public static void Bottom(Entity fixedEntity, Entity movableEntity)
{ {
movableEntity.Offset(0, fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom); movableEntity.Offset(
0,
fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom
);
} }
public static void Bottom(Entity fixedEntity, List<Entity> entities) public static void Bottom(Entity fixedEntity, List<Entity> entities)
@@ -137,14 +155,19 @@ namespace OpenNest
return; return;
var list = new List<Part>(parts); var list = new List<Part>(parts);
list.Sort((p1, p2) => horizontal list.Sort(
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X) (p1, p2) =>
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y)); horizontal
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y)
);
var lastIndex = list.Count - 1; var lastIndex = list.Count - 1;
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y; var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y; var end = horizontal
? list[lastIndex].BoundingBox.Center.X
: list[lastIndex].BoundingBox.Center.Y;
var spacing = (end - start) / lastIndex; var spacing = (end - start) / lastIndex;
+2 -3
View File
@@ -1,5 +1,4 @@
 namespace OpenNest
namespace OpenNest
{ {
public enum AlignType public enum AlignType
{ {
@@ -10,6 +9,6 @@ namespace OpenNest
Horizontally, Horizontally,
Vertically, Vertically,
EvenlySpaceHorizontally, EvenlySpaceHorizontally,
EvenlySpaceVertically EvenlySpaceVertically,
} }
} }
+16 -9
View File
@@ -1,7 +1,7 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Bending namespace OpenNest.Bending
{ {
@@ -10,7 +10,7 @@ namespace OpenNest.Bending
public static readonly Layer EtchLayer = new Layer("ETCH") public static readonly Layer EtchLayer = new Layer("ETCH")
{ {
Color = Color.Green, Color = Color.Green,
IsVisible = true IsVisible = true,
}; };
private const double DefaultEtchLength = 1.0; private const double DefaultEtchLength = 1.0;
@@ -32,9 +32,8 @@ namespace OpenNest.Bending
public double Length => StartPoint.DistanceTo(EndPoint); public double Length => StartPoint.DistanceTo(EndPoint);
public double AngleRadians => Angle.HasValue public double AngleRadians =>
? OpenNest.Math.Angle.ToRadians(Angle.Value) Angle.HasValue ? OpenNest.Math.Angle.ToRadians(Angle.Value) : 0;
: 0;
public Line ToLine() => new Line(StartPoint, EndPoint); public Line ToLine() => new Line(StartPoint, EndPoint);
@@ -66,7 +65,9 @@ namespace OpenNest.Bending
var dx = System.Math.Cos(angle) * etchLength; var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength; var dy = System.Math.Sin(angle) * etchLength;
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))); result.Add(
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
);
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint)); result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
} }
@@ -79,7 +80,8 @@ namespace OpenNest.Bending
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends) public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
{ {
entities.RemoveAll(e => e.Tag == BendEtchTag); entities.RemoveAll(e => e.Tag == BendEtchTag);
if (bends == null) return; if (bends == null)
return;
foreach (var bend in bends) foreach (var bend in bends)
entities.AddRange(bend.GetEtchEntities()); entities.AddRange(bend.GetEtchEntities());
@@ -87,7 +89,12 @@ namespace OpenNest.Bending
private static Line CreateEtchLine(Vector start, Vector end) private static Line CreateEtchLine(Vector start, Vector end)
{ {
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green, Tag = BendEtchTag }; return new Line(start, end)
{
Layer = EtchLayer,
Color = Color.Green,
Tag = BendEtchTag,
};
} }
public override string ToString() public override string ToString()
+1 -1
View File
@@ -4,6 +4,6 @@ namespace OpenNest.Bending
{ {
Unknown, Unknown,
Up, Up,
Down Down,
} }
} }
+19 -12
View File
@@ -5,16 +5,22 @@ namespace OpenNest.CNC
{ {
public class ArcMove : Motion public class ArcMove : Motion
{ {
public ArcMove() public ArcMove() { }
{
}
public ArcMove(double x, double y, double i, double j, RotationType rotation = RotationType.CCW) public ArcMove(
: this(new Vector(x, y), new Vector(i, j), rotation) double x,
{ double y,
} double i,
double j,
RotationType rotation = RotationType.CCW
)
: this(new Vector(x, y), new Vector(i, j), rotation) { }
public ArcMove(Vector endPoint, Vector centerPoint, RotationType rotation = RotationType.CCW) public ArcMove(
Vector endPoint,
Vector centerPoint,
RotationType rotation = RotationType.CCW
)
{ {
EndPoint = endPoint; EndPoint = endPoint;
CenterPoint = centerPoint; CenterPoint = centerPoint;
@@ -68,7 +74,8 @@ namespace OpenNest.CNC
{ {
Layer = Layer, Layer = Layer,
Suppressed = Suppressed, Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null VariableRefs =
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
}; };
} }
@@ -85,9 +92,9 @@ namespace OpenNest.CNC
var i = CenterPoint.X.ToString(dp); var i = CenterPoint.X.ToString(dp);
var j = CenterPoint.Y.ToString(dp); var j = CenterPoint.Y.ToString(dp);
return Rotation == RotationType.CW ? return Rotation == RotationType.CW
string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j) : ? string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j)
string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j); : string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j);
} }
} }
} }
+2 -3
View File
@@ -1,5 +1,4 @@
 namespace OpenNest.CNC
namespace OpenNest.CNC
{ {
public enum CodeType public enum CodeType
{ {
@@ -9,6 +8,6 @@ namespace OpenNest.CNC
RapidMove, RapidMove,
SetFeedrate, SetFeedrate,
SetKerf, SetKerf,
SubProgramCall SubProgramCall,
} }
} }
+1 -3
View File
@@ -2,9 +2,7 @@
{ {
public class Comment : ICode public class Comment : ICode
{ {
public Comment() public Comment() { }
{
}
public Comment(string value) public Comment(string value)
{ {
@@ -1,7 +1,7 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -48,7 +48,12 @@ namespace OpenNest.CNC.CuttingStrategy
for (var iter = 0; iter < 3; iter++) for (var iter = 0; iter < 3; iter++)
{ {
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point; var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _); perimeterSeed = FindPerimeterIntersection(
profile.Perimeter,
lastCutoutPt,
nextPartStart,
out _
);
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed); orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
orderedCutouts.Reverse(); orderedCutouts.Reverse();
@@ -56,7 +61,12 @@ namespace OpenNest.CNC.CuttingStrategy
} }
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point; var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity); perimeterPt = FindPerimeterIntersection(
profile.Perimeter,
finalLastCutout,
nextPartStart,
out perimeterEntity
);
} }
else else
{ {
@@ -79,18 +89,25 @@ namespace OpenNest.CNC.CuttingStrategy
if (!profile.Perimeter.IsClosed()) if (!profile.Perimeter.IsClosed())
EmitRawContour(result, profile.Perimeter); EmitRawContour(result, profile.Perimeter);
else else
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External); EmitContour(
result,
profile.Perimeter,
perimeterPt,
perimeterEntity,
ContourType.External
);
result.Mode = Mode.Incremental; result.Mode = Mode.Incremental;
return new CuttingResult return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
{
Program = result,
LastCutPoint = perimeterPt
};
} }
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType) public CuttingResult ApplySingle(
Program partProgram,
Vector point,
Entity entity,
ContourType contourType
)
{ {
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -141,14 +158,14 @@ namespace OpenNest.CNC.CuttingStrategy
result.Mode = Mode.Incremental; result.Mode = Mode.Incremental;
return new CuttingResult return new CuttingResult { Program = result, LastCutPoint = point };
{
Program = result,
LastCutPoint = point
};
} }
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity) private static (Shape Shape, Entity Entity) FindTargetShape(
ShapeProfile profile,
Vector point,
Entity clickedEntity
)
{ {
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity); var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
if (matched != null) if (matched != null)
@@ -190,20 +207,26 @@ namespace OpenNest.CNC.CuttingStrategy
if (shapeEntity is Line sLine && clickedEntity is Line cLine) if (shapeEntity is Line sLine && clickedEntity is Line cLine)
{ {
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon if (
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon) sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon
)
return shapeEntity; return shapeEntity;
} }
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc) else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
{ {
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon if (
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon) System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon
)
return shapeEntity; return shapeEntity;
} }
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle) else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
{ {
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon if (
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon) System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon
)
return shapeEntity; return shapeEntity;
} }
} }
@@ -218,7 +241,10 @@ namespace OpenNest.CNC.CuttingStrategy
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint)); program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
} }
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint) private static List<ContourEntry> ResolveLeadInPoints(
List<Shape> cutouts,
Vector startPoint
)
{ {
var entries = new ContourEntry[cutouts.Count]; var entries = new ContourEntry[cutouts.Count];
var currentPoint = startPoint; var currentPoint = startPoint;
@@ -235,7 +261,12 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ContourEntry>(entries); return new List<ContourEntry>(entries);
} }
private static Vector FindPerimeterIntersection(Shape perimeter, Vector lastCutout, Vector nextPartStart, out Entity entity) private static Vector FindPerimeterIntersection(
Shape perimeter,
Vector lastCutout,
Vector nextPartStart,
out Entity entity
)
{ {
var ray = new Line(lastCutout, nextPartStart); var ray = new Line(lastCutout, nextPartStart);
@@ -269,7 +300,13 @@ namespace OpenNest.CNC.CuttingStrategy
return HashCode.Combine(r, a); return HashCode.Combine(r, a);
} }
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null) private void EmitContour(
Program program,
Shape shape,
Vector point,
Entity entity,
ContourType? forceType = null
)
{ {
var contourType = forceType ?? DetectContourType(shape); var contourType = forceType ?? DetectContourType(shape);
var winding = DetermineWinding(shape); var winding = DetermineWinding(shape);
@@ -289,7 +326,8 @@ namespace OpenNest.CNC.CuttingStrategy
var outwardAngle = normal - System.Math.PI; var outwardAngle = normal - System.Math.PI;
point = new Vector( point = new Vector(
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle), circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle)); circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle)
);
} }
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal); leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
@@ -297,7 +335,10 @@ namespace OpenNest.CNC.CuttingStrategy
// Build hole sub-program relative to (0,0) // Build hole sub-program relative to (0,0)
var holeCenter = circle.Center; var holeCenter = circle.Center;
var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y); var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y);
var relativeCircle = new Circle(new Vector(0, 0), circle.Radius) { Rotation = circle.Rotation }; var relativeCircle = new Circle(new Vector(0, 0), circle.Radius)
{
Rotation = circle.Rotation,
};
var relativeShape = new Shape(); var relativeShape = new Shape();
relativeShape.Entities.Add(relativeCircle); relativeShape.Entities.Add(relativeCircle);
@@ -314,12 +355,14 @@ namespace OpenNest.CNC.CuttingStrategy
if (!program.SubPrograms.ContainsKey(key)) if (!program.SubPrograms.ContainsKey(key))
program.SubPrograms[key] = subPgm; program.SubPrograms[key] = subPgm;
program.Codes.Add(new SubProgramCall program.Codes.Add(
{ new SubProgramCall
Id = key, {
Program = program.SubPrograms[key], Id = key,
Offset = holeCenter Program = program.SubPrograms[key],
}); Offset = holeCenter,
}
);
return; return;
} }
@@ -328,7 +371,11 @@ namespace OpenNest.CNC.CuttingStrategy
var reindexedShape = shape.ReindexAt(point, entity); var reindexedShape = shape.ReindexAt(point, entity);
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External) if (
Parameters.TabsEnabled
&& Parameters.TabConfig != null
&& contourType == ContourType.External
)
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size); reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point)); program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
@@ -337,7 +384,8 @@ namespace OpenNest.CNC.CuttingStrategy
private void EmitScribeContours(Program program, List<Entity> scribeEntities) private void EmitScribeContours(Program program, List<Entity> scribeEntities)
{ {
if (scribeEntities.Count == 0) return; if (scribeEntities.Count == 0)
return;
var shapes = ShapeBuilder.GetShapes(scribeEntities); var shapes = ShapeBuilder.GetShapes(scribeEntities);
foreach (var shape in shapes) foreach (var shape in shapes)
@@ -388,8 +436,12 @@ namespace OpenNest.CNC.CuttingStrategy
return ContourType.Internal; return ContourType.Internal;
} }
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType, public static double ComputeNormal(
RotationType winding = RotationType.CW) Vector point,
Entity entity,
ContourType contourType,
RotationType winding = RotationType.CW
)
{ {
double normal; double normal;
@@ -442,7 +494,12 @@ namespace OpenNest.CNC.CuttingStrategy
return polygon.RotationDirection(); return polygon.RotationDirection();
} }
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle) private LeadIn ClampLeadInForCircle(
LeadIn leadIn,
Circle circle,
Vector contourPoint,
double normalAngle
)
{ {
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0) if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
return leadIn; return leadIn;
@@ -492,7 +549,7 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn, ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
ContourType.Internal => Parameters.InternalLeadIn, ContourType.Internal => Parameters.InternalLeadIn,
_ => Parameters.ExternalLeadIn _ => Parameters.ExternalLeadIn,
}; };
} }
@@ -502,7 +559,7 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut, ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
ContourType.Internal => Parameters.InternalLeadOut, ContourType.Internal => Parameters.InternalLeadOut,
_ => Parameters.ExternalLeadOut _ => Parameters.ExternalLeadOut,
}; };
} }
@@ -565,12 +622,18 @@ namespace OpenNest.CNC.CuttingStrategy
private static Vector EntityStartPoint(Entity entity) private static Vector EntityStartPoint(Entity entity)
{ {
if (entity is Line line) return line.StartPoint; if (entity is Line line)
if (entity is Arc arc) return arc.StartPoint(); return line.StartPoint;
if (entity is Arc arc)
return arc.StartPoint();
return Vector.Zero; return Vector.Zero;
} }
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint, LayerType layer = LayerType.Display) private List<ICode> ConvertShapeToMoves(
Shape shape,
Vector startPoint,
LayerType layer = LayerType.Display
)
{ {
var moves = new List<ICode>(); var moves = new List<ICode>();
@@ -582,15 +645,28 @@ namespace OpenNest.CNC.CuttingStrategy
} }
else if (entity is Arc arc) else if (entity is Arc arc)
{ {
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer }); moves.Add(
new ArcMove(
arc.EndPoint(),
arc.Center,
arc.IsReversed ? RotationType.CW : RotationType.CCW
)
{
Layer = layer,
}
);
} }
else if (entity is Circle circle) else if (entity is Circle circle)
{ {
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }); moves.Add(
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
);
} }
else else
{ {
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}"); throw new System.InvalidOperationException(
$"Unsupported entity type: {entity.Type}"
);
} }
} }
@@ -600,9 +676,12 @@ namespace OpenNest.CNC.CuttingStrategy
private static Vector GetShapeStartPoint(Shape shape) private static Vector GetShapeStartPoint(Shape shape)
{ {
var first = shape.Entities[0]; var first = shape.Entities[0];
if (first is Line line) return line.StartPoint; if (first is Line line)
if (first is Arc arc) return arc.StartPoint(); return line.StartPoint;
if (first is Circle circle) return new Vector(circle.Center.X + circle.Radius, circle.Center.Y); if (first is Arc arc)
return arc.StartPoint();
if (first is Circle circle)
return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
return Vector.Zero; return Vector.Zero;
} }
} }
@@ -4,6 +4,6 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
External, External,
Internal, Internal,
ArcCircle ArcCircle,
} }
} }
@@ -15,7 +15,8 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn(); public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut(); public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }; public LeadIn InternalLeadIn { get; set; } =
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut(); public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn(); public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -7,19 +7,23 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
public double Radius { get; set; } public double Radius { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenter = new Vector( var arcCenter = new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle), contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)); contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin } new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
}; };
} }
@@ -30,10 +34,10 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector( return new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle), arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)); arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
new ArcLeadIn { Radius = Radius * factor };
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double ArcRadius { get; set; } public double ArcRadius { get; set; }
public double Kerf { get; set; } public double Kerf { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0); var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStart = new Vector( var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle), arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)); arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin }, new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin } new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
}; };
} }
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector( return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle), arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)); arcStartY + LineLength * System.Math.Sin(lineAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, Kerf = Kerf }; new CleanHoleLeadIn
{
LineLength = LineLength * factor,
ArcRadius = ArcRadius * factor,
Kerf = Kerf,
};
} }
} }
@@ -1,12 +1,15 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public abstract class LeadIn public abstract class LeadIn
{ {
public abstract List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public abstract List<ICode> Generate(
RotationType winding = RotationType.CW); Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle); public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double ApproachAngle { get; set; } = 135.0; public double ApproachAngle { get; set; } = 135.0;
public double ArcRadius { get; set; } public double ArcRadius { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStart = new Vector( var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle), arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)); arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin }, new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin } new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
}; };
} }
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector( return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle), arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)); arcStartY + LineLength * System.Math.Sin(lineAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new LineArcLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, ApproachAngle = ApproachAngle }; new LineArcLeadIn
{
LineLength = LineLength * factor,
ArcRadius = ArcRadius * factor,
ApproachAngle = ApproachAngle,
};
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -9,15 +9,18 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length { get; set; } public double Length { get; set; }
public double ApproachAngle { get; set; } = 90.0; public double ApproachAngle { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin } new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
}; };
} }
@@ -26,7 +29,8 @@ namespace OpenNest.CNC.CuttingStrategy
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle); var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
return new Vector( return new Vector(
contourStartPoint.X + Length * System.Math.Cos(approachAngle), contourStartPoint.X + Length * System.Math.Cos(approachAngle),
contourStartPoint.Y + Length * System.Math.Sin(approachAngle)); contourStartPoint.Y + Length * System.Math.Sin(approachAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -11,21 +11,25 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length2 { get; set; } public double Length2 { get; set; }
public double ApproachAngle2 { get; set; } = 90.0; public double ApproachAngle2 { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1); var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midPoint = new Vector( var midPoint = new Vector(
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle), contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)); contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(midPoint) { Layer = LayerType.Leadin }, new LinearMove(midPoint) { Layer = LayerType.Leadin },
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin } new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
}; };
} }
@@ -38,10 +42,17 @@ namespace OpenNest.CNC.CuttingStrategy
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2); var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
return new Vector( return new Vector(
midX + Length1 * System.Math.Cos(firstAngle), midX + Length1 * System.Math.Cos(firstAngle),
midY + Length1 * System.Math.Sin(firstAngle)); midY + Length1 * System.Math.Sin(firstAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new LineLineLeadIn { Length1 = Length1 * factor, ApproachAngle1 = ApproachAngle1, Length2 = Length2 * factor, ApproachAngle2 = ApproachAngle2 }; new LineLineLeadIn
{
Length1 = Length1 * factor,
ApproachAngle1 = ApproachAngle1,
Length2 = Length2 * factor,
ApproachAngle2 = ApproachAngle2,
};
} }
} }
@@ -1,17 +1,17 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public class NoLeadIn : LeadIn public class NoLeadIn : LeadIn
{ {
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
return new List<ICode> return new List<ICode> { new RapidMove(contourStartPoint) };
{
new RapidMove(contourStartPoint)
};
} }
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -7,8 +7,11 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
public double Radius { get; set; } public double Radius { get; set; }
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle); var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle); var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
@@ -16,11 +19,12 @@ namespace OpenNest.CNC.CuttingStrategy
var endPoint = new Vector( var endPoint = new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2), arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)); arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
);
return new List<ICode> return new List<ICode>
{ {
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout } new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout },
}; };
} }
} }
@@ -1,11 +1,14 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public abstract class LeadOut public abstract class LeadOut
{ {
public abstract List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public abstract List<ICode> Generate(
RotationType winding = RotationType.CW); Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -9,18 +9,19 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length { get; set; } public double Length { get; set; }
public double ApproachAngle { get; set; } = 90.0; public double ApproachAngle { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle); var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
var endPoint = new Vector( var endPoint = new Vector(
contourEndPoint.X + Length * System.Math.Cos(overcutAngle), contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)); contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)
);
return new List<ICode> return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
{
new LinearMove(endPoint) { Layer = LayerType.Leadout }
};
} }
} }
} }
@@ -1,12 +1,15 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public class NoLeadOut : LeadOut public class NoLeadOut : LeadOut
{ {
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
return new List<ICode>(); return new List<ICode>();
} }
@@ -9,7 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
BottomSide = 4, BottomSide = 4,
EdgeStart = 5, EdgeStart = 5,
LeftSide = 7, LeftSide = 7,
RightSideAlt = 8 RightSideAlt = 8,
} }
public class SequenceParameters public class SequenceParameters
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double BreakerAngle { get; set; } public double BreakerAngle { get; set; }
public override List<ICode> Generate( public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, Vector tabStartPoint,
RotationType winding = RotationType.CW) Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var codes = new List<ICode>(); var codes = new List<ICode>();
@@ -21,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
var scoreAngle = contourNormalAngle + System.Math.PI; var scoreAngle = contourNormalAngle + System.Math.PI;
var scoreEnd = new Vector( var scoreEnd = new Vector(
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle), tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle),
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)); tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)
);
codes.Add(new LinearMove(scoreEnd)); codes.Add(new LinearMove(scoreEnd));
codes.Add(new RapidMove(tabEndPoint)); codes.Add(new RapidMove(tabEndPoint));
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -8,13 +8,13 @@ namespace OpenNest.CNC.CuttingStrategy
public int MachineTabId { get; set; } public int MachineTabId { get; set; }
public override List<ICode> Generate( public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, Vector tabStartPoint,
RotationType winding = RotationType.CW) Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
return new List<ICode> return new List<ICode> { new RapidMove(tabEndPoint) };
{
new RapidMove(tabEndPoint)
};
} }
} }
} }
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -11,8 +11,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double CutoutMaxHeight { get; set; } public double CutoutMaxHeight { get; set; }
public override List<ICode> Generate( public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, Vector tabStartPoint,
RotationType winding = RotationType.CW) Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var codes = new List<ICode>(); var codes = new List<ICode>();
@@ -29,8 +32,10 @@ namespace OpenNest.CNC.CuttingStrategy
public bool AppliesToCutout(double cutoutWidth, double cutoutHeight) public bool AppliesToCutout(double cutoutWidth, double cutoutHeight)
{ {
return cutoutWidth >= CutoutMinWidth && cutoutWidth <= CutoutMaxWidth return cutoutWidth >= CutoutMinWidth
&& cutoutHeight >= CutoutMinHeight && cutoutHeight <= CutoutMaxHeight; && cutoutWidth <= CutoutMaxWidth
&& cutoutHeight >= CutoutMinHeight
&& cutoutHeight <= CutoutMaxHeight;
} }
} }
} }
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -10,7 +10,10 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadOut TabLeadOut { get; set; } public LeadOut TabLeadOut { get; set; }
public abstract List<ICode> Generate( public abstract List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, Vector tabStartPoint,
RotationType winding = RotationType.CW); Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
} }
} }
+1 -3
View File
@@ -6,9 +6,7 @@
public const int UseMax = -2; public const int UseMax = -2;
public Feedrate() public Feedrate() { }
{
}
public Feedrate(double value) public Feedrate(double value)
{ {
+2 -3
View File
@@ -1,10 +1,9 @@
 namespace OpenNest.CNC
namespace OpenNest.CNC
{ {
public enum KerfType public enum KerfType
{ {
None, None,
Left, Left,
Right Right,
} }
} }
+2 -3
View File
@@ -1,5 +1,4 @@
 namespace OpenNest.CNC
namespace OpenNest.CNC
{ {
public enum LayerType public enum LayerType
{ {
@@ -7,6 +6,6 @@ namespace OpenNest.CNC
Scribe, Scribe,
Cut, Cut,
Leadin, Leadin,
Leadout Leadout,
} }
} }
+4 -7
View File
@@ -6,14 +6,10 @@ namespace OpenNest.CNC
public class LinearMove : Motion public class LinearMove : Motion
{ {
public LinearMove() public LinearMove()
: this(new Vector()) : this(new Vector()) { }
{
}
public LinearMove(double x, double y) public LinearMove(double x, double y)
: this(new Vector(x, y)) : this(new Vector(x, y)) { }
{
}
public LinearMove(Vector endPoint) public LinearMove(Vector endPoint)
{ {
@@ -34,7 +30,8 @@ namespace OpenNest.CNC
{ {
Layer = Layer, Layer = Layer,
Suppressed = Suppressed, Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null VariableRefs =
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
}; };
} }
+2 -3
View File
@@ -1,9 +1,8 @@
 namespace OpenNest.CNC
namespace OpenNest.CNC
{ {
public enum Mode public enum Mode
{ {
Absolute, Absolute,
Incremental Incremental,
} }
} }
+206 -182
View File
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.CNC namespace OpenNest.CNC
{ {
@@ -10,7 +10,8 @@ namespace OpenNest.CNC
{ {
public List<ICode> Codes; public List<ICode> Codes;
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, VariableDefinition> Variables { get; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<int, Program> SubPrograms { get; } = new(); public Dictionary<int, Program> SubPrograms { get; } = new();
@@ -66,9 +67,17 @@ namespace OpenNest.CNC
{ {
if (code is Motion m) if (code is Motion m)
{ {
var cmd = m is RapidMove ? "G00" : (m is ArcMove am ? (am.Rotation == RotationType.CW ? "G02" : "G03") : "G01"); var cmd =
m is RapidMove
? "G00"
: (
m is ArcMove am
? (am.Rotation == RotationType.CW ? "G02" : "G03")
: "G01"
);
sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}"); sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}");
if (m is ArcMove arc) sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}"); if (m is ArcMove arc)
sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
sb.AppendLine(); sb.AppendLine();
} }
} }
@@ -97,7 +106,8 @@ namespace OpenNest.CNC
var dy = subpgm.Offset.Y - origin.Y; var dy = subpgm.Offset.Y - origin.Y;
subpgm.Offset = new Geometry.Vector( subpgm.Offset = new Geometry.Vector(
origin.X + dx * cos - dy * sin, origin.X + dx * cos - dy * sin,
origin.Y + dx * sin + dy * cos); origin.Y + dx * sin + dy * cos
);
} }
if (subpgm.Program != null) if (subpgm.Program != null)
@@ -130,8 +140,7 @@ namespace OpenNest.CNC
if (code is SubProgramCall subpgm) if (code is SubProgramCall subpgm)
{ {
subpgm.Offset = new Geometry.Vector( subpgm.Offset = new Geometry.Vector(subpgm.Offset.X + x, subpgm.Offset.Y + y);
subpgm.Offset.X + x, subpgm.Offset.Y + y);
} }
if (code is Motion == false) if (code is Motion == false)
@@ -159,7 +168,9 @@ namespace OpenNest.CNC
if (code is SubProgramCall subpgm) if (code is SubProgramCall subpgm)
{ {
subpgm.Offset = new Geometry.Vector( subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y); subpgm.Offset.X + voffset.X,
subpgm.Offset.Y + voffset.Y
);
} }
if (code is Motion == false) if (code is Motion == false)
@@ -258,56 +269,63 @@ namespace OpenNest.CNC
switch (Mode) switch (Mode)
{ {
case Mode.Absolute: case Mode.Absolute:
{
for (int i = Codes.Count; i >= 0; --i)
{ {
for (int i = Codes.Count; i >= 0; --i) var code = Codes[i];
{ var motion = code as Motion;
var code = Codes[i];
var motion = code as Motion;
if (motion == null) continue; if (motion == null)
continue;
return motion.EndPoint; return motion.EndPoint;
}
break;
} }
break;
}
case Mode.Incremental: case Mode.Incremental:
{
var pos = new Vector(0, 0);
for (int i = 0; i < Codes.Count; ++i)
{ {
var pos = new Vector(0, 0); var code = Codes[i];
var motion = code as Motion;
for (int i = 0; i < Codes.Count; ++i) if (motion == null)
{ continue;
var code = Codes[i];
var motion = code as Motion;
if (motion == null) continue; pos += motion.EndPoint;
pos += motion.EndPoint;
}
return pos;
} }
return pos;
}
} }
return new Vector(0, 0); return new Vector(0, 0);
} }
/// <summary>
/// Bounding box of the geometry the program visits. The tool's starting position is not
/// part of the geometry, so the origin only contributes when the program reaches it.
/// An empty program returns a zero-size box at the origin.
/// </summary>
public Box BoundingBox() public Box BoundingBox()
{ {
var origin = new Vector(0, 0); var origin = new Vector(0, 0);
return BoundingBox(ref origin); return BoundingBox(ref origin, out var box) ? box : new Box(0, 0, 0, 0);
} }
private Box BoundingBox(ref Vector pos) private bool BoundingBox(ref Vector pos, out Box result)
{ {
// Capture the frame origin at entry. Sub-program Offsets and // Capture the frame origin at entry. Sub-program Offsets and
// absolute-mode endpoints are relative to this fixed origin. // absolute-mode endpoints are relative to this fixed origin.
var frameOrigin = pos; var frameOrigin = pos;
double minX = 0.0; var minX = double.PositiveInfinity;
double minY = 0.0; var minY = double.PositiveInfinity;
double maxX = 0.0; var maxX = double.NegativeInfinity;
double maxY = 0.0; var maxY = double.NegativeInfinity;
for (int i = 0; i < Codes.Count; ++i) for (int i = 0; i < Codes.Count; ++i)
{ {
@@ -316,174 +334,180 @@ namespace OpenNest.CNC
switch (code.Type) switch (code.Type)
{ {
case CodeType.LinearMove: case CodeType.LinearMove:
{ {
var line = (LinearMove)code; var line = (LinearMove)code;
var pt = Mode == Mode.Absolute ? var pt =
frameOrigin + line.EndPoint : Mode == Mode.Absolute
line.EndPoint + pos;
if (pt.X > maxX)
maxX = pt.X;
else if (pt.X < minX)
minX = pt.X;
if (pt.Y > maxY)
maxY = pt.Y;
else if (pt.Y < minY)
minY = pt.Y;
pos = pt;
break;
}
case CodeType.RapidMove:
{
var line = (RapidMove)code;
var pt = Mode == Mode.Absolute
? frameOrigin + line.EndPoint ? frameOrigin + line.EndPoint
: line.EndPoint + pos; : line.EndPoint + pos;
if (pt.X > maxX) if (pt.X > maxX)
maxX = pt.X; maxX = pt.X;
else if (pt.X < minX) if (pt.X < minX)
minX = pt.X; minX = pt.X;
if (pt.Y > maxY) if (pt.Y > maxY)
maxY = pt.Y; maxY = pt.Y;
else if (pt.Y < minY) if (pt.Y < minY)
minY = pt.Y; minY = pt.Y;
pos = pt; pos = pt;
break; break;
} }
case CodeType.RapidMove:
{
var line = (RapidMove)code;
var pt =
Mode == Mode.Absolute
? frameOrigin + line.EndPoint
: line.EndPoint + pos;
if (pt.X > maxX)
maxX = pt.X;
if (pt.X < minX)
minX = pt.X;
if (pt.Y > maxY)
maxY = pt.Y;
if (pt.Y < minY)
minY = pt.Y;
pos = pt;
break;
}
case CodeType.ArcMove: case CodeType.ArcMove:
{
var arc = (ArcMove)code;
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
Vector endpt;
Vector centerpt;
if (Mode == Mode.Incremental)
{ {
var arc = (ArcMove)code; endpt = arc.EndPoint + pos;
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint); centerpt = arc.CenterPoint + pos;
Vector endpt;
Vector centerpt;
if (Mode == Mode.Incremental)
{
endpt = arc.EndPoint + pos;
centerpt = arc.CenterPoint + pos;
}
else
{
endpt = frameOrigin + arc.EndPoint;
centerpt = frameOrigin + arc.CenterPoint;
}
double minX1;
double minY1;
double maxX1;
double maxY1;
if (pos.X < endpt.X)
{
minX1 = pos.X;
maxX1 = endpt.X;
}
else
{
minX1 = endpt.X;
maxX1 = pos.X;
}
if (pos.Y < endpt.Y)
{
minY1 = pos.Y;
maxY1 = endpt.Y;
}
else
{
minY1 = endpt.Y;
maxY1 = pos.Y;
}
var startAngle = pos.AngleFrom(centerpt);
var endAngle = endpt.AngleFrom(centerpt);
// switch the angle to counter clockwise.
if (arc.Rotation == RotationType.CW)
Generic.Swap(ref startAngle, ref endAngle);
startAngle = Angle.NormalizeRad(startAngle);
endAngle = Angle.NormalizeRad(endAngle);
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
maxY1 = centerpt.Y + radius;
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
minX1 = centerpt.X - radius;
const double oneHalfPI = System.Math.PI * 1.5;
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
minY1 = centerpt.Y - radius;
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
maxX1 = centerpt.X + radius;
if (maxX1 > maxX)
maxX = maxX1;
if (minX1 < minX)
minX = minX1;
if (maxY1 > maxY)
maxY = maxY1;
if (minY1 < minY)
minY = minY1;
pos = endpt;
break;
} }
else
{
endpt = frameOrigin + arc.EndPoint;
centerpt = frameOrigin + arc.CenterPoint;
}
double minX1;
double minY1;
double maxX1;
double maxY1;
if (pos.X < endpt.X)
{
minX1 = pos.X;
maxX1 = endpt.X;
}
else
{
minX1 = endpt.X;
maxX1 = pos.X;
}
if (pos.Y < endpt.Y)
{
minY1 = pos.Y;
maxY1 = endpt.Y;
}
else
{
minY1 = endpt.Y;
maxY1 = pos.Y;
}
var startAngle = pos.AngleFrom(centerpt);
var endAngle = endpt.AngleFrom(centerpt);
// switch the angle to counter clockwise.
if (arc.Rotation == RotationType.CW)
Generic.Swap(ref startAngle, ref endAngle);
startAngle = Angle.NormalizeRad(startAngle);
endAngle = Angle.NormalizeRad(endAngle);
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
maxY1 = centerpt.Y + radius;
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
minX1 = centerpt.X - radius;
const double oneHalfPI = System.Math.PI * 1.5;
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
minY1 = centerpt.Y - radius;
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
maxX1 = centerpt.X + radius;
if (maxX1 > maxX)
maxX = maxX1;
if (minX1 < minX)
minX = minX1;
if (maxY1 > maxY)
maxY = maxY1;
if (minY1 < minY)
minY = minY1;
pos = endpt;
break;
}
case CodeType.SubProgramCall: case CodeType.SubProgramCall:
{ {
var subpgm = (SubProgramCall)code; var subpgm = (SubProgramCall)code;
if (subpgm.Program == null) if (subpgm.Program == null)
break;
// Sub-program frame origin in this program's frame
// is frameOrigin + Offset, regardless of current pos.
pos = frameOrigin + subpgm.Offset;
var box = subpgm.Program.BoundingBox(ref pos);
if (box.Left < minX)
minX = box.Left;
if (box.Right > maxX)
maxX = box.Right;
if (box.Bottom < minY)
minY = box.Bottom;
if (box.Top > maxY)
maxY = box.Top;
break; break;
}
// Sub-program frame origin in this program's frame
// is frameOrigin + Offset, regardless of current pos.
pos = frameOrigin + subpgm.Offset;
if (!subpgm.Program.BoundingBox(ref pos, out var box))
break;
if (box.Left < minX)
minX = box.Left;
if (box.Right > maxX)
maxX = box.Right;
if (box.Bottom < minY)
minY = box.Bottom;
if (box.Top > maxY)
maxY = box.Top;
break;
}
} }
} }
return new Box(minX, minY, maxX - minX, maxY - minY); if (minX > maxX || minY > maxY)
{
result = new Box(0, 0, 0, 0);
return false;
}
result = new Box(minX, minY, maxX - minX, maxY - minY);
return true;
} }
public object Clone() public object Clone()
{ {
var pgm = new Program() var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
{
mode = this.mode,
Rotation = this.Rotation
};
var codes = new ICode[Length]; var codes = new ICode[Length];
+2 -2
View File
@@ -20,8 +20,8 @@ namespace OpenNest.CNC
public List<string> EmitDeclarations() public List<string> EmitDeclarations()
{ {
return _variables.Values return _variables
.Where(v => v.Expression != null) .Values.Where(v => v.Expression != null)
.OrderBy(v => v.Number) .OrderBy(v => v.Number)
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})") .Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
.ToList(); .ToList();
+12 -5
View File
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC namespace OpenNest.CNC
{ {
@@ -36,7 +36,13 @@ namespace OpenNest.CNC
return basePos; return basePos;
} }
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results) private static void Walk(
Program pgm,
Vector basePos,
ref Vector pos,
bool skipFirst,
List<Segment> results
)
{ {
var skipped = !skipFirst; var skipped = !skipFirst;
@@ -60,9 +66,10 @@ namespace OpenNest.CNC
} }
else if (code is Motion motion) else if (code is Motion motion)
{ {
var endpt = pgm.Mode == Mode.Incremental var endpt =
? motion.EndPoint + pos pgm.Mode == Mode.Incremental
: motion.EndPoint + basePos; ? motion.EndPoint + pos
: motion.EndPoint + basePos;
if (code.Type == CodeType.RapidMove) if (code.Type == CodeType.RapidMove)
{ {
+2 -1
View File
@@ -30,7 +30,8 @@ namespace OpenNest.CNC
return new RapidMove(EndPoint) return new RapidMove(EndPoint)
{ {
Suppressed = Suppressed, Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null VariableRefs =
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
}; };
} }
+1 -3
View File
@@ -9,9 +9,7 @@ namespace OpenNest.CNC
private double rotation; private double rotation;
private Program program; private Program program;
public SubProgramCall() public SubProgramCall() { }
{
}
public SubProgramCall(Program program, double rotation) public SubProgramCall(Program program, double rotation)
{ {
+7 -2
View File
@@ -8,8 +8,13 @@ namespace OpenNest.CNC
public bool Inline { get; } public bool Inline { get; }
public bool Global { get; } public bool Global { get; }
public VariableDefinition(string name, string expression, double value, public VariableDefinition(
bool inline = false, bool global = false) string name,
string expression,
double value,
bool inline = false,
bool global = false
)
{ {
Name = name; Name = name;
Expression = expression; Expression = expression;
+93 -4
View File
@@ -1,6 +1,6 @@
using System.Linq;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using System.Linq;
namespace OpenNest namespace OpenNest
{ {
@@ -14,6 +14,12 @@ namespace OpenNest
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary> /// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
public const double SnapToZero = 0.001; public const double SnapToZero = 0.001;
/// <summary>Centroid offsets below this fraction of the MBR extent count as symmetric.</summary>
private const double SymmetryTolerance = 1e-6;
/// <summary>Angular margin (radians) keeping axis-aligned centroid offsets off the edge of the preferred quadrant.</summary>
private const double PreferenceMargin = 0.001;
/// <summary> /// <summary>
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which /// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula /// computes the MBR itself) and by PartClassifier (which already has one). Single formula
@@ -44,8 +50,9 @@ namespace OpenNest
if (drawing?.Program == null) if (drawing?.Program == null)
return 0.0; return 0.0;
var entities = ConvertProgram.ToGeometry(drawing.Program) var entities = ConvertProgram
.Where(e => e.Layer != SpecialLayers.Rapid); .ToGeometry(drawing.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer));
var shapes = ShapeBuilder.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0) if (shapes.Count == 0)
@@ -72,7 +79,89 @@ namespace OpenNest
return 0.0; return 0.0;
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull); var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
return FromMbr(mbr); var angle = FromMbr(mbr);
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
return angle;
var quarterTurns = PreferredQuarterTurns(polygon, hull, angle);
if (quarterTurns == 0)
return angle;
return NormalizeSigned(angle + quarterTurns * System.Math.PI / 2.0);
}
/// <summary>
/// The MBR only fixes the frame modulo 90°, leaving four equivalent orientations. Nest
/// results are not 90°-symmetric, so pick one deterministically: the quarter-turn count
/// that puts the perimeter's centroid toward the lower-left of its MBR. Shapes with no
/// centroid offset (rectangles, circles) are symmetric and keep the MBR orientation.
/// </summary>
private static int PreferredQuarterTurns(Polygon polygon, Polygon hull, double angle)
{
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var vertex in hull.Vertices)
{
var rotated = vertex.Rotate(angle);
minX = System.Math.Min(minX, rotated.X);
minY = System.Math.Min(minY, rotated.Y);
maxX = System.Math.Max(maxX, rotated.X);
maxY = System.Math.Max(maxY, rotated.Y);
}
var centroid = Centroid(polygon).Rotate(angle);
var dx = centroid.X - (minX + maxX) / 2.0;
var dy = centroid.Y - (minY + maxY) / 2.0;
var extent = System.Math.Max(maxX - minX, maxY - minY);
if (System.Math.Sqrt(dx * dx + dy * dy) <= SymmetryTolerance * extent)
return 0;
// Choose k so the offset direction lands in [PI - margin, 3PI/2 - margin). The margin
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
// interval edge so floating-point noise cannot flip the choice.
var halfPi = System.Math.PI / 2.0;
var direction = System.Math.Atan2(dy, dx);
for (var turns = 0; turns < 4; turns++)
{
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
relative -= 2.0 * System.Math.PI * System.Math.Floor(relative / (2.0 * System.Math.PI));
if (relative < halfPi)
return turns;
}
return 0;
}
private static Vector Centroid(Polygon polygon)
{
var vertices = polygon.Vertices;
var doubleArea = 0.0;
var cx = 0.0;
var cy = 0.0;
for (var i = 0; i < vertices.Count; i++)
{
var p = vertices[i];
var q = vertices[(i + 1) % vertices.Count];
var cross = p.X * q.Y - q.X * p.Y;
doubleArea += cross;
cx += (p.X + q.X) * cross;
cy += (p.Y + q.Y) * cross;
}
if (System.Math.Abs(doubleArea) <= OpenNest.Math.Tolerance.Epsilon)
return new Vector(vertices.Average(v => v.X), vertices.Average(v => v.Y));
return new Vector(cx / (3.0 * doubleArea), cy / (3.0 * doubleArea));
}
private static double NormalizeSigned(double angle)
{
var twoPi = 2.0 * System.Math.PI;
angle -= twoPi * System.Math.Floor((angle + System.Math.PI) / twoPi);
return angle;
} }
} }
} }
@@ -2,7 +2,5 @@
namespace OpenNest.Collections namespace OpenNest.Collections
{ {
public class DrawingCollection : HashSet<Drawing> public class DrawingCollection : HashSet<Drawing> { }
{
}
} }
+17 -6
View File
@@ -1,7 +1,7 @@
using OpenNest.Geometry;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Converters namespace OpenNest.Converters
{ {
@@ -10,7 +10,7 @@ namespace OpenNest.Converters
Perimeter, Perimeter,
Hole, Hole,
Etch, Etch,
Open Open,
} }
public sealed class ContourInfo public sealed class ContourInfo
@@ -91,7 +91,8 @@ namespace OpenNest.Converters
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter) // Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
for (var i = 0; i < shapes.Count; i++) for (var i = 0; i < shapes.Count; i++)
{ {
if (i == perimeterIndex) continue; if (i == perimeterIndex)
continue;
var shape = shapes[i]; var shape = shapes[i];
var type = ClassifyShape(shape); var type = ClassifyShape(shape);
@@ -116,7 +117,13 @@ namespace OpenNest.Converters
} }
// Perimeter last // Perimeter last
result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter")); result.Add(
new ContourInfo(
shapes[perimeterIndex],
ContourClassification.Perimeter,
"Perimeter"
)
);
return result; return result;
} }
@@ -124,8 +131,12 @@ namespace OpenNest.Converters
private static ContourClassification ClassifyShape(Shape shape) private static ContourClassification ClassifyShape(Shape shape)
{ {
// Check etch layer — all entities must be on ETCH layer // Check etch layer — all entities must be on ETCH layer
if (shape.Entities.Count > 0 && if (
shape.Entities.All(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase))) shape.Entities.Count > 0
&& shape.Entities.All(e =>
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
)
)
return ContourClassification.Etch; return ContourClassification.Etch;
if (shape.IsClosed()) if (shape.IsClosed())
+38 -9
View File
@@ -1,7 +1,7 @@
using OpenNest.CNC; using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Converters namespace OpenNest.Converters
{ {
@@ -87,14 +87,24 @@ namespace OpenNest.Converters
lastpt = endpt; lastpt = endpt;
var layer = ClassifyLayer(arc);
var sweep = System.Math.Abs(arc.SweepAngle()); var sweep = System.Math.Abs(arc.SweepAngle());
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI)) if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{ {
pgm.LineTo(endpt); pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
} }
else else
{ {
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW); pgm.Codes.Add(
new ArcMove(
endpt,
arc.Center,
arc.IsReversed ? RotationType.CW : RotationType.CCW
)
{
Layer = layer,
}
);
} }
return lastpt; return lastpt;
@@ -107,7 +117,12 @@ namespace OpenNest.Converters
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt); pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, circle.Rotation); pgm.Codes.Add(
new ArcMove(startpt, circle.Center, circle.Rotation)
{
Layer = ClassifyLayer(circle),
}
);
lastpt = startpt; lastpt = startpt;
return lastpt; return lastpt;
@@ -118,13 +133,27 @@ namespace OpenNest.Converters
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(line.StartPoint); pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint); pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
move.Layer = LayerType.Scribe;
pgm.Codes.Add(move);
lastpt = line.EndPoint; lastpt = line.EndPoint;
return lastpt; return lastpt;
} }
// Engrave/etch/scribe geometry maps to Scribe so the post processor can treat it as a
// separate tool pass; everything else keeps the move's default Cut layer. SCRIBE is the
// name marks carry once saved (SpecialLayers.Scribe), so drawings rebuilt from stored
// entities must map it too or their marks silently become cut moves.
private static LayerType ClassifyLayer(Entity geo)
{
var name = geo.Layer?.Name;
if (
string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, SpecialLayers.Scribe.Name, System.StringComparison.OrdinalIgnoreCase)
)
return LayerType.Scribe;
return LayerType.Cut;
}
} }
} }
+84 -15
View File
@@ -1,7 +1,7 @@
using OpenNest.CNC; using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Converters namespace OpenNest.Converters
{ {
@@ -18,7 +18,12 @@ namespace OpenNest.Converters
return geometry; return geometry;
} }
private static void AddProgram(Program program, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) private static void AddProgram(
Program program,
ref Mode mode,
ref Vector curpos,
ref List<Entity> geometry
)
{ {
// Capture the frame origin at entry. Sub-program Offsets are relative // Capture the frame origin at entry. Sub-program Offsets are relative
// to this fixed origin, not to the current tool position. // to this fixed origin, not to the current tool position.
@@ -49,7 +54,10 @@ namespace OpenNest.Converters
// The sub-program's frame origin in this program's frame is // The sub-program's frame origin in this program's frame is
// frameOrigin + Offset — independent of current tool position. // frameOrigin + Offset — independent of current tool position.
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y); curpos = new Vector(
frameOrigin.X + subpgm.Offset.X,
frameOrigin.Y + subpgm.Offset.Y
);
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry); AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
mode = savedMode; mode = savedMode;
@@ -58,7 +66,12 @@ namespace OpenNest.Converters
} }
} }
private static void AddLinearMove(LinearMove linearMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) private static void AddLinearMove(
LinearMove linearMove,
ref Mode mode,
ref Vector curpos,
ref List<Entity> geometry
)
{ {
var pt = linearMove.EndPoint; var pt = linearMove.EndPoint;
@@ -66,16 +79,17 @@ namespace OpenNest.Converters
pt += curpos; pt += curpos;
var layer = ConvertLayer(linearMove.Layer); var layer = ConvertLayer(linearMove.Layer);
var line = new Line(curpos, pt) var line = new Line(curpos, pt) { Layer = layer, Color = layer.Color };
{
Layer = layer,
Color = layer.Color
};
geometry.Add(line); geometry.Add(line);
curpos = pt; curpos = pt;
} }
private static void AddRapidMove(RapidMove rapidMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) private static void AddRapidMove(
RapidMove rapidMove,
ref Mode mode,
ref Vector curpos,
ref List<Entity> geometry
)
{ {
var pt = rapidMove.EndPoint; var pt = rapidMove.EndPoint;
@@ -85,13 +99,18 @@ namespace OpenNest.Converters
var line = new Line(curpos, pt) var line = new Line(curpos, pt)
{ {
Layer = SpecialLayers.Rapid, Layer = SpecialLayers.Rapid,
Color = SpecialLayers.Rapid.Color Color = SpecialLayers.Rapid.Color,
}; };
geometry.Add(line); geometry.Add(line);
curpos = pt; curpos = pt;
} }
private static void AddArcMove(ArcMove arcMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) private static void AddArcMove(
ArcMove arcMove,
ref Mode mode,
ref Vector curpos,
ref List<Entity> geometry
)
{ {
var center = arcMove.CenterPoint; var center = arcMove.CenterPoint;
var endpt = arcMove.EndPoint; var endpt = arcMove.EndPoint;
@@ -102,6 +121,8 @@ namespace OpenNest.Converters
center += curpos; center += curpos;
} }
center = FitCenterToEndpoints(center, curpos, endpt);
var startAngle = center.AngleTo(curpos); var startAngle = center.AngleTo(curpos);
var endAngle = center.AngleTo(endpt); var endAngle = center.AngleTo(endpt);
@@ -112,13 +133,61 @@ namespace OpenNest.Converters
var layer = ConvertLayer(arcMove.Layer); var layer = ConvertLayer(arcMove.Layer);
if (startAngle.IsEqualTo(endAngle)) if (startAngle.IsEqualTo(endAngle))
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation }); geometry.Add(
new Circle(center, radius)
{
Layer = layer,
Color = layer.Color,
Rotation = arcMove.Rotation,
}
);
else else
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color }); geometry.Add(
new Arc(
center,
radius,
startAngle,
endAngle,
arcMove.Rotation == RotationType.CW
)
{
Layer = layer,
Color = layer.Color,
}
);
curpos = endpt; curpos = endpt;
} }
/// <summary>
/// Programs can carry arc centers that are not quite equidistant from the
/// start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from
/// the end radius alone then leaves its start point off the previous move's
/// end, which breaks contour chaining. Project the center onto the chord's
/// perpendicular bisector so the arc passes through both endpoints exactly.
/// </summary>
private static Vector FitCenterToEndpoints(Vector center, Vector start, Vector end)
{
var startRadius = center.DistanceTo(start);
var endRadius = center.DistanceTo(end);
if (startRadius.IsEqualTo(endRadius))
return center;
var chord = end - start;
var chordLengthSq = chord.X * chord.X + chord.Y * chord.Y;
// Full circle (start == end): no chord to fit against.
if (chordLengthSq < Tolerance.Epsilon * Tolerance.Epsilon)
return center;
var mid = new Vector((start.X + end.X) * 0.5, (start.Y + end.Y) * 0.5);
var normal = new Vector(-chord.Y, chord.X);
var t = ((center.X - mid.X) * normal.X + (center.Y - mid.Y) * normal.Y) / chordLengthSq;
return new Vector(mid.X + normal.X * t, mid.Y + normal.Y * t);
}
private static Layer ConvertLayer(LayerType layer) private static Layer ConvertLayer(LayerType layer)
{ {
switch (layer) switch (layer)
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Converters
{
/// <summary>
/// Restores the Scribe layer on program moves that were saved as cuts. Programs built from
/// stored entities before ConvertGeometry recognized the SCRIBE layer name turned etch marks
/// into Cut moves, which nesting then treated as open cut geometry. The drawing's source
/// entities still carry the mark layer, so matching moves are reclassified from them.
/// </summary>
public static class ScribeLayerRepair
{
private const double MatchTolerance = 0.001;
/// <summary>
/// Reclassifies Cut moves in <paramref name="program"/> that lie on a mark entity in
/// <paramref name="sourceEntities"/>. Program coordinates are source coordinates shifted
/// by -<paramref name="sourceOffset"/>. Returns the number of moves reclassified.
/// </summary>
public static int Apply(Program program, IEnumerable<Entity> sourceEntities, Vector sourceOffset)
{
if (program == null || sourceEntities == null)
return 0;
var marks = sourceEntities.Where(e => IsMarkLayer(e.Layer)).ToList();
if (marks.Count == 0 || program.Codes.Any(c => c is SubProgramCall))
return 0;
// ToGeometry emits exactly one entity per rapid/linear/arc move of a flat program.
var motions = program.Codes.Where(c => c is RapidMove or LinearMove or ArcMove).ToList();
var geometry = ConvertProgram.ToGeometry(program);
if (geometry.Count != motions.Count)
return 0;
var repaired = 0;
for (var i = 0; i < motions.Count; i++)
{
var source = Translate(geometry[i], sourceOffset);
switch (motions[i])
{
case LinearMove line when line.Layer == LayerType.Cut && IsOnMark(source, marks):
line.Layer = LayerType.Scribe;
repaired++;
break;
case ArcMove arc when arc.Layer == LayerType.Cut && IsOnMark(source, marks):
arc.Layer = LayerType.Scribe;
repaired++;
break;
}
}
return repaired;
}
public static bool IsMarkLayer(Layer layer) =>
layer != null
&& (
layer == SpecialLayers.Scribe
|| string.Equals(layer.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(layer.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|| string.Equals(layer.Name, "ENGRAVE", StringComparison.OrdinalIgnoreCase)
);
private static List<Vector> Translate(Entity entity, Vector offset)
{
var points = entity switch
{
Line l => new List<Vector>
{
l.StartPoint,
l.EndPoint,
new Vector((l.StartPoint.X + l.EndPoint.X) / 2, (l.StartPoint.Y + l.EndPoint.Y) / 2),
},
Arc a => new List<Vector> { a.StartPoint(), a.EndPoint(), a.MidPoint() },
Circle c => new List<Vector>
{
new Vector(c.Center.X + c.Radius, c.Center.Y),
new Vector(c.Center.X - c.Radius, c.Center.Y),
new Vector(c.Center.X, c.Center.Y + c.Radius),
},
_ => new List<Vector>(),
};
return points.ConvertAll(p => new Vector(p.X + offset.X, p.Y + offset.Y));
}
// A move is a mark when every sample point lies on one single mark entity.
private static bool IsOnMark(List<Vector> points, List<Entity> marks) =>
points.Count > 0
&& marks.Any(m => points.All(p => m.ClosestPointTo(p).DistanceTo(p) <= MatchTolerance));
}
}
+92 -26
View File
@@ -1,18 +1,20 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest namespace OpenNest
{ {
public enum CutOffAxis public enum CutOffAxis
{ {
Horizontal, Horizontal,
Vertical Vertical,
} }
public class CutOff public class CutOff
{ {
private const double OffsetTolerance = 0.001;
public Vector Position { get; set; } public Vector Position { get; set; }
public CutOffAxis Axis { get; set; } public CutOffAxis Axis { get; set; }
public double? StartLimit { get; set; } public double? StartLimit { get; set; }
@@ -26,7 +28,11 @@ namespace OpenNest
Drawing = new Drawing(GetName()) { IsCutOff = true }; Drawing = new Drawing(GetName()) { IsCutOff = true };
} }
public void Regenerate(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache = null) public void Regenerate(
Plate plate,
CutOffSettings settings,
Dictionary<Part, Entity> cache = null
)
{ {
var segments = ComputeSegments(plate, settings, cache); var segments = ComputeSegments(plate, settings, cache);
var program = BuildProgram(segments, settings); var program = BuildProgram(segments, settings);
@@ -40,11 +46,17 @@ namespace OpenNest
return $"CutOff-{axisChar}-{coord:F2}"; return $"CutOff-{axisChar}-{coord:F2}";
} }
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache) private List<(double Start, double End)> ComputeSegments(
Plate plate,
CutOffSettings settings,
Dictionary<Part, Entity> cache
)
{ {
var bounds = plate.BoundingBox(includeParts: false); var bounds = plate.BoundingBox(includeParts: false);
double lineStart, lineEnd, cutPosition; double lineStart,
lineEnd,
cutPosition;
if (Axis == CutOffAxis.Vertical) if (Axis == CutOffAxis.Vertical)
{ {
@@ -68,7 +80,14 @@ namespace OpenNest
Entity perimeter = null; Entity perimeter = null;
cache?.TryGetValue(part, out perimeter); cache?.TryGetValue(part, out perimeter);
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance); var partExclusions = GetPartExclusions(
part,
perimeter,
cutPosition,
lineStart,
lineEnd,
settings.PartClearance
);
exclusions.AddRange(partExclusions); exclusions.AddRange(partExclusions);
} }
@@ -107,7 +126,13 @@ namespace OpenNest
private static readonly List<(double Start, double End)> EmptyExclusions = new(); private static readonly List<(double Start, double End)> EmptyExclusions = new();
private List<(double Start, double End)> GetPartExclusions( private List<(double Start, double End)> GetPartExclusions(
Part part, Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance) Part part,
Entity perimeter,
double cutPosition,
double lineStart,
double lineEnd,
double clearance
)
{ {
var bb = part.BoundingBox; var bb = part.BoundingBox;
var (partMin, partMax) = AxisBounds(bb, clearance); var (partMin, partMax) = AxisBounds(bb, clearance);
@@ -118,7 +143,13 @@ namespace OpenNest
if (perimeter != null) if (perimeter != null)
{ {
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance); var perimeterExclusions = IntersectPerimeter(
perimeter,
cutPosition,
lineStart,
lineEnd,
clearance
);
if (perimeterExclusions != null) if (perimeterExclusions != null)
return perimeterExclusions; return perimeterExclusions;
} }
@@ -127,17 +158,33 @@ namespace OpenNest
} }
private List<(double Start, double End)> IntersectPerimeter( private List<(double Start, double End)> IntersectPerimeter(
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance) Entity perimeter,
double cutPosition,
double lineStart,
double lineEnd,
double clearance
)
{ {
var target = OffsetOutward(perimeter, clearance) ?? perimeter; var offset = OffsetOutward(perimeter, clearance);
var usedOffset = target != perimeter; var usedOffset = offset != null;
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd)); var targets = offset ?? new List<Entity> { perimeter };
var cutLine = new Line(
MakePoint(cutPosition, lineStart),
MakePoint(cutPosition, lineEnd)
);
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2) var pts = new List<Vector>();
foreach (var target in targets)
{
if (target.Intersects(cutLine, out var targetPts))
pts.AddRange(targetPts);
}
if (pts.Count < 2)
return null; return null;
var coords = pts var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
.OrderBy(c => c) .OrderBy(c => c)
.ToList(); .ToList();
@@ -152,21 +199,37 @@ namespace OpenNest
return result; return result;
} }
private static Entity OffsetOutward(Entity perimeter, double clearance) /// <summary>
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
/// narrower than twice the clearance close up instead of leaving a gap the cut
/// could run into. Holes appear only where the perimeter curls back on itself.
/// </summary>
private static List<Entity> OffsetOutward(Entity perimeter, double clearance)
{ {
if (clearance <= 0) if (clearance <= 0)
return null; return null;
try var offset = perimeter switch
{
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
offset?.UpdateBounds();
return offset;
}
catch
{ {
Shape shape => ClipperBridge.OffsetPerimeter(
shape,
clearance,
OffsetTolerance,
circumscribe: true
),
Polygon polygon => ClipperBridge.OffsetPerimeter(
polygon,
clearance,
OffsetTolerance,
circumscribe: true
),
_ => null,
};
if (offset == null || offset.Outers.Count == 0)
return null; return null;
}
return offset.Outers.Concat(offset.Holes).Cast<Entity>().ToList();
} }
private Vector MakePoint(double cutCoord, double lineCoord) => private Vector MakePoint(double cutCoord, double lineCoord) =>
@@ -184,7 +247,10 @@ namespace OpenNest
? (bb.Y - clearance, bb.Y + bb.Width + clearance) ? (bb.Y - clearance, bb.Y + bb.Width + clearance)
: (bb.X - clearance, bb.X + bb.Length + clearance); : (bb.X - clearance, bb.X + bb.Length + clearance);
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings) private Program BuildProgram(
List<(double Start, double End)> segments,
CutOffSettings settings
)
{ {
var program = new Program(); var program = new Program();
+1 -1
View File
@@ -3,7 +3,7 @@ namespace OpenNest
public enum CutDirection public enum CutDirection
{ {
TowardOrigin, TowardOrigin,
AwayFromOrigin AwayFromOrigin,
} }
public class CutOffSettings public class CutOffSettings
+8 -7
View File
@@ -11,11 +11,12 @@ public class CutParameters
public string PostProcessor { get; set; } public string PostProcessor { get; set; }
public Units Units { get; set; } public Units Units { get; set; }
public static CutParameters Default => new() public static CutParameters Default =>
{ new()
Feedrate = 100, {
RapidTravelRate = 300, Feedrate = 100,
PierceTime = TimeSpan.FromSeconds(0.5), RapidTravelRate = 300,
Units = OpenNest.Units.Inches PierceTime = TimeSpan.FromSeconds(0.5),
}; Units = OpenNest.Units.Inches,
};
} }
+22 -24
View File
@@ -1,12 +1,12 @@
using OpenNest.Bending; using System;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest namespace OpenNest
{ {
@@ -18,18 +18,18 @@ namespace OpenNest
public static Color[] PartColors = new Color[] public static Color[] PartColors = new Color[]
{ {
Color.FromArgb(205, 92, 92), // Indian Red Color.FromArgb(205, 92, 92), // Indian Red
Color.FromArgb(148, 103, 189), // Medium Purple Color.FromArgb(148, 103, 189), // Medium Purple
Color.FromArgb(75, 180, 175), // Teal Color.FromArgb(75, 180, 175), // Teal
Color.FromArgb(210, 190, 75), // Goldenrod Color.FromArgb(210, 190, 75), // Goldenrod
Color.FromArgb(190, 85, 175), // Orchid Color.FromArgb(190, 85, 175), // Orchid
Color.FromArgb(185, 115, 85), // Sienna Color.FromArgb(185, 115, 85), // Sienna
Color.FromArgb(120, 100, 190), // Slate Blue Color.FromArgb(120, 100, 190), // Slate Blue
Color.FromArgb(200, 100, 140), // Rose Color.FromArgb(200, 100, 140), // Rose
Color.FromArgb(80, 175, 155), // Sea Green Color.FromArgb(80, 175, 155), // Sea Green
Color.FromArgb(195, 160, 85), // Dark Khaki Color.FromArgb(195, 160, 85), // Dark Khaki
Color.FromArgb(175, 95, 160), // Plum Color.FromArgb(175, 95, 160), // Plum
Color.FromArgb(215, 130, 130), // Light Coral Color.FromArgb(215, 130, 130), // Light Coral
}; };
public static Color GetNextColor() public static Color GetNextColor()
@@ -40,14 +40,10 @@ namespace OpenNest
} }
public Drawing() public Drawing()
: this(string.Empty, new Program()) : this(string.Empty, new Program()) { }
{
}
public Drawing(string name) public Drawing(string name)
: this(name, new Program()) : this(name, new Program()) { }
{
}
public Drawing(string name, Program pgm) public Drawing(string name, Program pgm)
{ {
@@ -127,7 +123,9 @@ namespace OpenNest
public void UpdateArea() public void UpdateArea()
{ {
var geometry = ConvertProgram.ToGeometry(Program).Where(entity => entity.Layer != SpecialLayers.Rapid); var geometry = ConvertProgram
.ToGeometry(Program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer));
var shapes = ShapeBuilder.GetShapes(geometry); var shapes = ShapeBuilder.GetShapes(geometry);
if (shapes.Count == 0) if (shapes.Count == 0)
+52 -40
View File
@@ -1,6 +1,6 @@
using OpenNest.Math; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -12,16 +12,18 @@ namespace OpenNest.Geometry
private Vector center; private Vector center;
private bool reversed; private bool reversed;
public Arc() public Arc() { }
{
}
public Arc(double x, double y, double r, double a1, double a2, bool reversed = false) public Arc(double x, double y, double r, double a1, double a2, bool reversed = false)
: this(new Vector(x, y), r, a1, a2, reversed) : this(new Vector(x, y), r, a1, a2, reversed) { }
{
}
public Arc(Vector center, double radius, double startAngle, double endAngle, bool reversed = false) public Arc(
Vector center,
double radius,
double startAngle,
double endAngle,
bool reversed = false
)
{ {
this.center = center; this.center = center;
this.radius = radius; this.radius = radius;
@@ -93,8 +95,7 @@ namespace OpenNest.Geometry
} }
} }
public bool IsFullCircle() => public bool IsFullCircle() => SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
/// <summary> /// <summary>
/// Angle in radians between start and end angles. /// Angle in radians between start and end angles.
@@ -130,10 +131,7 @@ namespace OpenNest.Geometry
public RotationType Rotation public RotationType Rotation
{ {
get { return IsReversed ? RotationType.CW : RotationType.CCW; } get { return IsReversed ? RotationType.CW : RotationType.CCW; }
set set { IsReversed = (value == RotationType.CW); }
{
IsReversed = (value == RotationType.CW);
}
} }
/// <summary> /// <summary>
@@ -144,7 +142,8 @@ namespace OpenNest.Geometry
{ {
return new Vector( return new Vector(
Center.X + Radius * System.Math.Cos(StartAngle), Center.X + Radius * System.Math.Cos(StartAngle),
Center.Y + Radius * System.Math.Sin(StartAngle)); Center.Y + Radius * System.Math.Sin(StartAngle)
);
} }
/// <summary> /// <summary>
@@ -155,7 +154,8 @@ namespace OpenNest.Geometry
{ {
return new Vector( return new Vector(
Center.X + Radius * System.Math.Cos(EndAngle), Center.X + Radius * System.Math.Cos(EndAngle),
Center.Y + Radius * System.Math.Sin(EndAngle)); Center.Y + Radius * System.Math.Sin(EndAngle)
);
} }
/// <summary> /// <summary>
@@ -166,7 +166,8 @@ namespace OpenNest.Geometry
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2); var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
return new Vector( return new Vector(
Center.X + Radius * System.Math.Cos(midAngle), Center.X + Radius * System.Math.Cos(midAngle),
Center.Y + Radius * System.Math.Sin(midAngle)); Center.Y + Radius * System.Math.Sin(midAngle)
);
} }
/// <summary> /// <summary>
@@ -231,7 +232,10 @@ namespace OpenNest.Geometry
return 1; return 1;
var maxAngle = 2.0 * System.Math.Acos(1.0 - tolerance / Radius); var maxAngle = 2.0 * System.Math.Acos(1.0 - tolerance / Radius);
return System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle)); return System.Math.Max(
1,
(int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle)
);
} }
/// <summary> /// <summary>
@@ -242,21 +246,23 @@ namespace OpenNest.Geometry
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false) public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
{ {
var points = new List<Vector>(); var points = new List<Vector>();
var stepAngle = reversed var stepAngle = reversed ? -SweepAngle() / segments : SweepAngle() / segments;
? -SweepAngle() / segments
: SweepAngle() / segments;
var r = circumscribe && segments > 0 var r =
? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0) circumscribe && segments > 0
: Radius; ? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0)
: Radius;
for (int i = 0; i <= segments; ++i) for (int i = 0; i <= segments; ++i)
{ {
var angle = stepAngle * i + StartAngle; var angle = stepAngle * i + StartAngle;
points.Add(new Vector( points.Add(
System.Math.Cos(angle) * r + Center.X, new Vector(
System.Math.Sin(angle) * r + Center.Y)); System.Math.Cos(angle) * r + Center.X,
System.Math.Sin(angle) * r + Center.Y
)
);
} }
return points; return points;
@@ -437,19 +443,23 @@ namespace OpenNest.Geometry
boundingBox.Width = maxY - minY; boundingBox.Width = maxY - minY;
} }
/// <summary>
/// Offsets the arc to the given side of its travel direction. The center lies to
/// the left of a CCW arc and to the right of a CW (reversed) one, so the arc grows
/// on the other side and shrinks toward its center. Returns null when it shrinks
/// to nothing.
/// </summary>
public override Entity OffsetEntity(double distance, OffsetSide side) public override Entity OffsetEntity(double distance, OffsetSide side)
{ {
if (side == OffsetSide.Left && reversed) var grows = (side == OffsetSide.Left) == reversed;
{
return new Arc(center, radius + distance, startAngle, endAngle, reversed);
}
else
{
if (distance >= radius)
return null;
return new Arc(center, radius - distance, startAngle, endAngle, reversed); if (grows)
} return new Arc(center, radius + distance, startAngle, endAngle, reversed);
if (distance >= radius)
return null;
return new Arc(center, radius - distance, startAngle, endAngle, reversed);
} }
public override Entity OffsetEntity(double distance, Vector pt) public override Entity OffsetEntity(double distance, Vector pt)
@@ -470,7 +480,8 @@ namespace OpenNest.Geometry
{ {
return new Vector( return new Vector(
System.Math.Cos(angle) * Radius + Center.X, System.Math.Cos(angle) * Radius + Center.X,
System.Math.Sin(angle) * Radius + Center.Y); System.Math.Sin(angle) * Radius + Center.Y
);
} }
else else
{ {
@@ -500,7 +511,8 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts) public override bool Intersects(Arc arc, out List<Vector> pts)
{ {
return Intersect.Intersects(this, arc, out pts); ; return Intersect.Intersects(this, arc, out pts);
;
} }
/// <summary> /// <summary>
+55 -41
View File
@@ -14,7 +14,9 @@ namespace OpenNest.Geometry
/// the arc passes through both endpoints and departs P1 in the given direction. /// the arc passes through both endpoints and departs P1 in the given direction.
/// </summary> /// </summary>
internal static (Vector center, double radius, double deviation) FitWithStartTangent( internal static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent) List<Vector> points,
Vector tangent
)
{ {
if (points.Count < 3) if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue); return (Vector.Invalid, 0, double.MaxValue);
@@ -57,14 +59,22 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Fits a circular arc constrained to be tangent to the given directions at both /// Fits a circular arc that passes exactly through both the first and last points
/// the first and last points. The center lies at the intersection of the normals /// while matching the given endpoint tangents as closely as possible. For any
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives /// circle through two points, the tangents at those points make equal mirrored
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent); /// angles with the chord, so the achievable inscribed angle is the average of the
/// deviation includes any endpoint gap at Pn. /// two requested ones — when the requested tangents are consistent with a single
/// circular arc, both are matched exactly.
/// </summary> /// </summary>
internal static (Vector center, double radius, double deviation) FitWithDualTangent( internal static (
List<Vector> points, Vector startTangent, Vector endTangent) Vector center,
double radius,
double deviation
) FitThroughEndpointsWithTangents(
List<Vector> points,
Vector startTangent,
Vector endTangent
)
{ {
if (points.Count < 3) if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue); return (Vector.Invalid, 0, double.MaxValue);
@@ -72,48 +82,51 @@ namespace OpenNest.Geometry
var p1 = points[0]; var p1 = points[0];
var pn = points[^1]; var pn = points[^1];
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
if (stLen < 1e-10 || etLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
// Normal to start tangent at P1 (perpendicular)
var n1x = -startTangent.Y / stLen;
var n1y = startTangent.X / stLen;
// Normal to end tangent at Pn
var n2x = -endTangent.Y / etLen;
var n2y = endTangent.X / etLen;
// Solve: P1 + t1*N1 = Pn + t2*N2
var det = n1x * (-n2y) - (-n2x) * n1y;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var dx = pn.X - p1.X; var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y; var dy = pn.Y - p1.Y;
var t1 = (dx * (-n2y) - (-n2x) * dy) / det; var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
var cx = p1.X + t1 * n1x;
var cy = p1.Y + t1 * n1y;
// Use radius from P1 (guarantees exact start tangent and passes through P1)
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (r1 < 1e-10)
return (Vector.Invalid, 0, double.MaxValue); return (Vector.Invalid, 0, double.MaxValue);
// Measure endpoint gap at Pn var ux = dx / chordLen;
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y)); var uy = dy / chordLen;
var endpointDev = System.Math.Abs(r2 - r1);
var interiorDev = MaxRadialDeviation(points, cx, cy, r1); // Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev)); var theta1 = SignedAngle(ux, uy, startTangent);
var theta2 = -SignedAngle(ux, uy, endTangent);
var theta = (theta1 + theta2) / 2;
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
return (Vector.Invalid, 0, double.MaxValue);
var halfChord = chordLen / 2;
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
var d = -halfChord / System.Math.Tan(theta);
var cx = (p1.X + pn.X) / 2 + d * -uy;
var cy = (p1.Y + pn.Y) / 2 + d * ux;
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
private static double SignedAngle(double ux, double uy, Vector to)
{
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
if (len < 1e-10)
return 0;
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
} }
/// <summary> /// <summary>
/// Computes the maximum radial deviation of interior points from a circle. /// Computes the maximum radial deviation of interior points from a circle.
/// </summary> /// </summary>
internal static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius) internal static double MaxRadialDeviation(
List<Vector> points,
double cx,
double cy,
double radius
)
{ {
var maxDev = 0.0; var maxDev = 0.0;
for (var i = 1; i < points.Count - 1; i++) for (var i = 1; i < points.Count - 1; i++)
@@ -122,7 +135,8 @@ namespace OpenNest.Geometry
var py = points[i].Y - cy; var py = points[i].Y - cy;
var dist = System.Math.Sqrt(px * px + py * py); var dist = System.Math.Sqrt(px * px + py * py);
var dev = System.Math.Abs(dist - radius); var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev; if (dev > maxDev)
maxDev = dev;
} }
return maxDev; return maxDev;
} }
+24 -12
View File
@@ -17,10 +17,14 @@ namespace OpenNest.Geometry
foreach (var box in boxes) foreach (var box in boxes)
{ {
if (box.Left < minX) minX = box.Left; if (box.Left < minX)
if (box.Right > maxX) maxX = box.Right; minX = box.Left;
if (box.Bottom < minY) minY = box.Bottom; if (box.Right > maxX)
if (box.Top > maxY) maxY = box.Top; maxX = box.Right;
if (box.Bottom < minY)
minY = box.Bottom;
if (box.Top > maxY)
maxY = box.Top;
} }
return new Box(minX, minY, maxX - minX, maxY - minY); return new Box(minX, minY, maxX - minX, maxY - minY);
@@ -41,11 +45,15 @@ namespace OpenNest.Geometry
{ {
var vertex = pts[i]; var vertex = pts[i];
if (vertex.X < minX) minX = vertex.X; if (vertex.X < minX)
else if (vertex.X > maxX) maxX = vertex.X; minX = vertex.X;
else if (vertex.X > maxX)
maxX = vertex.X;
if (vertex.Y < minY) minY = vertex.Y; if (vertex.Y < minY)
else if (vertex.Y > maxY) maxY = vertex.Y; minY = vertex.Y;
else if (vertex.Y > maxY)
maxY = vertex.Y;
} }
return new Box(minX, minY, maxX - minX, maxY - minY); return new Box(minX, minY, maxX - minX, maxY - minY);
@@ -65,10 +73,14 @@ namespace OpenNest.Geometry
foreach (var box in items) foreach (var box in items)
{ {
if (box.Left < left) left = box.Left; if (box.Left < left)
if (box.Right > right) right = box.Right; left = box.Left;
if (box.Bottom < bottom) bottom = box.Bottom; if (box.Right > right)
if (box.Top > top) top = box.Top; right = box.Right;
if (box.Bottom < bottom)
bottom = box.Bottom;
if (box.Top > top)
top = box.Top;
} }
return new Box(left, bottom, right - left, top - bottom); return new Box(left, bottom, right - left, top - bottom);
+21 -13
View File
@@ -8,9 +8,7 @@ namespace OpenNest.Geometry
public static readonly Box Empty = new Box(); public static readonly Box Empty = new Box();
public Box() public Box()
: this(0, 0, 0, 0) : this(0, 0, 0, 0) { }
{
}
public Box(double x, double y, double w, double h) public Box(double x, double y, double w, double h)
{ {
@@ -117,10 +115,14 @@ namespace OpenNest.Geometry
public bool Intersects(Box box) public bool Intersects(Box box)
{ {
if (Left >= box.Right) return false; if (Left >= box.Right)
if (Right <= box.Left) return false; return false;
if (Top <= box.Bottom) return false; if (Right <= box.Left)
if (Bottom >= box.Top) return false; return false;
if (Top <= box.Bottom)
return false;
if (Bottom >= box.Top)
return false;
return true; return true;
} }
@@ -146,18 +148,24 @@ namespace OpenNest.Geometry
public bool Contains(Box box) public bool Contains(Box box)
{ {
if (box.Top > Top) return false; if (box.Top > Top)
if (box.Left < Left) return false; return false;
if (box.Right > Right) return false; if (box.Left < Left)
if (box.Bottom < Bottom) return false; return false;
if (box.Right > Right)
return false;
if (box.Bottom < Bottom)
return false;
return true; return true;
} }
public bool Contains(Vector pt) public bool Contains(Vector pt)
{ {
return pt.X >= Left - Tolerance.Epsilon && pt.X <= Right + Tolerance.Epsilon return pt.X >= Left - Tolerance.Epsilon
&& pt.Y >= Bottom - Tolerance.Epsilon && pt.Y <= Top + Tolerance.Epsilon; && pt.X <= Right + Tolerance.Epsilon
&& pt.Y >= Bottom - Tolerance.Epsilon
&& pt.Y <= Top + Tolerance.Epsilon;
} }
public bool IsHorizontalTo(Box box) public bool IsHorizontalTo(Box box)
+30 -31
View File
@@ -1,5 +1,5 @@
using OpenNest.Math; using System.Collections.Generic;
using System.Collections.Generic; using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -8,14 +8,10 @@ namespace OpenNest.Geometry
private Vector center; private Vector center;
private double radius; private double radius;
public Circle() public Circle() { }
{
}
public Circle(double x, double y, double radius) public Circle(double x, double y, double radius)
: this(new Vector(x, y), radius) : this(new Vector(x, y), radius) { }
{
}
public Circle(Vector center, double radius) public Circle(Vector center, double radius)
{ {
@@ -137,21 +133,22 @@ namespace OpenNest.Geometry
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false) public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
{ {
var points = new List<Vector>(); var points = new List<Vector>();
var stepAngle = Rotation == RotationType.CW var stepAngle =
? -Angle.TwoPI / segments Rotation == RotationType.CW ? -Angle.TwoPI / segments : Angle.TwoPI / segments;
: Angle.TwoPI / segments;
var r = circumscribe && segments > 0 var r =
? Radius / System.Math.Cos(stepAngle / 2.0) circumscribe && segments > 0 ? Radius / System.Math.Cos(stepAngle / 2.0) : Radius;
: Radius;
for (int i = 0; i <= segments; ++i) for (int i = 0; i <= segments; ++i)
{ {
var angle = stepAngle * i; var angle = stepAngle * i;
points.Add(new Vector( points.Add(
System.Math.Cos(angle) * r + Center.X, new Vector(
System.Math.Sin(angle) * r + Center.Y)); System.Math.Cos(angle) * r + Center.X,
System.Math.Sin(angle) * r + Center.Y
)
);
} }
return points; return points;
@@ -276,17 +273,18 @@ namespace OpenNest.Geometry
public override Entity OffsetEntity(double distance, OffsetSide side) public override Entity OffsetEntity(double distance, OffsetSide side)
{ {
if (side == OffsetSide.Left && Rotation == RotationType.CCW) // The center lies to the left of a CCW circle and to the right of a CW one.
var shrinks = (side == OffsetSide.Left) == (Rotation == RotationType.CCW);
if (shrinks)
{ {
return Radius <= distance ? null : new Circle(center, Radius - distance) return Radius <= distance
{ ? null
Layer = Layer, : new Circle(center, Radius - distance) { Layer = Layer, Rotation = Rotation };
Rotation = Rotation
};
} }
else else
{ {
return new Circle(center, Radius + distance) { Layer = Layer }; return new Circle(center, Radius + distance) { Layer = Layer, Rotation = Rotation };
} }
} }
@@ -294,11 +292,9 @@ namespace OpenNest.Geometry
{ {
if (ContainsPoint(pt)) if (ContainsPoint(pt))
{ {
return Radius <= distance ? null : new Circle(center, Radius - distance) return Radius <= distance
{ ? null
Layer = Layer, : new Circle(center, Radius - distance) { Layer = Layer, Rotation = Rotation };
Rotation = Rotation
};
} }
else else
{ {
@@ -317,7 +313,8 @@ namespace OpenNest.Geometry
return new Vector( return new Vector(
System.Math.Cos(angle) * Radius + Center.X, System.Math.Cos(angle) * Radius + Center.X,
System.Math.Sin(angle) * Radius + Center.Y); System.Math.Sin(angle) * Radius + Center.Y
);
} }
/// <summary> /// <summary>
@@ -350,7 +347,9 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle) public override bool Intersects(Circle circle)
{ {
var dist = Center.DistanceTo(circle.Center); var dist = Center.DistanceTo(circle.Center);
return (dist < (Radius + circle.Radius) && dist > System.Math.Abs(Radius - circle.Radius)); return (
dist < (Radius + circle.Radius) && dist > System.Math.Abs(Radius - circle.Radius)
);
} }
/// <summary> /// <summary>
+423
View File
@@ -0,0 +1,423 @@
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// Region offsetting through Clipper2, for CPU-side preparation only: work done
/// once per drawing, rotation or spacing whose output is cached and fed to hot
/// loops. Per-pair tests (<see cref="Collision"/>) stay hand-rolled so they can
/// be ported to a GPU kernel.
/// </summary>
public static class ClipperBridge
{
/// <summary>
/// Decimal places Clipper keeps (1e-4 in either inches or mm).
/// </summary>
public const int Precision = 4;
private const double MiterLimit = 2.0;
private const double ConservativeJoinFactor = 0.25;
private const double ValidationJoinFactor = 0.1;
/// <summary>
/// Converts a polygon to a Clipper path, dropping the closing vertex and
/// orienting it positive (CCW) or negative (CW).
/// </summary>
public static PathD ToPath(Polygon polygon, bool positive)
{
var path = ToPath(polygon, new Vector());
if (path.Count >= 3 && Clipper.IsPositive(path) != positive)
path.Reverse();
return path;
}
/// <summary>
/// Converts a polygon to a Clipper path with an optional offset, dropping the
/// closing vertex and keeping the polygon's own winding.
/// </summary>
public static PathD ToPath(Polygon polygon, Vector offset)
{
var verts = polygon.Vertices;
var n = verts.Count;
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
n--;
var path = new PathD(n);
for (var i = 0; i < n; i++)
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
return path;
}
/// <summary>
/// Converts a Clipper path to a closed polygon with updated bounds.
/// </summary>
public static Polygon ToPolygon(PathD path)
{
var polygon = new Polygon();
foreach (var pt in path)
polygon.Vertices.Add(new Vector(pt.x, pt.y));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
/// <summary>
/// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
/// </summary>
public static PathsD ToRegion(ShapeProfile profile, double tolerance, bool circumscribe)
{
var region = new PathsD(profile.Cutouts.Count + 1);
AddShape(region, profile.Perimeter, tolerance, circumscribe, positive: true);
// A cutout is flattened the opposite way: circumscribing it would shrink the
// material around it, so inscribe instead to keep the region conservative.
foreach (var cutout in profile.Cutouts)
AddShape(region, cutout, tolerance, !circumscribe, positive: false);
return region;
}
/// <summary>
/// Offsets a part region outward by <paramref name="distance"/>: the perimeter
/// grows and the cutouts shrink. Features narrower than twice the distance
/// collapse, and cutouts that close up disappear. Joins are round, with chords
/// no more than <paramref name="tolerance"/> from the true arc.
/// </summary>
/// <param name="circumscribe">
/// When true, the result never under-estimates the offset: perimeter arcs are
/// flattened outside the true curve, cutout arcs inside it, and the inflation is
/// padded by the round-join chord error and Clipper's rounding.
/// </param>
public static OffsetRegion Offset(
ShapeProfile profile,
double distance,
double tolerance,
bool circumscribe = false
)
{
var region = ToRegion(profile, tolerance, circumscribe);
return Offset(region, distance, tolerance, circumscribe);
}
/// <summary>
/// Offsets a single closed shape outward, ignoring any cutouts. A perimeter that
/// curls back on itself (a C shape with a narrow mouth) can gain holes.
/// </summary>
public static OffsetRegion OffsetPerimeter(
Shape perimeter,
double distance,
double tolerance,
bool circumscribe = false
)
{
var polygon = Flatten(perimeter, tolerance, circumscribe);
return OffsetPerimeter(polygon, distance, tolerance, circumscribe);
}
/// <summary>
/// Offsets a closed polygon outward, whatever its winding.
/// </summary>
public static OffsetRegion OffsetPerimeter(
Polygon perimeter,
double distance,
double tolerance,
bool circumscribe = false
)
{
var region = new PathsD(1);
AddPolygon(region, perimeter, positive: true);
return Offset(region, distance, tolerance, circumscribe);
}
/// <summary>
/// Offsets an already-flattened region (outers positive, holes negative).
/// A distance of zero only unions the region, with no conservative padding.
/// </summary>
public static OffsetRegion Offset(
PathsD region,
double distance,
double tolerance,
bool circumscribe = false
)
{
// Round joins put their vertices on the true arc, so each chord sits inside
// it by up to the join tolerance. In conservative mode, joins use a finer
// tolerance and the inflation is padded by it (plus Clipper's rounding).
var delta = distance;
var joinTolerance = tolerance;
if (circumscribe && distance > 0)
{
joinTolerance = tolerance * ConservativeJoinFactor;
delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision);
}
return Inflate(region, delta, joinTolerance);
}
/// <summary>
/// Offset for checking a finished layout against its spacing. Arcs are flattened
/// as in conservative mode (perimeter arcs circumscribed, cutout arcs inscribed),
/// but round joins use a tenth of the tolerance and nothing is padded, so a layout
/// exactly at the spacing passes. The only under-estimate is the join chord error
/// at convex corners, at most a tenth of <paramref name="tolerance"/>.
/// </summary>
public static OffsetRegion OffsetForValidation(
ShapeProfile profile,
double distance,
double tolerance
)
{
var region = ToRegion(profile, tolerance, circumscribe: true);
return Inflate(region, distance, tolerance * ValidationJoinFactor);
}
private static OffsetRegion Inflate(PathsD region, double delta, double joinTolerance)
{
var inflated =
delta <= 0
? Union(region)
: Clipper.InflatePaths(
region,
delta,
JoinType.Round,
EndType.Polygon,
MiterLimit,
Precision,
joinTolerance
);
var result = new OffsetRegion(new List<Polygon>(), new List<Polygon>());
foreach (var path in inflated)
{
if (path.Count < 3)
continue;
if (Clipper.IsPositive(path))
result.Outers.Add(ToPolygon(path));
else
result.Holes.Add(ToPolygon(path));
}
return result;
}
/// <summary>
/// Miter-offsets a closed polygon by <paramref name="delta"/> (positive grows it,
/// negative shrinks it). Returns the largest resulting polygon (CCW), or null
/// when the polygon collapses.
/// </summary>
public static Polygon OffsetMiter(Polygon polygon, double delta)
{
var path = ToPath(polygon, positive: true);
if (path.Count < 3)
return null;
var inflated = Clipper.InflatePaths(
new PathsD { path },
delta,
JoinType.Miter,
EndType.Polygon,
MiterLimit,
Precision
);
PathD largest = null;
var largestArea = 0.0;
foreach (var candidate in inflated)
{
var area = Clipper.Area(candidate);
if (area > largestArea)
{
largest = candidate;
largestArea = area;
}
}
return largest == null ? null : ToPolygon(largest);
}
/// <summary>
/// Flattens a closed shape to a polygon whose chords stay within
/// <paramref name="tolerance"/> of every arc. Inscribed, the vertices lie on the
/// arcs. Circumscribed, arc endpoints stay on the arc and the interior vertices sit
/// on tangent intersections, so the polygon never falls inside the curve and never
/// pokes past the straight edges an arc meets.
/// </summary>
public static Polygon Flatten(Shape shape, double tolerance, bool circumscribe)
{
var polygon = new Polygon();
foreach (var entity in shape.Entities)
{
switch (entity)
{
case Line line:
polygon.Vertices.Add(line.StartPoint);
polygon.Vertices.Add(line.EndPoint);
break;
case Arc arc:
AddArc(polygon.Vertices, arc, tolerance, circumscribe);
break;
case Circle circle:
AddCircle(polygon.Vertices, circle, tolerance, circumscribe);
break;
}
}
polygon.Close();
polygon.Cleanup();
polygon.UpdateBounds();
return polygon;
}
private static void AddArc(List<Vector> points, Arc arc, double tolerance, bool circumscribe)
{
if (!circumscribe)
{
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)));
return;
}
var sweep = arc.SweepAngle();
var segments = CircumscribedSegments(arc.Radius, sweep, tolerance);
var step = (arc.IsReversed ? -sweep : sweep) / segments;
var r = arc.Radius / System.Math.Cos(System.Math.Abs(step) / 2);
points.Add(arc.StartPoint());
for (var i = 0; i < segments; i++)
{
var angle = arc.StartAngle + step * (i + 0.5);
points.Add(
new Vector(
arc.Center.X + r * System.Math.Cos(angle),
arc.Center.Y + r * System.Math.Sin(angle)
)
);
}
points.Add(arc.EndPoint());
}
private static void AddCircle(
List<Vector> points,
Circle circle,
double tolerance,
bool circumscribe
)
{
if (!circumscribe)
{
points.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance)));
return;
}
var segments = CircumscribedSegments(circle.Radius, Angle.TwoPI, tolerance);
var step = Angle.TwoPI / segments;
var r = circle.Radius / System.Math.Cos(step / 2);
for (var i = 0; i < segments; i++)
{
points.Add(
new Vector(
circle.Center.X + r * System.Math.Cos(step * i),
circle.Center.Y + r * System.Math.Sin(step * i)
)
);
}
}
/// <summary>
/// Segments for a circumscribed arc: a tangent-intersection vertex sits
/// radius / cos(step / 2) from the center, so keep that within the tolerance, and
/// keep each step at 90 degrees or less so the tangents meet close to the arc.
/// </summary>
private static int CircumscribedSegments(double radius, double sweep, double tolerance)
{
var maxHalfStep = System.Math.Acos(radius / (radius + tolerance));
var segments = (int)System.Math.Ceiling(System.Math.Abs(sweep) / (2 * maxHalfStep));
var quarters = (int)System.Math.Ceiling(System.Math.Abs(sweep) / Angle.HalfPI);
return System.Math.Max(1, System.Math.Max(segments, quarters));
}
private static PathsD Union(PathsD region)
{
var clipper = new ClipperD(Precision);
clipper.AddSubject(region);
var solution = new PathsD();
clipper.Execute(ClipType.Union, FillRule.NonZero, solution);
return solution;
}
private static void AddShape(
PathsD region,
Shape shape,
double tolerance,
bool circumscribe,
bool positive
)
{
AddPolygon(region, Flatten(shape, tolerance, circumscribe), positive);
}
private static void AddPolygon(PathsD region, Polygon polygon, bool positive)
{
if (polygon.Vertices.Count < 3)
return;
var path = ToPath(polygon, positive);
if (path.Count >= 3)
region.Add(path);
}
}
/// <summary>
/// Result of <see cref="ClipperBridge.Offset(ShapeProfile, double, double, bool)"/>:
/// outer boundaries (CCW) and holes (CW), as closed polygons.
/// </summary>
public sealed record OffsetRegion(List<Polygon> Outers, List<Polygon> Holes)
{
/// <summary>
/// The outer boundary with the largest area, or null when the region is empty.
/// </summary>
public Polygon LargestOuter()
{
Polygon best = null;
var bestArea = 0.0;
foreach (var outer in Outers)
{
var area = outer.Area();
if (best == null || area > bestArea)
{
best = outer;
bestArea = area;
}
}
return best;
}
}
}
+153 -114
View File
@@ -1,12 +1,32 @@
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
/// <summary>
/// Polygon overlap test with hole subtraction. This is the reference implementation
/// for a future GPU kernel, so it deliberately stays hand-rolled instead of using
/// Clipper (which is CPU-only and allocation-heavy; see <see cref="ClipperBridge"/>
/// for the CPU preparation that feeds it).
/// <para>
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
/// then cached and uploaded: the spacing offset (<see cref="ClipperBridge"/>),
/// triangulation (<see cref="ConvexDecomposition.Triangulate"/>) of the outline and
/// each hole, and the bounding box of every polygon and triangle. Per-pair work,
/// kernel-shaped (fixed-size, loop-only, no recursion): the bounding-box rejects,
/// Sutherland-Hodgman clipping of convex triangle pairs (<c>ClipConvex</c>), and
/// subtraction of hole triangles from the clipped regions (<c>SubtractTriangles</c>).
/// Inputs are closed, lines-only polygons; winding is normalized by triangulation.
/// </para>
/// </summary>
public static class Collision public static class Collision
{ {
public static CollisionResult Check(Polygon a, Polygon b, public static CollisionResult Check(
List<Polygon> holesA = null, List<Polygon> holesB = null) Polygon a,
Polygon b,
List<Polygon> holesA = null,
List<Polygon> holesB = null
)
{ {
// Step 1: Bounding box pre-filter // Step 1: Bounding box pre-filter
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox)) if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
@@ -46,8 +66,12 @@ namespace OpenNest.Geometry
return new CollisionResult(true, regions, intersectionPoints); return new CollisionResult(true, regions, intersectionPoints);
} }
public static bool HasOverlap(Polygon a, Polygon b, public static bool HasOverlap(
List<Polygon> holesA = null, List<Polygon> holesB = null) Polygon a,
Polygon b,
List<Polygon> holesA = null,
List<Polygon> holesB = null
)
{ {
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox)) if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
return false; return false;
@@ -57,8 +81,10 @@ namespace OpenNest.Geometry
return Check(a, b, holesA, holesB).Overlaps; return Check(a, b, holesA, holesB).Overlaps;
} }
public static List<CollisionResult> CheckAll(List<Polygon> polygons, public static List<CollisionResult> CheckAll(
List<List<Polygon>> holes = null) List<Polygon> polygons,
List<List<Polygon>> holes = null
)
{ {
var results = new List<CollisionResult>(); var results = new List<CollisionResult>();
@@ -78,8 +104,7 @@ namespace OpenNest.Geometry
return results; return results;
} }
public static bool HasAnyOverlap(List<Polygon> polygons, public static bool HasAnyOverlap(List<Polygon> polygons, List<List<Polygon>> holes = null)
List<List<Polygon>> holes = null)
{ {
for (var i = 0; i < polygons.Count; i++) for (var i = 0; i < polygons.Count; i++)
{ {
@@ -98,10 +123,8 @@ namespace OpenNest.Geometry
private static bool BoundingBoxesOverlap(Box a, Box b) private static bool BoundingBoxesOverlap(Box a, Box b)
{ {
var overlapX = System.Math.Min(a.Right, b.Right) var overlapX = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left);
- System.Math.Max(a.Left, b.Left); var overlapY = System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom);
var overlapY = System.Math.Min(a.Top, b.Top)
- System.Math.Max(a.Bottom, b.Bottom);
return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon; return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon;
} }
@@ -161,67 +184,14 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
private static Polygon ClipConvex(Polygon subject, Polygon clip) private static Polygon ClipConvex(Polygon subject, Polygon clip)
{ {
var output = new List<Vector>(subject.Vertices); var output = OpenVertices(subject);
var clipVerts = OpenVertices(clip);
// Remove closing vertex if present for (var i = 0; i < clipVerts.Count && output.Count >= 3; i++)
if (output.Count > 1 && output[0].X == output[output.Count - 1].X
&& output[0].Y == output[output.Count - 1].Y)
output.RemoveAt(output.Count - 1);
var clipVerts = new List<Vector>(clip.Vertices);
if (clipVerts.Count > 1 && clipVerts[0].X == clipVerts[clipVerts.Count - 1].X
&& clipVerts[0].Y == clipVerts[clipVerts.Count - 1].Y)
clipVerts.RemoveAt(clipVerts.Count - 1);
for (var i = 0; i < clipVerts.Count; i++)
{ {
if (output.Count == 0) output = ClipHalfSpace(output, clipVerts[i], clipVerts[(i + 1) % clipVerts.Count], true);
return null;
var edgeStart = clipVerts[i];
var edgeEnd = clipVerts[(i + 1) % clipVerts.Count];
var input = output;
output = new List<Vector>();
for (var j = 0; j < input.Count; j++)
{
var current = input[j];
var next = input[(j + 1) % input.Count];
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
if (currentInside)
{
output.Add(current);
if (!nextInside)
{
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
if (ix.IsValid())
output.Add(ix);
}
}
else if (nextInside)
{
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
if (ix.IsValid())
output.Add(ix);
}
}
} }
if (output.Count < 3) return PositiveAreaPolygon(output);
return null;
var result = new Polygon();
result.Vertices.AddRange(output);
result.Close();
result.UpdateBounds();
// Reject degenerate slivers
if (result.Area() < Tolerance.Epsilon)
return null;
return result;
} }
/// <summary> /// <summary>
@@ -231,36 +201,23 @@ namespace OpenNest.Geometry
private static double Cross(Vector edgeStart, Vector edgeEnd, Vector point) private static double Cross(Vector edgeStart, Vector edgeEnd, Vector point)
{ {
return (edgeEnd.X - edgeStart.X) * (point.Y - edgeStart.Y) return (edgeEnd.X - edgeStart.X) * (point.Y - edgeStart.Y)
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X); - (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
}
/// <summary>
/// Intersection of lines (a1->a2) and (b1->b2). Returns Vector.Invalid if parallel.
/// </summary>
private static Vector LineIntersection(Vector a1, Vector a2, Vector b1, Vector b2)
{
var d1x = a2.X - a1.X;
var d1y = a2.Y - a1.Y;
var d2x = b2.X - b1.X;
var d2y = b2.Y - b1.Y;
var cross = d1x * d2y - d1y * d2x;
if (System.Math.Abs(cross) < Tolerance.Epsilon)
return Vector.Invalid;
var t = ((b1.X - a1.X) * d2y - (b1.Y - a1.Y) * d2x) / cross;
return new Vector(a1.X + t * d1x, a1.Y + t * d1y);
} }
/// <summary> /// <summary>
/// Subtracts holes from overlap regions. /// Subtracts holes from overlap regions.
/// </summary> /// </summary>
private static List<Polygon> SubtractHoles(List<Polygon> regions, private static List<Polygon> SubtractHoles(
List<Polygon> holesA, List<Polygon> holesB) List<Polygon> regions,
List<Polygon> holesA,
List<Polygon> holesB
)
{ {
var allHoles = new List<Polygon>(); var allHoles = new List<Polygon>();
if (holesA != null) allHoles.AddRange(holesA); if (holesA != null)
if (holesB != null) allHoles.AddRange(holesB); allHoles.AddRange(holesA);
if (holesB != null)
allHoles.AddRange(holesB);
if (allHoles.Count == 0) if (allHoles.Count == 0)
return regions; return regions;
@@ -286,8 +243,9 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Subtracts hole triangles from a region. Conservative: partial overlaps /// Subtracts hole triangles from a convex region. At each edge, emit the outside
/// keep the full piece triangle (acceptable for visual shading). /// portion and carry only the inside remainder to the next edge. The emitted
/// pieces are disjoint and convex, so no repeated triangulation is needed.
/// </summary> /// </summary>
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris) private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
{ {
@@ -295,36 +253,117 @@ namespace OpenNest.Geometry
foreach (var holeTri in holeTris) foreach (var holeTri in holeTris)
{ {
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
continue;
var next = new List<Polygon>(); var next = new List<Polygon>();
foreach (var piece in current) foreach (var piece in current)
{ {
var pieceTris = TriangulateWithBounds(piece); // Subtraction must also remove thin fragments created by clipping.
// The pair-level length tolerance would skip some of these even
foreach (var pieceTri in pieceTris) // when their area is large enough to count as an overlap.
var a = piece.BoundingBox;
var b = holeTri.BoundingBox;
if (a.Right <= b.Left || b.Right <= a.Left || a.Top <= b.Bottom || b.Top <= a.Bottom)
{ {
var inside = ClipConvex(pieceTri, holeTri); next.Add(piece);
if (inside == null) continue;
{ }
// No overlap with hole - keep
next.Add(pieceTri); var remainder = OpenVertices(piece);
} var holeVerts = OpenVertices(holeTri);
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon) for (var i = 0; i < holeVerts.Count && remainder.Count >= 3; i++)
{ {
// Partial overlap - keep the piece (conservative) var start = holeVerts[i];
next.Add(pieceTri); var end = holeVerts[(i + 1) % holeVerts.Count];
} var outside = PositiveAreaPolygon(ClipHalfSpace(remainder, start, end, false));
// else: fully inside hole - discard if (outside != null)
next.Add(outside);
remainder = ClipHalfSpace(remainder, start, end, true);
} }
} }
current = next; current = next;
if (current.Count == 0)
break;
} }
return current; return current;
} }
/// <summary>
/// Clips an open vertex list against one half-space. Classification and
/// interpolation use the same signed cross products: intersections always
/// lie on the input segment. An epsilon-shifted inside test combined with
/// intersections on the unshifted line can extrapolate and create material.
/// Apply the area tolerance only to the resulting polygons, not to edge signs.
/// </summary>
private static List<Vector> ClipHalfSpace(
List<Vector> vertices,
Vector edgeStart,
Vector edgeEnd,
bool inside
)
{
var kept = new List<Vector>();
for (var i = 0; i < vertices.Count; i++)
{
var current = vertices[i];
var next = vertices[(i + 1) % vertices.Count];
var currentDistance = Cross(edgeStart, edgeEnd, current);
var nextDistance = Cross(edgeStart, edgeEnd, next);
if (inside ? currentDistance >= 0 : currentDistance <= 0)
AddDistinct(kept, current);
// Only strict opposite signs cross the line. Boundary endpoints
// are already kept, and near-parallel crossings need no cutoff.
if ((currentDistance < 0 && nextDistance > 0) || (currentDistance > 0 && nextDistance < 0))
{
var t = currentDistance / (currentDistance - nextDistance);
AddDistinct(kept, new Vector(
current.X + t * (next.X - current.X),
current.Y + t * (next.Y - current.Y)));
}
}
if (kept.Count > 1 && SamePoint(kept[0], kept[kept.Count - 1]))
kept.RemoveAt(kept.Count - 1);
return kept;
}
private static bool SamePoint(Vector a, Vector b) => a.X == b.X && a.Y == b.Y;
private static void AddDistinct(List<Vector> vertices, Vector point)
{
if (vertices.Count == 0 || !SamePoint(vertices[vertices.Count - 1], point))
vertices.Add(point);
}
private static List<Vector> OpenVertices(Polygon polygon)
{
var vertices = new List<Vector>(polygon.Vertices);
if (vertices.Count > 1 && SamePoint(vertices[0], vertices[vertices.Count - 1]))
vertices.RemoveAt(vertices.Count - 1);
return vertices;
}
private static Polygon PositiveAreaPolygon(List<Vector> vertices)
{
if (vertices.Count < 3)
return null;
// Measure relative to a vertex to avoid cancellation of world-coordinate
// products when a small clipped fragment is far from the origin.
var twiceArea = 0.0;
for (var i = 1; i + 1 < vertices.Count; i++)
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
if (System.Math.Abs(twiceArea) <= 2 * Tolerance.Epsilon)
return null;
var polygon = new Polygon();
polygon.Vertices.AddRange(vertices);
// Polygon.Close uses fuzzy Vector equality; clipping needs an exact
// closing vertex even when the last edge is shorter than Epsilon.
polygon.Vertices.Add(vertices[0]);
polygon.UpdateBounds();
return polygon;
}
} }
} }
+10 -2
View File
@@ -5,9 +5,17 @@ namespace OpenNest.Geometry
{ {
public class CollisionResult public class CollisionResult
{ {
public static readonly CollisionResult None = new(false, new List<Polygon>(), new List<Vector>()); public static readonly CollisionResult None = new(
false,
new List<Polygon>(),
new List<Vector>()
);
public CollisionResult(bool overlaps, List<Polygon> overlapRegions, List<Vector> intersectionPoints) public CollisionResult(
bool overlaps,
List<Polygon> overlapRegions,
List<Vector> intersectionPoints
)
{ {
Overlaps = overlaps; Overlaps = overlaps;
OverlapRegions = overlapRegions; OverlapRegions = overlapRegions;
+13 -4
View File
@@ -19,8 +19,11 @@ namespace OpenNest.Geometry
var verts = new List<Vector>(polygon.Vertices); var verts = new List<Vector>(polygon.Vertices);
// Remove closing vertex if polygon is closed. // Remove closing vertex if polygon is closed.
if (verts.Count > 1 && verts[0].X == verts[verts.Count - 1].X if (
&& verts[0].Y == verts[verts.Count - 1].Y) verts.Count > 1
&& verts[0].X == verts[verts.Count - 1].X
&& verts[0].Y == verts[verts.Count - 1].Y
)
verts.RemoveAt(verts.Count - 1); verts.RemoveAt(verts.Count - 1);
if (verts.Count < 3) if (verts.Count < 3)
@@ -84,8 +87,14 @@ namespace OpenNest.Geometry
/// Tests whether the vertex at curr forms an ear (a convex vertex whose /// Tests whether the vertex at curr forms an ear (a convex vertex whose
/// triangle contains no other polygon vertices). /// triangle contains no other polygon vertices).
/// </summary> /// </summary>
private static bool IsEar(Vector prev, Vector curr, Vector next, private static bool IsEar(
List<Vector> verts, List<int> indices, int n) Vector prev,
Vector curr,
Vector next,
List<Vector> verts,
List<int> indices,
int n
)
{ {
// Must be convex (CCW turn). // Must be convex (CCW turn).
if (Cross(prev, curr, next) <= 0) if (Cross(prev, curr, next) <= 0)
+8 -2
View File
@@ -20,7 +20,10 @@ namespace OpenNest.Geometry
foreach (var p in sorted) foreach (var p in sorted)
{ {
while (lower.Count >= 2 && Cross(lower[lower.Count - 2], lower[lower.Count - 1], p) <= 0) while (
lower.Count >= 2
&& Cross(lower[lower.Count - 2], lower[lower.Count - 1], p) <= 0
)
lower.RemoveAt(lower.Count - 1); lower.RemoveAt(lower.Count - 1);
lower.Add(p); lower.Add(p);
@@ -32,7 +35,10 @@ namespace OpenNest.Geometry
{ {
var p = sorted[i]; var p = sorted[i];
while (upper.Count >= 2 && Cross(upper[upper.Count - 2], upper[upper.Count - 1], p) <= 0) while (
upper.Count >= 2
&& Cross(upper[upper.Count - 2], upper[upper.Count - 1], p) <= 0
)
upper.RemoveAt(upper.Count - 1); upper.RemoveAt(upper.Count - 1);
upper.Add(p); upper.Add(p);
+401
View File
@@ -0,0 +1,401 @@
#nullable enable
using System;
namespace OpenNest.Geometry
{
/// <summary>
/// Immutable flat-array polygon with a uniform edge grid, used as an outer-shell
/// clearance prefilter. Two closed polygons share positive area only when an edge pair
/// crosses/touches or one polygon's vertex lies strictly inside the other; neither
/// happening certifies the two closed regions (hence any materials inside them) are
/// clear. <see cref="Relate"/> returns Clear only in that certified case and Unknown
/// for uncertain contacts, so it can only ever skip the exact <see cref="Collision"/>
/// gate when the exact gate would also find no overlap - the exact gate triangulates
/// both polygons per call and dominates runtime on finely flattened arc geometry.
/// <para>
/// A <see cref="EdgeGridPolygonTemplate"/> holds the shared geometry; <see cref="Translated"/>
/// produces a placement in world coordinates in O(1) - translation leaves the grid and
/// all cell indices unchanged, only the predicate coordinates shift.
/// </para>
/// </summary>
public sealed class EdgeGridPolygon
{
/// <summary>Vertex-on-segment / collinearity tolerance for conservative touches.</summary>
private const double TouchEps = 1e-9;
private readonly EdgeGridPolygonTemplate _template;
/// <summary>Translation applied to the shared template geometry.</summary>
private readonly double Dx;
private readonly double Dy;
private EdgeGridPolygon(EdgeGridPolygonTemplate template, double dx, double dy)
{
_template = template;
Dx = dx;
Dy = dy;
}
private double MinX => _template.MinX + Dx;
private double MinY => _template.MinY + Dy;
private double MaxX => _template.MaxX + Dx;
private double MaxY => _template.MaxY + Dy;
/// <summary>
/// Builds from a closed <see cref="Polygon"/> (last vertex may repeat the first).
/// Returns null when the polygon has no usable ring - callers treat that as
/// "no information" and fall through to the exact gate.
/// </summary>
public static EdgeGridPolygon? From(Polygon polygon)
{
var template = EdgeGridPolygonTemplate.Build(polygon);
return template == null ? null : new EdgeGridPolygon(template, 0, 0);
}
/// <summary>Returns a placement sharing immutable geometry, with an added translation.</summary>
public EdgeGridPolygon Translated(double dx, double dy) => new(_template, Dx + dx, Dy + dy);
private double X(int i) => _template.X[i] + Dx;
private double Y(int i) => _template.Y[i] + Dy;
/// <summary>
/// Certifies disjoint filled perimeters. Any crossing, containment or uncertain
/// boundary contact returns Unknown and must defer to the exact collision test.
/// Holes need not be supplied: removing material cannot invalidate Clear.
/// </summary>
public static ShellRelation Relate(EdgeGridPolygon a, EdgeGridPolygon b)
{
if (
a.MaxX <= b.MinX
|| b.MaxX <= a.MinX
|| a.MaxY <= b.MinY
|| b.MaxY <= a.MinY
)
return ShellRelation.Clear; // disjoint bounding boxes
// One walk per direction reports the strongest edge relation: a transversal
// crossing shares a positive-area wedge (overlap); a mere touch shares zero
// area but may hide a crossing in near-degenerate coordinates (unknown).
var edge = EdgeRelation(a, b);
if (edge < 2)
{
var back = EdgeRelation(b, a);
if (back > edge)
edge = back;
}
if (edge == 2)
return ShellRelation.Unknown;
// Only fully disjoint boundaries can certify clearance. Point touches and
// collinear/near-degenerate contacts always defer to the reference test.
switch (edge)
{
case 0:
if (ContainsPointStrictly(a, b.X(0), b.Y(0)))
return ShellRelation.Unknown;
if (ContainsPointStrictly(b, a.X(0), a.Y(0)))
return ShellRelation.Unknown;
return ShellRelation.Clear;
default:
return ShellRelation.Unknown;
}
}
/// <summary>
/// Classifies whether any edge of <paramref name="q"/> crosses or touches the boundary of
/// <paramref name="p"/>. Walks p's grid using each query edge's own bbox cells.
/// p's grid lives in p's LOCAL frame (the template's own coordinates), so the
/// query edge is converted by subtracting p's translation first.
/// </summary>
private static int EdgeRelation(EdgeGridPolygon p, EdgeGridPolygon q)
{
var t = p._template;
var n = t.Count;
Span<int> seen = n <= 1024 ? stackalloc int[n] : new int[n];
seen.Clear();
var head = t.Head;
var nodeEdge = t.NodeEdge;
var nodeNext = t.NodeNext;
var no = q._template.Count;
var strongest = 0;
for (var e = 0; e < no; e++)
{
// Stamp per QUERY edge: a grid edge may need testing against every query
// edge; the dedupe only collapses cells an individual query edge crosses
// more than once.
var stamp = e + 1;
var i2 = (e + 1) % no;
var p0x = q.X(e) - p.Dx;
var p0y = q.Y(e) - p.Dy;
var p1x = q.X(i2) - p.Dx;
var p1y = q.Y(i2) - p.Dy;
var c0 = ColLow(t, p0x, p1x);
if (c0 > ColHigh(t, p0x, p1x))
continue;
var c1 = ColHigh(t, p0x, p1x);
var r0 = RowLow(t, p0y, p1y);
if (r0 > RowHigh(t, p0y, p1y))
continue;
var r1 = RowHigh(t, p0y, p1y);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
for (var nIdx = head[r * t.Cols + c]; nIdx >= 0; nIdx = nodeNext[nIdx])
{
var ea = nodeEdge[nIdx];
if (seen[ea] == stamp)
continue;
seen[ea] = stamp;
var a2 = (ea + 1) % n;
var relation = SegmentRelation(
t.X[ea], t.Y[ea], t.X[a2], t.Y[a2], p0x, p0y, p1x, p1y
);
if (relation == 2)
return 2; // transversal crossing
if (relation > strongest)
strongest = relation;
}
}
return strongest;
}
/// <summary>
/// Segment-pair relation: 2 = transversal crossing (strict sign flips on both
/// orientations - the regions share a positive-area wedge); 1 = a clean endpoint
/// touch (zero shared area by itself; callers decide via interior-vertex tests);
/// 3 = collinear or near-degenerate contact (a shared boundary segment can hide
/// either a same-side positive overlap or an opposite-side tangency, so it must
/// defer to the exact gate); 0 = disjoint.
/// </summary>
private static int SegmentRelation(
double ax, double ay, double bx, double by, double cx, double cy, double dx, double dy
)
{
var rx = bx - ax;
var ry = by - ay;
var sx = dx - cx;
var sy = dy - cy;
var d1 = rx * (cy - ay) - ry * (cx - ax);
var d2 = rx * (dy - ay) - ry * (dx - ax);
var d3 = sx * (ay - cy) - sy * (ax - cx);
var d4 = sx * (by - cy) - sy * (bx - cx);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
return 2; // proper crossing
// A near-zero orientation means the configuration is collinear or too close to
// classify; only exact-zero orientations get the clean point-touch verdict.
var scale = System.Math.Max(
1e-30,
System.Math.Max(System.Math.Abs(rx) + System.Math.Abs(ry), System.Math.Abs(sx) + System.Math.Abs(sy))
);
var eps = TouchEps * scale;
var nearDegenerate =
(System.Math.Abs(d1) <= eps && d1 != 0)
|| (System.Math.Abs(d2) <= eps && d2 != 0)
|| (System.Math.Abs(d3) <= eps && d3 != 0)
|| (System.Math.Abs(d4) <= eps && d4 != 0);
var exactDegenerate = d1 == 0 || d2 == 0 || d3 == 0 || d4 == 0;
var touch =
(d1 == 0 && PointOnSegment(cx, cy, ax, ay, bx, by))
|| (d2 == 0 && PointOnSegment(dx, dy, ax, ay, bx, by))
|| (d3 == 0 && PointOnSegment(ax, ay, cx, cy, dx, dy))
|| (d4 == 0 && PointOnSegment(bx, by, cx, cy, dx, dy));
if (nearDegenerate)
return 3;
if (exactDegenerate)
// Collinear: contact along a segment (or too close to tell) must defer to
// the exact gate; collinear but disjoint edges simply do not touch.
return touch ? 3 : 0;
if (touch)
return 1;
return 0;
}
private static bool PointOnSegment(
double px, double py, double ax, double ay, double bx, double by
) =>
System.Math.Min(ax, bx) - TouchEps <= px
&& px <= System.Math.Max(ax, bx) + TouchEps
&& System.Math.Min(ay, by) - TouchEps <= py
&& py <= System.Math.Max(ay, by) + TouchEps;
/// <summary>Strict ray-cast containment (boundary touches are excluded upstream).</summary>
private static bool ContainsPointStrictly(EdgeGridPolygon poly, double px, double py)
{
var t = poly._template;
var inside = false;
var n = t.Count;
for (var i = 0; i < n; i++)
{
var j = (i + 1) % n;
var yi = poly.Y(i);
var yj = poly.Y(j);
if ((yi > py) != (yj > py))
{
var xAt = poly.X(i) + (py - yi) / (yj - yi) * (poly.X(j) - poly.X(i));
if (px < xAt)
inside = !inside;
}
}
return inside;
}
private static int ColLow(EdgeGridPolygonTemplate t, double a, double b) =>
System.Math.Clamp((int)System.Math.Floor((System.Math.Min(a, b) - t.MinX) / t.CellSize), 0, t.Cols);
private static int ColHigh(EdgeGridPolygonTemplate t, double a, double b) =>
System.Math.Clamp((int)System.Math.Floor((System.Math.Max(a, b) - t.MinX) / t.CellSize), -1, t.Cols - 1);
private static int RowLow(EdgeGridPolygonTemplate t, double a, double b) =>
System.Math.Clamp((int)System.Math.Floor((System.Math.Min(a, b) - t.MinY) / t.CellSize), 0, t.Rows);
private static int RowHigh(EdgeGridPolygonTemplate t, double a, double b) =>
System.Math.Clamp((int)System.Math.Floor((System.Math.Max(a, b) - t.MinY) / t.CellSize), -1, t.Rows - 1);
/// <summary>
/// Shared, immutable grid geometry for <see cref="EdgeGridPolygon"/>; the grid is defined
/// relative to the shape's own local coordinates, so translated instances reuse it.
/// Per-query deduplication scratch is local, so placements may be queried concurrently.
/// </summary>
private sealed class EdgeGridPolygonTemplate
{
internal readonly double[] X;
internal readonly double[] Y;
internal readonly int Count;
internal readonly double MinX;
internal readonly double MinY;
internal readonly double MaxX;
internal readonly double MaxY;
internal readonly double CellSize;
internal readonly int Cols;
internal readonly int Rows;
internal readonly int[] Head;
/// <summary>
/// Grid nodes as parallel (edge, next) arrays: an edge spanning several cells gets
/// one node PER cell - a single next-per-edge chain would corrupt the other cells'
/// chains and silently drop edges from the walk.
/// </summary>
internal readonly int[] NodeEdge;
internal readonly int[] NodeNext;
private EdgeGridPolygonTemplate(
double[] x,
double[] y,
int count,
double minX,
double minY,
double maxX,
double maxY
)
{
X = x;
Y = y;
Count = count;
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
var extentX = System.Math.Max(maxX - minX, 1e-9);
var extentY = System.Math.Max(maxY - minY, 1e-9);
CellSize = System.Math.Max(System.Math.Max(extentX, extentY) / 16.0, 1e-9);
Cols = System.Math.Clamp((int)System.Math.Ceiling(extentX / CellSize) + 1, 1, 48);
Rows = System.Math.Clamp((int)System.Math.Ceiling(extentY / CellSize) + 1, 1, 48);
Head = new int[Cols * Rows];
Array.Fill(Head, -1);
// Pass 1: count nodes; pass 2: fill (edge, next) node arrays.
var cellsPerEdge = new int[count];
var total = 0;
for (var e = 0; e < count; e++)
{
var i2 = (e + 1) % count;
var c0 = ClampCol(System.Math.Min(x[e], x[i2]) - minX);
var c1 = ClampCol(System.Math.Max(x[e], x[i2]) - minX);
var r0 = ClampRow(System.Math.Min(y[e], y[i2]) - minY);
var r1 = ClampRow(System.Math.Max(y[e], y[i2]) - minY);
cellsPerEdge[e] = (c1 - c0 + 1) * (r1 - r0 + 1);
total += cellsPerEdge[e];
}
NodeEdge = new int[total];
NodeNext = new int[total];
var node = 0;
for (var e = 0; e < count; e++)
{
var i2 = (e + 1) % count;
var c0 = ClampCol(System.Math.Min(x[e], x[i2]) - minX);
var c1 = ClampCol(System.Math.Max(x[e], x[i2]) - minX);
var r0 = ClampRow(System.Math.Min(y[e], y[i2]) - minY);
var r1 = ClampRow(System.Math.Max(y[e], y[i2]) - minY);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
{
var cell = r * Cols + c;
NodeEdge[node] = e;
NodeNext[node] = Head[cell];
Head[cell] = node;
node++;
}
}
}
private int ClampCol(double dx) =>
System.Math.Clamp((int)System.Math.Floor(dx / CellSize), 0, Cols - 1);
private int ClampRow(double dy) =>
System.Math.Clamp((int)System.Math.Floor(dy / CellSize), 0, Rows - 1);
internal static EdgeGridPolygonTemplate? Build(Polygon polygon)
{
var vertices = polygon.Vertices;
var n = vertices.Count;
if (n >= 2 && vertices[0].X == vertices[n - 1].X && vertices[0].Y == vertices[n - 1].Y)
n--;
if (n < 3)
return null;
var xs = new double[n];
var ys = new double[n];
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var i = 0; i < n; i++)
{
var vx = vertices[i].X;
var vy = vertices[i].Y;
xs[i] = vx;
ys[i] = vy;
if (vx < minX)
minX = vx;
if (vx > maxX)
maxX = vx;
if (vy < minY)
minY = vy;
if (vy > maxY)
maxY = vy;
}
return new EdgeGridPolygonTemplate(xs, ys, n, minX, minY, maxX, maxY);
}
}
}
/// <summary>Conservative result of an outer-perimeter prefilter.</summary>
public enum ShellRelation
{
/// <summary>Filled perimeters, and therefore their material, are disjoint.</summary>
Clear,
/// <summary>Run an exact collision test; the prefilter cannot certify clearance.</summary>
Unknown,
}
}
+140 -39
View File
@@ -1,6 +1,6 @@
using OpenNest.Math;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -9,7 +9,13 @@ namespace OpenNest.Geometry
private const int MaxSubdivisionDepth = 12; private const int MaxSubdivisionDepth = 12;
private const int DeviationSamples = 20; private const int DeviationSamples = 20;
internal static Vector EvaluatePoint(double semiMajor, double semiMinor, double rotation, Vector center, double t) internal static Vector EvaluatePoint(
double semiMajor,
double semiMinor,
double rotation,
Vector center,
double t
)
{ {
var x = semiMajor * System.Math.Cos(t); var x = semiMajor * System.Math.Cos(t);
var y = semiMinor * System.Math.Sin(t); var y = semiMinor * System.Math.Sin(t);
@@ -17,12 +23,15 @@ namespace OpenNest.Geometry
var cos = System.Math.Cos(rotation); var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation); var sin = System.Math.Sin(rotation);
return new Vector( return new Vector(center.X + x * cos - y * sin, center.Y + x * sin + y * cos);
center.X + x * cos - y * sin,
center.Y + x * sin + y * cos);
} }
internal static Vector EvaluateTangent(double semiMajor, double semiMinor, double rotation, double t) internal static Vector EvaluateTangent(
double semiMajor,
double semiMinor,
double rotation,
double t
)
{ {
var tx = -semiMajor * System.Math.Sin(t); var tx = -semiMajor * System.Math.Sin(t);
var ty = semiMinor * System.Math.Cos(t); var ty = semiMinor * System.Math.Cos(t);
@@ -30,12 +39,15 @@ namespace OpenNest.Geometry
var cos = System.Math.Cos(rotation); var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation); var sin = System.Math.Sin(rotation);
return new Vector( return new Vector(tx * cos - ty * sin, tx * sin + ty * cos);
tx * cos - ty * sin,
tx * sin + ty * cos);
} }
internal static Vector EvaluateNormal(double semiMajor, double semiMinor, double rotation, double t) internal static Vector EvaluateNormal(
double semiMajor,
double semiMinor,
double rotation,
double t
)
{ {
// Inward normal: perpendicular to tangent, pointing toward center of curvature. // Inward normal: perpendicular to tangent, pointing toward center of curvature.
// In local coords: N(t) = (-b*cos(t), -a*sin(t)) // In local coords: N(t) = (-b*cos(t), -a*sin(t))
@@ -45,9 +57,7 @@ namespace OpenNest.Geometry
var cos = System.Math.Cos(rotation); var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation); var sin = System.Math.Sin(rotation);
return new Vector( return new Vector(nx * cos - ny * sin, nx * sin + ny * cos);
nx * cos - ny * sin,
nx * sin + ny * cos);
} }
internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2) internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2)
@@ -83,11 +93,21 @@ namespace OpenNest.Geometry
return new Vector(ux + c.X, uy + c.Y); return new Vector(ux + c.X, uy + c.Y);
} }
public static List<Entity> Convert(Vector center, double semiMajor, double semiMinor, public static List<Entity> Convert(
double rotation, double startParam, double endParam, double tolerance = 0.001) Vector center,
double semiMajor,
double semiMinor,
double rotation,
double startParam,
double endParam,
double tolerance = 0.001
)
{ {
if (tolerance <= 0) if (tolerance <= 0)
throw new ArgumentOutOfRangeException(nameof(tolerance), "Tolerance must be positive."); throw new ArgumentOutOfRangeException(
nameof(tolerance),
"Tolerance must be positive."
);
if (semiMajor <= 0 || semiMinor <= 0) if (semiMajor <= 0 || semiMinor <= 0)
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive."); throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
@@ -102,14 +122,28 @@ namespace OpenNest.Geometry
var entities = new List<Entity>(); var entities = new List<Entity>();
for (var i = 0; i < splits.Count - 1; i++) for (var i = 0; i < splits.Count - 1; i++)
FitSegment(center, semiMajor, semiMinor, rotation, FitSegment(
splits[i], splits[i + 1], tolerance, entities, 0); center,
semiMajor,
semiMinor,
rotation,
splits[i],
splits[i + 1],
tolerance,
entities,
0
);
return entities; return entities;
} }
private static List<Entity> ConvertCircle(Vector center, double radius, private static List<Entity> ConvertCircle(
double rotation, double startParam, double endParam) Vector center,
double radius,
double rotation,
double startParam,
double endParam
)
{ {
var sweep = endParam - startParam; var sweep = endParam - startParam;
var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01; var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01;
@@ -123,7 +157,7 @@ namespace OpenNest.Geometry
return new List<Entity> return new List<Entity>
{ {
new Arc(center, radius, startAngle1, midAngle, false), new Arc(center, radius, startAngle1, midAngle, false),
new Arc(center, radius, midAngle, endAngle2, false) new Arc(center, radius, midAngle, endAngle2, false),
}; };
} }
@@ -136,7 +170,8 @@ namespace OpenNest.Geometry
{ {
var splits = new List<double> { startParam }; var splits = new List<double> { startParam };
var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2); var firstQuadrant =
System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2);
for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2) for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2)
{ {
if (q > startParam + 1e-10 && q < endParam - 1e-10) if (q > startParam + 1e-10 && q < endParam - 1e-10)
@@ -147,8 +182,17 @@ namespace OpenNest.Geometry
return splits; return splits;
} }
private static void FitSegment(Vector center, double semiMajor, double semiMinor, private static void FitSegment(
double rotation, double t0, double t1, double tolerance, List<Entity> results, int depth) Vector center,
double semiMajor,
double semiMinor,
double rotation,
double t0,
double t1,
double tolerance,
List<Entity> results,
int depth
)
{ {
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0); var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0);
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1); var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1);
@@ -168,12 +212,29 @@ namespace OpenNest.Geometry
} }
var radius = p0.DistanceTo(arcCenter); var radius = p0.DistanceTo(arcCenter);
var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation, var maxDev = MeasureDeviation(
t0, t1, arcCenter, radius); center,
semiMajor,
semiMinor,
rotation,
t0,
t1,
arcCenter,
radius
);
if (maxDev <= tolerance) if (maxDev <= tolerance)
{ {
var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1); var arc = CreateArc(
arcCenter,
radius,
center,
semiMajor,
semiMinor,
rotation,
t0,
t1
);
if (arc.SweepAngle() < Tolerance.Epsilon) if (arc.SweepAngle() < Tolerance.Epsilon)
results.Add(new Line(p0, p1)); results.Add(new Line(p0, p1));
else else
@@ -182,13 +243,41 @@ namespace OpenNest.Geometry
else else
{ {
var tMid = (t0 + t1) / 2.0; var tMid = (t0 + t1) / 2.0;
FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1); FitSegment(
FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1); center,
semiMajor,
semiMinor,
rotation,
t0,
tMid,
tolerance,
results,
depth + 1
);
FitSegment(
center,
semiMajor,
semiMinor,
rotation,
tMid,
t1,
tolerance,
results,
depth + 1
);
} }
} }
private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor, private static double MeasureDeviation(
double rotation, double t0, double t1, Vector arcCenter, double radius) Vector center,
double semiMajor,
double semiMinor,
double rotation,
double t0,
double t1,
Vector arcCenter,
double radius
)
{ {
var maxDev = 0.0; var maxDev = 0.0;
for (var i = 1; i <= DeviationSamples; i++) for (var i = 1; i <= DeviationSamples; i++)
@@ -197,14 +286,22 @@ namespace OpenNest.Geometry
var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t); var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t);
var dist = p.DistanceTo(arcCenter); var dist = p.DistanceTo(arcCenter);
var dev = System.Math.Abs(dist - radius); var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev; if (dev > maxDev)
maxDev = dev;
} }
return maxDev; return maxDev;
} }
private static Arc CreateArc(Vector arcCenter, double radius, private static Arc CreateArc(
Vector ellipseCenter, double semiMajor, double semiMinor, double rotation, Vector arcCenter,
double t0, double t1) double radius,
Vector ellipseCenter,
double semiMajor,
double semiMinor,
double rotation,
double t0,
double t1
)
{ {
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0); var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1); var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
@@ -225,8 +322,10 @@ namespace OpenNest.Geometry
var points = new List<Vector> { p0, pMid, p1 }; var points = new List<Vector> { p0, pMid, p1 };
var isReversed = SumSignedAngles(arcCenter, points) < 0; var isReversed = SumSignedAngles(arcCenter, points) < 0;
if (startAngle < 0) startAngle += Angle.TwoPI; if (startAngle < 0)
if (endAngle < 0) endAngle += Angle.TwoPI; startAngle += Angle.TwoPI;
if (endAngle < 0)
endAngle += Angle.TwoPI;
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed); return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
} }
@@ -239,8 +338,10 @@ namespace OpenNest.Geometry
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X); var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X); var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1; var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI; while (da > System.Math.PI)
while (da < -System.Math.PI) da += Angle.TwoPI; da -= Angle.TwoPI;
while (da < -System.Math.PI)
da += Angle.TwoPI;
total += da; total += da;
} }
return total; return total;
+7 -3
View File
@@ -1,7 +1,7 @@
using OpenNest.Math; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -331,7 +331,11 @@ namespace OpenNest.Geometry
return points; return points;
} }
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI) public static BoundingRectangleResult FindBestRotation(
this List<Entity> entities,
double startAngle = 0,
double endAngle = Angle.TwoPI
)
{ {
// Check for Shape entity first (recursive case returns early) // Check for Shape entity first (recursive case returns early)
foreach (var entity in entities) foreach (var entity in entities)
+2 -3
View File
@@ -1,5 +1,4 @@
 namespace OpenNest.Geometry
namespace OpenNest.Geometry
{ {
public enum EntityType public enum EntityType
{ {
@@ -7,6 +6,6 @@ namespace OpenNest.Geometry
Circle, Circle,
Line, Line,
Shape, Shape,
Polygon Polygon,
} }
} }
+88 -47
View File
@@ -1,21 +1,25 @@
using OpenNest.Math;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
public static class GeometryOptimizer public static class GeometryOptimizer
{ {
public static void Optimize(IList<Arc> arcs) => public static void Optimize(IList<Arc> arcs) =>
MergePass(arcs, MergePass(
arcs,
(list, item, i) => list.GetCoradialArs(item, i), (list, item, i) => list.GetCoradialArs(item, i),
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined)); (Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined)
);
public static void Optimize(IList<Line> lines) => public static void Optimize(IList<Line> lines) =>
MergePass(lines, MergePass(
lines,
(list, item, i) => list.GetCollinearLines(item, i), (list, item, i) => list.GetCollinearLines(item, i),
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)); (Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)
);
public static void Deduplicate(IList<Circle> circles) public static void Deduplicate(IList<Circle> circles)
{ {
@@ -23,8 +27,10 @@ namespace OpenNest.Geometry
{ {
for (var j = i - 1; j >= 0; j--) for (var j = i - 1; j >= 0; j--)
{ {
if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon if (
&& circles[i].Radius.IsEqualTo(circles[j].Radius)) circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
&& circles[i].Radius.IsEqualTo(circles[j].Radius)
)
{ {
circles.RemoveAt(i); circles.RemoveAt(i);
break; break;
@@ -39,9 +45,11 @@ namespace OpenNest.Geometry
{ {
for (var j = arcs.Count - 1; j >= 0; j--) for (var j = arcs.Count - 1; j >= 0; j--)
{ {
if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon if (
arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
&& arcs[j].Radius.IsEqualTo(circles[i].Radius) && arcs[j].Radius.IsEqualTo(circles[i].Radius)
&& arcs[j].IsFullCircle()) && arcs[j].IsFullCircle()
)
{ {
arcs.RemoveAt(j); arcs.RemoveAt(j);
} }
@@ -51,9 +59,12 @@ namespace OpenNest.Geometry
private delegate bool TryJoin<T>(T a, T b, out T joined); private delegate bool TryJoin<T>(T a, T b, out T joined);
private static void MergePass<T>(IList<T> items, private static void MergePass<T>(
IList<T> items,
Func<IList<T>, T, int, List<T>> findCandidates, Func<IList<T>, T, int, List<T>> findCandidates,
TryJoin<T> tryJoin) where T : class TryJoin<T> tryJoin
)
where T : class
{ {
for (var i = 0; i < items.Count; ++i) for (var i = 0; i < items.Count; ++i)
{ {
@@ -117,10 +128,14 @@ namespace OpenNest.Geometry
if (!onPoint) if (!onPoint)
{ {
if (t1 < b2 - Tolerance.Epsilon) return false; if (t1 < b2 - Tolerance.Epsilon)
if (b1 > t2 + Tolerance.Epsilon) return false; return false;
if (l1 > r2 + Tolerance.Epsilon) return false; if (b1 > t2 + Tolerance.Epsilon)
if (r1 < l2 - Tolerance.Epsilon) return false; return false;
if (l1 > r2 + Tolerance.Epsilon)
return false;
if (r1 < l2 - Tolerance.Epsilon)
return false;
} }
var l = l1 < l2 ? l1 : l2; var l = l1 < l2 ? l1 : l2;
@@ -129,9 +144,17 @@ namespace OpenNest.Geometry
var b = b1 < b2 ? b1 : b2; var b = b1 < b2 ? b1 : b2;
if (!line1.IsVertical() && line1.Slope() < 0) if (!line1.IsVertical() && line1.Slope() < 0)
lineOut = new Line(new Vector(l, t), new Vector(r, b)) { Layer = line1.Layer, Color = line1.Color }; lineOut = new Line(new Vector(l, t), new Vector(r, b))
{
Layer = line1.Layer,
Color = line1.Color,
};
else else
lineOut = new Line(new Vector(l, b), new Vector(r, t)) { Layer = line1.Layer, Color = line1.Color }; lineOut = new Line(new Vector(l, b), new Vector(r, t))
{
Layer = line1.Layer,
Color = line1.Color,
};
return true; return true;
} }
@@ -177,33 +200,47 @@ namespace OpenNest.Geometry
if (sweep >= Angle.TwoPI - Tolerance.Epsilon) if (sweep >= Angle.TwoPI - Tolerance.Epsilon)
return false; return false;
if (startAngle < 0) startAngle += Angle.TwoPI; if (startAngle < 0)
if (endAngle < 0) endAngle += Angle.TwoPI; startAngle += Angle.TwoPI;
if (endAngle < 0)
endAngle += Angle.TwoPI;
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle) { Layer = arc1.Layer, Color = arc1.Color }; arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle)
{
Layer = arc1.Layer,
Color = arc1.Color,
};
return true; return true;
} }
private static List<Line> GetCollinearLines(this IList<Line> lines, Line line, int startIndex) private static List<Line> GetCollinearLines(
this IList<Line> lines,
Line line,
int startIndex
)
{ {
var collinearLines = new List<Line>(); var collinearLines = new List<Line>();
Parallel.For(startIndex, lines.Count, index => Parallel.For(
{ startIndex,
var compareLine = lines[index]; lines.Count,
index =>
if (Object.ReferenceEquals(line, compareLine))
return;
if (!line.IsCollinearTo(compareLine))
return;
lock (collinearLines)
{ {
collinearLines.Add(compareLine); var compareLine = lines[index];
if (Object.ReferenceEquals(line, compareLine))
return;
if (!line.IsCollinearTo(compareLine))
return;
lock (collinearLines)
{
collinearLines.Add(compareLine);
}
} }
}); );
return collinearLines; return collinearLines;
} }
@@ -212,21 +249,25 @@ namespace OpenNest.Geometry
{ {
var coradialArcs = new List<Arc>(); var coradialArcs = new List<Arc>();
Parallel.For(startIndex, arcs.Count, index => Parallel.For(
{ startIndex,
var compareArc = arcs[index]; arcs.Count,
index =>
if (Object.ReferenceEquals(arc, compareArc))
return;
if (!arc.IsCoradialTo(compareArc))
return;
lock (coradialArcs)
{ {
coradialArcs.Add(compareArc); var compareArc = arcs[index];
if (Object.ReferenceEquals(arc, compareArc))
return;
if (!arc.IsCoradialTo(compareArc))
return;
lock (coradialArcs)
{
coradialArcs.Add(compareArc);
}
} }
}); );
return coradialArcs; return coradialArcs;
} }
+350 -92
View File
@@ -15,8 +15,10 @@ public class ArcCandidate
public double MaxDeviation { get; set; } public double MaxDeviation { get; set; }
public Box BoundingBox { get; set; } public Box BoundingBox { get; set; }
public bool IsSelected { get; set; } = true; public bool IsSelected { get; set; } = true;
/// <summary>First point of the original line segments this candidate covers.</summary> /// <summary>First point of the original line segments this candidate covers.</summary>
public Vector FirstPoint { get; set; } public Vector FirstPoint { get; set; }
/// <summary>Last point of the original line segments this candidate covers.</summary> /// <summary>Last point of the original line segments this candidate covers.</summary>
public Vector LastPoint { get; set; } public Vector LastPoint { get; set; }
} }
@@ -46,9 +48,7 @@ public class MirrorAxisResult
var dx = p.X - Point.X; var dx = p.X - Point.X;
var dy = p.Y - Point.Y; var dy = p.Y - Point.Y;
var dot = dx * Direction.X + dy * Direction.Y; var dot = dx * Direction.X + dy * Direction.Y;
return new Vector( return new Vector(p.X - 2 * (dx - dot * Direction.X), p.Y - 2 * (dy - dot * Direction.Y));
p.X - 2 * (dx - dot * Direction.X),
p.Y - 2 * (dy - dot * Direction.Y));
} }
} }
@@ -74,9 +74,14 @@ public class GeometrySimplifier
var runStart = i; var runStart = i;
var layerName = entities[i].Layer?.Name; var layerName = entities[i].Layer?.Name;
var lineCount = 0; var lineCount = 0;
while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer?.Name == layerName) while (
i < entities.Count
&& (entities[i] is Line || entities[i] is Arc)
&& entities[i].Layer?.Name == layerName
)
{ {
if (entities[i] is Line) lineCount++; if (entities[i] is Line)
lineCount++;
i++; i++;
} }
var runEnd = i - 1; var runEnd = i - 1;
@@ -90,10 +95,7 @@ public class GeometrySimplifier
public Shape Apply(Shape shape, List<ArcCandidate> candidates) public Shape Apply(Shape shape, List<ArcCandidate> candidates)
{ {
var selected = candidates var selected = candidates.Where(c => c.IsSelected).OrderBy(c => c.StartIndex).ToList();
.Where(c => c.IsSelected)
.OrderBy(c => c.StartIndex)
.ToList();
var newEntities = new List<Entity>(); var newEntities = new List<Entity>();
var i = 0; var i = 0;
@@ -132,11 +134,10 @@ public class GeometrySimplifier
foreach (var e in shape.Entities) foreach (var e in shape.Entities)
midpoints.Add(e.BoundingBox.Center); midpoints.Add(e.BoundingBox.Center);
if (midpoints.Count < 4) return MirrorAxisResult.None; if (midpoints.Count < 4)
return MirrorAxisResult.None;
var centroid = new Vector( var centroid = new Vector(midpoints.Average(p => p.X), midpoints.Average(p => p.Y));
midpoints.Average(p => p.X),
midpoints.Average(p => p.Y));
var cx = centroid.X; var cx = centroid.X;
var cy = centroid.Y; var cy = centroid.Y;
@@ -190,8 +191,7 @@ public class GeometrySimplifier
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None; return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
} }
private static double NormalizeAngle(double angle) => private static double NormalizeAngle(double angle) => angle < 0 ? angle + Angle.TwoPI : angle;
angle < 0 ? angle + Angle.TwoPI : angle;
private static Vector Normalize(Vector v) private static Vector Normalize(Vector v)
{ {
@@ -231,7 +231,8 @@ public class GeometrySimplifier
for (var j = 0; j < points.Count; j++) for (var j = 0; j < points.Count; j++)
{ {
if (i == j) continue; if (i == j)
continue;
var d = reflected.DistanceTo(points[j]); var d = reflected.DistanceTo(points[j]);
if (d < matchTol) if (d < matchTol)
{ {
@@ -251,17 +252,20 @@ public class GeometrySimplifier
/// </summary> /// </summary>
public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis) public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis)
{ {
if (!axis.IsValid || candidates.Count < 2) return; if (!axis.IsValid || candidates.Count < 2)
return;
var paired = new HashSet<int>(); var paired = new HashSet<int>();
for (var i = 0; i < candidates.Count; i++) for (var i = 0; i < candidates.Count; i++)
{ {
if (paired.Contains(i)) continue; if (paired.Contains(i))
continue;
var ci = candidates[i]; var ci = candidates[i];
var ciCenter = ci.BoundingBox.Center; var ciCenter = ci.BoundingBox.Center;
if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 0.1) continue; // on the axis if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 0.1)
continue; // on the axis
var mirrorCenter = axis.Reflect(ciCenter); var mirrorCenter = axis.Reflect(ciCenter);
@@ -269,7 +273,8 @@ public class GeometrySimplifier
var bestDist = double.MaxValue; var bestDist = double.MaxValue;
for (var j = i + 1; j < candidates.Count; j++) for (var j = i + 1; j < candidates.Count; j++)
{ {
if (paired.Contains(j)) continue; if (paired.Contains(j))
continue;
var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center); var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center);
if (d < bestDist) if (d < bestDist)
{ {
@@ -279,7 +284,8 @@ public class GeometrySimplifier
} }
var matchTol = System.Math.Max(ci.BoundingBox.Width, ci.BoundingBox.Length) * 0.5; var matchTol = System.Math.Max(ci.BoundingBox.Width, ci.BoundingBox.Length) * 0.5;
if (bestJ < 0 || bestDist > matchTol) continue; if (bestJ < 0 || bestDist > matchTol)
continue;
paired.Add(i); paired.Add(i);
paired.Add(bestJ); paired.Add(bestJ);
@@ -287,7 +293,10 @@ public class GeometrySimplifier
var cj = candidates[bestJ]; var cj = candidates[bestJ];
var sourceIdx = i; var sourceIdx = i;
var targetIdx = bestJ; var targetIdx = bestJ;
if (cj.LineCount > ci.LineCount || (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation)) if (
cj.LineCount > ci.LineCount
|| (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation)
)
{ {
sourceIdx = bestJ; sourceIdx = bestJ;
targetIdx = i; targetIdx = i;
@@ -323,8 +332,12 @@ public class GeometrySimplifier
var mirrorEp = axis.Reflect(ep); var mirrorEp = axis.Reflect(ep);
// Mirroring reverses winding — swap start/end to preserve arc direction // Mirroring reverses winding — swap start/end to preserve arc direction
var mirrorStart = NormalizeAngle(System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X)); var mirrorStart = NormalizeAngle(
var mirrorEnd = NormalizeAngle(System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X)); System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X)
);
var mirrorEnd = NormalizeAngle(
System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X)
);
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed); var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
result.Layer = arc.Layer; result.Layer = arc.Layer;
@@ -332,7 +345,12 @@ public class GeometrySimplifier
return result; return result;
} }
private void FindCandidatesInRun(List<Entity> entities, int runStart, int runEnd, List<ArcCandidate> candidates) private void FindCandidatesInRun(
List<Entity> entities,
int runStart,
int runEnd,
List<ArcCandidate> candidates
)
{ {
var j = runStart; var j = runStart;
var chainedTangent = Vector.Invalid; var chainedTangent = Vector.Invalid;
@@ -349,46 +367,63 @@ public class GeometrySimplifier
chainedTangent = ComputeEndTangent(result.Center, result.Points); chainedTangent = ComputeEndTangent(result.Center, result.Points);
var arc = CreateArc(result.Center, result.Radius, result.Points, entities[j]); var arc = CreateArc(result.Center, result.Radius, result.Points, entities[j]);
candidates.Add(new ArcCandidate candidates.Add(
{ new ArcCandidate
StartIndex = j, {
EndIndex = result.EndIndex, StartIndex = j,
FittedArc = arc, EndIndex = result.EndIndex,
MaxDeviation = result.Deviation, FittedArc = arc,
BoundingBox = result.Points.GetBoundingBox(), MaxDeviation = result.Deviation,
FirstPoint = arc.StartPoint(), BoundingBox = result.Points.GetBoundingBox(),
LastPoint = arc.EndPoint(), FirstPoint = arc.StartPoint(),
}); LastPoint = arc.EndPoint(),
}
);
j = result.EndIndex + 1; j = result.EndIndex + 1;
} }
} }
private record ArcFitResult(Vector Center, double Radius, double Deviation, List<Vector> Points, int EndIndex); private record ArcFitResult(
Vector Center,
double Radius,
double Deviation,
List<Vector> Points,
int EndIndex
);
private ArcFitResult TryFitArcAt(List<Entity> entities, int start, int runEnd, Vector chainedTangent) private ArcFitResult TryFitArcAt(
List<Entity> entities,
int start,
int runEnd,
Vector chainedTangent
)
{ {
var k = start + MinLines - 1; var k = start + MinLines - 1;
if (k > runEnd) return null; if (k > runEnd)
return null;
var points = CollectPoints(entities, start, k); var points = CollectPoints(entities, start, k);
if (points.Count < 3) return null; if (points.Count < 3)
return null;
var startTangent = chainedTangent.IsValid() var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
? chainedTangent var endTangent = EstimateEndTangent(entities, k, points);
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var endTangent = GetExitDirection(entities[k]);
var (center, radius, dev) = TryFit(points, startTangent, endTangent); var (center, radius, dev) = TryFit(points, startTangent, endTangent);
if (!center.IsValid()) return null; if (!center.IsValid())
return null;
// Extend the arc as far as possible // Extend the arc as far as possible
while (k + 1 <= runEnd) while (k + 1 <= runEnd)
{ {
var extPoints = CollectPoints(entities, start, k + 1); var extPoints = CollectPoints(entities, start, k + 1);
var extEndTangent = GetExitDirection(entities[k + 1]); if (extPoints.Count < 3)
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d); break;
if (!nc.IsValid()) break;
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
if (!nc.IsValid())
break;
k++; k++;
center = nc; center = nc;
@@ -407,37 +442,228 @@ public class GeometrySimplifier
return new ArcFitResult(center, radius, dev, points, k); return new ArcFitResult(center, radius, dev, points, k);
} }
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent) private (Vector center, double radius, double deviation) TryFit(
List<Vector> points,
TangentEstimate start,
TangentEstimate end
)
{ {
// Try dual-tangent fit first (matches direction at both endpoints) foreach (var (center, radius, dev) in FitAttempts(points, start, end))
if (endTangent.IsValid())
{ {
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent); if (!center.IsValid() || dev > Tolerance)
if (dc.IsValid() && dd <= Tolerance) continue;
// Check that the arc doesn't bulge away from the original line segments
var isReversed = SumSignedAngles(center, points) < 0;
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
if (arcDev > Tolerance)
continue;
return (center, radius, System.Math.Max(dev, arcDev));
}
return (Vector.Invalid, 0, 0);
}
/// <summary>
/// Yields fit attempts in preference order. A trusted tangent (chained from the
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
/// exactly on its side; otherwise the tangency error is balanced between both
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
/// passes exactly through both endpoints, so no gaps are introduced.
/// </summary>
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
List<Vector> points,
TangentEstimate start,
TangentEstimate end
)
{
if (start.Trusted && !end.Trusted)
{
yield return ArcFit.FitWithStartTangent(points, start.Direction);
yield return ArcFit.FitThroughEndpointsWithTangents(
points,
start.Direction,
end.Direction
);
yield return FitWithEndTangent(points, end.Direction);
}
else if (end.Trusted && !start.Trusted)
{
yield return FitWithEndTangent(points, end.Direction);
yield return ArcFit.FitThroughEndpointsWithTangents(
points,
start.Direction,
end.Direction
);
yield return ArcFit.FitWithStartTangent(points, start.Direction);
}
else
{
yield return ArcFit.FitThroughEndpointsWithTangents(
points,
start.Direction,
end.Direction
);
yield return ArcFit.FitWithStartTangent(points, start.Direction);
yield return FitWithEndTangent(points, end.Direction);
}
yield return FitMirrorAxis(points);
}
/// <summary>
/// Fits an arc through both endpoints with an exact tangent at the last point,
/// by running the start-tangent fit on the reversed point sequence.
/// </summary>
private static (Vector center, double radius, double deviation) FitWithEndTangent(
List<Vector> points,
Vector endTangent
)
{
var reversed = new List<Vector>(points);
reversed.Reverse();
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
}
/// <summary>
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
/// vertices and only guide the fit.
/// </summary>
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
/// <summary>Segment-length ratio above which a neighboring line counts as a true
/// straight edge (rather than another chord of the tessellated curve).</summary>
private const double NeighborEdgeFactor = 3.0;
private static TangentEstimate EstimateStartTangent(
List<Entity> entities,
int start,
List<Vector> points,
Vector chainedTangent
)
{
if (chainedTangent.IsValid())
return new TangentEstimate(chainedTangent, true);
if (entities[start] is Arc startArc)
return new TangentEstimate(GetEntryDirection(startArc), true);
var firstChordLen = points[0].DistanceTo(points[1]);
if (start > 0)
{
var prev = entities[start - 1];
var prevEnd = prev switch
{ {
var isRev = SumSignedAngles(dc, points) < 0; Line l => l.EndPoint,
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev); Arc a => a.EndPoint(),
if (aDev <= Tolerance) _ => Vector.Invalid,
return (dc, dr, System.Math.Max(dd, aDev)); };
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
{
if (prev is Arc)
return new TangentEstimate(GetExitDirection(prev), true);
if (
prev is Line prevLine
&& prevLine.StartPoint.DistanceTo(prevLine.EndPoint)
>= NeighborEdgeFactor * firstChordLen
)
return new TangentEstimate(GetExitDirection(prevLine), true);
} }
} }
// Fall back to start-tangent-only, then mirror axis var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent); if (points.Count >= 3)
if (!center.IsValid() || dev > Tolerance) return new TangentEstimate(
(center, radius, dev) = FitMirrorAxis(points); EstimateVertexTangent(points[0], points[1], points[2], chord),
if (!center.IsValid() || dev > Tolerance) false
return (Vector.Invalid, 0, 0); );
return new TangentEstimate(chord, false);
// Check that the arc doesn't bulge away from the original line segments
var isReversed = SumSignedAngles(center, points) < 0;
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
if (arcDev > Tolerance)
return (Vector.Invalid, 0, 0);
return (center, radius, System.Math.Max(dev, arcDev));
} }
private static TangentEstimate EstimateEndTangent(
List<Entity> entities,
int k,
List<Vector> points
)
{
if (entities[k] is Arc endArc)
return new TangentEstimate(GetExitDirection(endArc), true);
var lastChordLen = points[^1].DistanceTo(points[^2]);
if (k + 1 < entities.Count)
{
var next = entities[k + 1];
var nextStart = next switch
{
Line l => l.StartPoint,
Arc a => a.StartPoint(),
_ => Vector.Invalid,
};
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
{
if (next is Arc nextArc)
return new TangentEstimate(GetEntryDirection(nextArc), true);
if (
next is Line nextLine
&& nextLine.StartPoint.DistanceTo(nextLine.EndPoint)
>= NeighborEdgeFactor * lastChordLen
)
return new TangentEstimate(GetExitDirection(nextLine), true);
}
}
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
if (points.Count >= 3)
return new TangentEstimate(
EstimateVertexTangent(points[^1], points[^2], points[^3], chord),
false
);
return new TangentEstimate(chord, false);
}
/// <summary>
/// Estimates the curve tangent at a polyline vertex from the circle through it and
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
/// Falls back to the travel direction when the three points are collinear.
/// </summary>
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
{
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
if (System.Math.Abs(d) < 1e-14)
return travel;
var sqA = at.X * at.X + at.Y * at.Y;
var sqB = b.X * b.X + b.Y * b.Y;
var sqC = c.X * c.X + c.Y * c.Y;
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
var tangent = new Vector(-(at.Y - cy), at.X - cx);
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
tangent = new Vector(-tangent.X, -tangent.Y);
return tangent;
}
/// <summary>
/// Returns the entry direction (tangent at start point) of an entity.
/// </summary>
private static Vector GetEntryDirection(Entity entity) =>
entity switch
{
Line line => new Vector(
line.EndPoint.X - line.StartPoint.X,
line.EndPoint.Y - line.StartPoint.Y
),
Arc arc => arc.IsReversed
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
_ => Vector.Invalid,
};
/// <summary> /// <summary>
/// Computes the tangent direction at the last point of a fitted arc, /// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc. /// used to chain tangent continuity to the next arc.
@@ -488,9 +714,17 @@ public class GeometrySimplifier
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta); var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord); var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
var dOpt = GoldenSectionMin(dInit - range, dInit + range, var dOpt = GoldenSectionMin(
d => ArcFit.MaxRadialDeviation(points, mx + d * nx, my + d * ny, dInit - range,
System.Math.Sqrt(halfChord * halfChord + d * d))); dInit + range,
d =>
ArcFit.MaxRadialDeviation(
points,
mx + d * nx,
my + d * ny,
System.Math.Sqrt(halfChord * halfChord + d * d)
)
);
var center = new Vector(mx + dOpt * nx, my + dOpt * ny); var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt); var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
@@ -542,13 +776,22 @@ public class GeometrySimplifier
return points; return points;
} }
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity) private static Arc CreateArc(
Vector center,
double radius,
List<Vector> points,
Entity sourceEntity
)
{ {
var firstPoint = points[0]; var firstPoint = points[0];
var lastPoint = points[^1]; var lastPoint = points[^1];
var startAngle = NormalizeAngle(System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X)); var startAngle = NormalizeAngle(
var endAngle = NormalizeAngle(System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X)); System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X)
);
var endAngle = NormalizeAngle(
System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X)
);
var isReversed = SumSignedAngles(center, points) < 0; var isReversed = SumSignedAngles(center, points) < 0;
var arc = new Arc(center, radius, startAngle, endAngle, isReversed); var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
@@ -560,14 +803,18 @@ public class GeometrySimplifier
/// <summary> /// <summary>
/// Returns the exit direction (tangent at endpoint) of an entity. /// Returns the exit direction (tangent at endpoint) of an entity.
/// </summary> /// </summary>
private static Vector GetExitDirection(Entity entity) => entity switch private static Vector GetExitDirection(Entity entity) =>
{ entity switch
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y), {
Arc arc => arc.IsReversed Line line => new Vector(
? new Vector(System.Math.Sin(arc.EndAngle), -System.Math.Cos(arc.EndAngle)) line.EndPoint.X - line.StartPoint.X,
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)), line.EndPoint.Y - line.StartPoint.Y
_ => Vector.Invalid, ),
}; Arc arc => arc.IsReversed
? new Vector(System.Math.Sin(arc.EndAngle), -System.Math.Cos(arc.EndAngle))
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)),
_ => Vector.Invalid,
};
/// <summary> /// <summary>
/// Sums signed angular change traversing consecutive points around a center. /// Sums signed angular change traversing consecutive points around a center.
@@ -581,8 +828,10 @@ public class GeometrySimplifier
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X); var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X); var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1; var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI; while (da > System.Math.PI)
while (da < -System.Math.PI) da += Angle.TwoPI; da -= Angle.TwoPI;
while (da < -System.Math.PI)
da += Angle.TwoPI;
total += da; total += da;
} }
return total; return total;
@@ -593,7 +842,12 @@ public class GeometrySimplifier
/// back to the original line segments. This catches cases where points lie /// back to the original line segments. This catches cases where points lie
/// on a large circle but the arc bulges far from the original straight geometry. /// on a large circle but the arc bulges far from the original straight geometry.
/// </summary> /// </summary>
private static double MaxArcToSegmentDeviation(List<Vector> points, Vector center, double radius, bool isReversed) private static double MaxArcToSegmentDeviation(
List<Vector> points,
Vector center,
double radius,
bool isReversed
)
{ {
var startAngle = System.Math.Atan2(points[0].Y - center.Y, points[0].X - center.X); var startAngle = System.Math.Atan2(points[0].Y - center.Y, points[0].X - center.X);
var endAngle = System.Math.Atan2(points[^1].Y - center.Y, points[^1].X - center.X); var endAngle = System.Math.Atan2(points[^1].Y - center.Y, points[^1].X - center.X);
@@ -601,11 +855,13 @@ public class GeometrySimplifier
var sweep = endAngle - startAngle; var sweep = endAngle - startAngle;
if (isReversed) if (isReversed)
{ {
if (sweep > 0) sweep -= Angle.TwoPI; if (sweep > 0)
sweep -= Angle.TwoPI;
} }
else else
{ {
if (sweep < 0) sweep += Angle.TwoPI; if (sweep < 0)
sweep += Angle.TwoPI;
} }
var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10)); var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10));
@@ -624,9 +880,11 @@ public class GeometrySimplifier
for (var j = 0; j < points.Count - 1; j++) for (var j = 0; j < points.Count - 1; j++)
{ {
var dist = DistanceToSegment(arcPt, points[j], points[j + 1]); var dist = DistanceToSegment(arcPt, points[j], points[j + 1]);
if (dist < minDist) minDist = dist; if (dist < minDist)
minDist = dist;
} }
if (minDist > maxDev) maxDev = minDist; if (minDist > maxDev)
maxDev = minDist;
} }
return maxDev; return maxDev;
} }
+1 -2
View File
@@ -1,5 +1,4 @@
 namespace OpenNest.Geometry
namespace OpenNest.Geometry
{ {
public interface IBoundable public interface IBoundable
{ {
-148
View File
@@ -1,148 +0,0 @@
using Clipper2Lib;
namespace OpenNest.Geometry
{
/// <summary>
/// Computes the Inner-Fit Polygon (IFP) — the feasible region where a part's
/// reference point can be placed so the part stays entirely within the plate boundary.
/// For a rectangular plate, the IFP is the plate shrunk by the part's bounding dimensions.
/// </summary>
public static class InnerFitPolygon
{
/// <summary>
/// Computes the IFP for placing a part polygon inside a rectangular work area.
/// The result is a polygon representing all valid reference point positions.
/// </summary>
public static Polygon Compute(Box workArea, Polygon partPolygon)
{
// Get the part's bounding box relative to its reference point (origin).
var verts = partPolygon.Vertices;
if (verts.Count < 3)
return new Polygon();
var minX = verts[0].X;
var maxX = verts[0].X;
var minY = verts[0].Y;
var maxY = verts[0].Y;
for (var i = 1; i < verts.Count; i++)
{
if (verts[i].X < minX) minX = verts[i].X;
if (verts[i].X > maxX) maxX = verts[i].X;
if (verts[i].Y < minY) minY = verts[i].Y;
if (verts[i].Y > maxY) maxY = verts[i].Y;
}
// The IFP is the work area shrunk inward by the part's extent in each direction.
// The reference point can range from (workArea.Left - minX) to (workArea.Right - maxX)
// and (workArea.Bottom - minY) to (workArea.Top - maxY).
var ifpLeft = workArea.X - minX;
var ifpRight = workArea.Right - maxX;
var ifpBottom = workArea.Y - minY;
var ifpTop = workArea.Top - maxY;
// If the part doesn't fit, return an empty polygon.
if (ifpRight < ifpLeft || ifpTop < ifpBottom)
return new Polygon();
var result = new Polygon();
result.Vertices.Add(new Vector(ifpLeft, ifpBottom));
result.Vertices.Add(new Vector(ifpRight, ifpBottom));
result.Vertices.Add(new Vector(ifpRight, ifpTop));
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
result.Close();
result.UpdateBounds();
return result;
}
/// <summary>
/// Computes the feasible region for placing a part given already-placed parts.
/// FeasibleRegion = IFP(plate, part) - union(NFP(placed_i, part))
/// Returns the polygon representing valid placement positions, or an empty
/// polygon if no valid position exists.
/// </summary>
public static Polygon ComputeFeasibleRegion(Polygon ifp, PathsD nfpPaths)
{
if (ifp.Vertices.Count < 3)
return new Polygon();
if (nfpPaths == null || nfpPaths.Count == 0)
return ifp;
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
var ifpPaths = new PathsD { ifpPath };
// Subtract the NFPs from the IFP.
// Clipper2 handles the implicit union of the clip paths.
var feasible = Clipper.Difference(ifpPaths, nfpPaths, FillRule.NonZero);
if (feasible.Count == 0)
return new Polygon();
// Find the polygon with the bottom-left-most point.
// This ensures we pick the correct region for placement.
PathD bestPath = null;
var bestY = double.MaxValue;
var bestX = double.MaxValue;
foreach (var path in feasible)
{
foreach (var pt in path)
{
if (pt.y < bestY || (pt.y == bestY && pt.x < bestX))
{
bestY = pt.y;
bestX = pt.x;
bestPath = path;
}
}
}
return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon();
}
/// <summary>
/// Computes the feasible region for placing a part given already-placed parts.
/// (Legacy overload for backward compatibility).
/// </summary>
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
{
if (nfps == null || nfps.Length == 0)
return ifp;
var nfpPaths = new PathsD(nfps.Length);
foreach (var nfp in nfps)
{
if (nfp.Vertices.Count >= 3)
nfpPaths.Add(NoFitPolygon.ToClipperPath(nfp));
}
return ComputeFeasibleRegion(ifp, nfpPaths);
}
/// <summary>
/// Finds the bottom-left-most point on a polygon boundary.
/// "Bottom-left" means: minimize Y first, then minimize X.
/// Returns Vector.Invalid if the polygon has no vertices.
/// </summary>
public static Vector FindBottomLeftPoint(Polygon polygon)
{
if (polygon.Vertices.Count == 0)
return Vector.Invalid;
var best = polygon.Vertices[0];
for (var i = 1; i < polygon.Vertices.Count; i++)
{
var v = polygon.Vertices[i];
if (v.Y < best.Y || (v.Y == best.Y && v.X < best.X))
best = v;
}
return best;
}
}
}
+60 -31
View File
@@ -1,6 +1,6 @@
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -18,8 +18,19 @@ namespace OpenNest.Geometry
} }
pts = pts.Where(pt => pts = pts.Where(pt =>
Angle.IsBetweenRad(arc1.Center.AngleTo(pt), arc1.StartAngle, arc1.EndAngle, arc1.IsReversed) && Angle.IsBetweenRad(
Angle.IsBetweenRad(arc2.Center.AngleTo(pt), arc2.StartAngle, arc2.EndAngle, arc2.IsReversed)) arc1.Center.AngleTo(pt),
arc1.StartAngle,
arc1.EndAngle,
arc1.IsReversed
)
&& Angle.IsBetweenRad(
arc2.Center.AngleTo(pt),
arc2.StartAngle,
arc2.EndAngle,
arc2.IsReversed
)
)
.ToList(); .ToList();
return pts.Count > 0; return pts.Count > 0;
@@ -35,11 +46,15 @@ namespace OpenNest.Geometry
return false; return false;
} }
pts = pts.Where(pt => Angle.IsBetweenRad( pts = pts.Where(pt =>
arc.Center.AngleTo(pt), Angle.IsBetweenRad(
arc.StartAngle, arc.Center.AngleTo(pt),
arc.EndAngle, arc.StartAngle,
arc.IsReversed)).ToList(); arc.EndAngle,
arc.IsReversed
)
)
.ToList();
return pts.Count > 0; return pts.Count > 0;
} }
@@ -54,11 +69,15 @@ namespace OpenNest.Geometry
return false; return false;
} }
pts = pts.Where(pt => Angle.IsBetweenRad( pts = pts.Where(pt =>
arc.Center.AngleTo(pt), Angle.IsBetweenRad(
arc.StartAngle, arc.Center.AngleTo(pt),
arc.EndAngle, arc.StartAngle,
arc.IsReversed)).ToList(); arc.EndAngle,
arc.IsReversed
)
)
.ToList();
return pts.Count > 0; return pts.Count > 0;
} }
@@ -74,11 +93,15 @@ namespace OpenNest.Geometry
pts2.AddRange(pts3); pts2.AddRange(pts3);
} }
pts = pts2.Where(pt => Angle.IsBetweenRad( pts = pts2.Where(pt =>
arc.Center.AngleTo(pt), Angle.IsBetweenRad(
arc.StartAngle, arc.Center.AngleTo(pt),
arc.EndAngle, arc.StartAngle,
arc.IsReversed)).ToList(); arc.EndAngle,
arc.IsReversed
)
)
.ToList();
return pts.Count > 0; return pts.Count > 0;
} }
@@ -95,11 +118,15 @@ namespace OpenNest.Geometry
pts2.AddRange(pts3); pts2.AddRange(pts3);
} }
pts = pts2.Where(pt => Angle.IsBetweenRad( pts = pts2.Where(pt =>
arc.Center.AngleTo(pt), Angle.IsBetweenRad(
arc.StartAngle, arc.Center.AngleTo(pt),
arc.EndAngle, arc.StartAngle,
arc.IsReversed)).ToList(); arc.EndAngle,
arc.IsReversed
)
)
.ToList();
return pts.Count > 0; return pts.Count > 0;
} }
@@ -123,20 +150,22 @@ namespace OpenNest.Geometry
} }
var d = circle2.Center - circle1.Center; var d = circle2.Center - circle1.Center;
var a = (circle1.Radius * circle1.Radius - circle2.Radius * circle2.Radius + distance * distance) / (2.0 * distance); var a =
(
circle1.Radius * circle1.Radius
- circle2.Radius * circle2.Radius
+ distance * distance
) / (2.0 * distance);
var h = System.Math.Sqrt(circle1.Radius * circle1.Radius - a * a); var h = System.Math.Sqrt(circle1.Radius * circle1.Radius - a * a);
var pt = new Vector( var pt = new Vector(
circle1.Center.X + (a * d.X) / distance, circle1.Center.X + (a * d.X) / distance,
circle1.Center.Y + (a * d.Y) / distance); circle1.Center.Y + (a * d.Y) / distance
);
var i1 = new Vector( var i1 = new Vector(pt.X + (h * d.Y) / distance, pt.Y - (h * d.X) / distance);
pt.X + (h * d.Y) / distance,
pt.Y - (h * d.X) / distance);
var i2 = new Vector( var i2 = new Vector(pt.X - (h * d.Y) / distance, pt.Y + (h * d.X) / distance);
pt.X - (h * d.Y) / distance,
pt.Y + (h * d.X) / distance);
pts = i1 != i2 ? new List<Vector> { i1, i2 } : new List<Vector> { i1 }; pts = i1 != i2 ? new List<Vector> { i1, i2 } : new List<Vector> { i1 };
+1 -1
View File
@@ -7,7 +7,7 @@ namespace OpenNest.Geometry
public static readonly Layer Default = new Layer("0") public static readonly Layer Default = new Layer("0")
{ {
Color = Color.White, Color = Color.White,
IsVisible = true IsVisible = true,
}; };
public Layer(string name) public Layer(string name)
+14 -22
View File
@@ -1,6 +1,6 @@
using OpenNest.Math; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -9,14 +9,10 @@ namespace OpenNest.Geometry
internal Vector pt1; internal Vector pt1;
internal Vector pt2; internal Vector pt2;
public Line() public Line() { }
{
}
public Line(double x1, double y1, double x2, double y2) public Line(double x1, double y1, double x2, double y2)
: this(new Vector(x1, y1), new Vector(x2, y2)) : this(new Vector(x1, y1), new Vector(x2, y2)) { }
{
}
public Line(Vector startPoint, Vector endPoint) public Line(Vector startPoint, Vector endPoint)
{ {
@@ -83,9 +79,7 @@ namespace OpenNest.Geometry
return EndPoint; return EndPoint;
else else
{ {
return new Vector( return new Vector(StartPoint.X + param * diff2.X, StartPoint.Y + param * diff2.Y);
StartPoint.X + param * diff2.X,
StartPoint.Y + param * diff2.Y);
} }
} }
@@ -372,7 +366,7 @@ namespace OpenNest.Geometry
/// <summary> /// <summary>
/// Updates the bounding box. /// Updates the bounding box.
/// </summary> /// </summary>
public override sealed void UpdateBounds() public sealed override void UpdateBounds()
{ {
if (StartPoint.X < EndPoint.X) if (StartPoint.X < EndPoint.X)
{ {
@@ -404,11 +398,9 @@ namespace OpenNest.Geometry
var x = System.Math.Cos(angle) * distance; var x = System.Math.Cos(angle) * distance;
var y = System.Math.Sin(angle) * distance; var y = System.Math.Sin(angle) * distance;
var pt = new Vector(x, y); var pt = side == OffsetSide.Left ? new Vector(x, y) : new Vector(-x, -y);
return side == OffsetSide.Left return new Line(StartPoint + pt, EndPoint + pt);
? new Line(StartPoint + pt, EndPoint + pt)
: new Line(EndPoint + pt, StartPoint + pt);
} }
public override Entity OffsetEntity(double distance, Vector pt) public override Entity OffsetEntity(double distance, Vector pt)
@@ -429,13 +421,13 @@ namespace OpenNest.Geometry
/// <returns>A tuple of (first, second) sub-lines.</returns> /// <returns>A tuple of (first, second) sub-lines.</returns>
public (Line first, Line second) SplitAt(Vector point) public (Line first, Line second) SplitAt(Vector point)
{ {
var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon var first =
? null point.DistanceTo(StartPoint) < Tolerance.Epsilon
: new Line(StartPoint, point); ? null
: new Line(StartPoint, point);
var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon var second =
? null point.DistanceTo(EndPoint) < Tolerance.Epsilon ? null : new Line(point, EndPoint);
: new Line(point, EndPoint);
return (first, second); return (first, second);
} }
+76 -132
View File
@@ -1,6 +1,6 @@
using Clipper2Lib; using Clipper2Lib;
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -11,21 +11,71 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
public static class NoFitPolygon public static class NoFitPolygon
{ {
private const double ClipperScale = 1000.0;
/// <summary> /// <summary>
/// Computes the NFP between a stationary polygon A and an orbiting polygon B. /// Computes forbidden translations of moving around stationary. Interior means
/// NFP(A, B) = Minkowski sum of A and -B (B reflected through its reference point). /// overlap and boundary means touch, subject to Clipper rounding at precision.
/// Inputs are simple filled perimeters, with either winding and optional closing
/// vertices. Cutouts are not supported: use Collision for hole-aware decisions.
/// The moving reference point is the origin, not its first vertex. Cache this
/// CPU preparation result. Rings with fewer than three vertices produce no region.
/// </summary> /// </summary>
public static Polygon Compute(Polygon stationary, Polygon orbiting) public static PathsD Compute(PathD stationary, PathD moving, int precision = ClipperBridge.Precision)
{ {
var reflected = Reflect(orbiting); var a = Normalize(stationary);
return MinkowskiSum(stationary, reflected); var b = Normalize(moving);
if (a.Count < 3 || b.Count < 3)
return new PathsD();
if (IsConvex(a) && IsConvex(b))
return new PathsD { ClipperBridge.ToPath(ComputeConvex(
ClipperBridge.ToPolygon(a), ClipperBridge.ToPolygon(b)), true) };
var negB = new PathD(b.Count);
foreach (var point in b)
negB.Add(new PointD(-point.x, -point.y));
// The boundary sweep alone misses both kinds of containment.
var sweep = Minkowski.Sum(negB, a, true, precision);
sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y));
sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y));
return Clipper.Union(sweep, new PathsD(), FillRule.NonZero, precision);
} }
/// <summary> /// <summary>
/// Optimized version of Compute for polygons known to be convex. /// Computes forbidden origin translations for two filled, lines-only perimeters.
/// Bypasses expensive triangulation and Clipper unions. /// Cutouts are not supported; use Collision for hole-aware decisions.
/// </summary>
public static PathsD Compute(Polygon stationary, Polygon moving) =>
Compute(ClipperBridge.ToPath(stationary, true), ClipperBridge.ToPath(moving, true));
private static PathD Normalize(PathD source)
{
var path = new PathD();
foreach (var point in source)
if (path.Count == 0 || path[path.Count - 1].x != point.x || path[path.Count - 1].y != point.y)
path.Add(point);
if (path.Count > 1 && path[0].x == path[path.Count - 1].x && path[0].y == path[path.Count - 1].y)
path.RemoveAt(path.Count - 1);
if (!Clipper.IsPositive(path))
path.Reverse();
return path;
}
private static bool IsConvex(PathD path)
{
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
var c = path[(i + 2) % path.Count];
if ((b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x) < 0)
return false;
}
return true;
}
/// <summary>
/// Computes the NFP between a convex stationary polygon A and a convex orbiting
/// polygon B: the Minkowski sum of A and -B (B reflected through its reference point).
/// </summary> /// </summary>
public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting) public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
{ {
@@ -48,42 +98,6 @@ namespace OpenNest.Geometry
return result; return result;
} }
/// <summary>
/// Computes the Minkowski sum of two polygons using convex decomposition.
/// For convex polygons, uses the direct O(n+m) merge-sort of edge vectors.
/// For concave polygons, decomposes into triangles, computes pairwise
/// convex Minkowski sums, and unions the results with Clipper2.
/// </summary>
private static Polygon MinkowskiSum(Polygon a, Polygon b)
{
var trisA = ConvexDecomposition.Triangulate(a);
var trisB = ConvexDecomposition.Triangulate(b);
if (trisA.Count == 0 || trisB.Count == 0)
return new Polygon();
var partialSums = new List<Polygon>();
foreach (var ta in trisA)
{
foreach (var tb in trisB)
{
var sum = ConvexMinkowskiSum(ta, tb);
if (sum.Vertices.Count >= 3)
partialSums.Add(sum);
}
}
if (partialSums.Count == 0)
return new Polygon();
if (partialSums.Count == 1)
return partialSums[0];
return UnionPolygons(partialSums);
}
/// <summary> /// <summary>
/// Computes the Minkowski sum of two convex polygons by merging their /// Computes the Minkowski sum of two convex polygons by merging their
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts. /// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
@@ -99,14 +113,15 @@ namespace OpenNest.Geometry
var startB = FindBottomLeft(b); var startB = FindBottomLeft(b);
var result = new Polygon(); var result = new Polygon();
// The starting point of the Minkowski sum A + B is the sum of the // The starting point of the Minkowski sum A + B is the sum of the
// starting points of A and B. For NFP = A + (-B), this is // starting points of A and B. For NFP = A + (-B), this is
// startA + startReflectedB. // startA + startReflectedB.
var current = new Vector( var current = new Vector(
a.Vertices[startA].X + b.Vertices[startB].X, a.Vertices[startA].X + b.Vertices[startB].X,
a.Vertices[startA].Y + b.Vertices[startB].Y); a.Vertices[startA].Y + b.Vertices[startB].Y
);
result.Vertices.Add(current); result.Vertices.Add(current);
var ia = 0; var ia = 0;
@@ -132,10 +147,12 @@ namespace OpenNest.Geometry
else else
{ {
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X); var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
if (angleA < 0) angleA += Angle.TwoPI; if (angleA < 0)
angleA += Angle.TwoPI;
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X); var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
if (angleB < 0) angleB += Angle.TwoPI; if (angleB < 0)
angleB += Angle.TwoPI;
if (angleA < angleB) if (angleA < angleB)
{ {
@@ -149,7 +166,8 @@ namespace OpenNest.Geometry
{ {
edge = new Vector( edge = new Vector(
orderedA[ia].X + orderedB[ib].X, orderedA[ia].X + orderedB[ib].X,
orderedA[ia].Y + orderedB[ib].Y); orderedA[ia].Y + orderedB[ib].Y
);
ia++; ia++;
ib++; ib++;
} }
@@ -203,8 +221,10 @@ namespace OpenNest.Geometry
for (var i = 1; i < n; i++) for (var i = 1; i < n; i++)
{ {
if (verts[i].Y < verts[best].Y || if (
(verts[i].Y == verts[best].Y && verts[i].X < verts[best].X)) verts[i].Y < verts[best].Y
|| (verts[i].Y == verts[best].Y && verts[i].X < verts[best].X)
)
best = i; best = i;
} }
@@ -224,81 +244,5 @@ namespace OpenNest.Geometry
return result; return result;
} }
/// <summary>
/// Unions multiple polygons using Clipper2.
/// Returns the outer boundary of the union as a single polygon.
/// </summary>
internal static Polygon UnionPolygons(List<Polygon> polygons)
{
var paths = new PathsD();
foreach (var poly in polygons)
{
var path = ToClipperPath(poly);
if (path.Count >= 3)
paths.Add(path);
}
if (paths.Count == 0)
return new Polygon();
var result = Clipper.Union(paths, FillRule.NonZero);
if (result.Count == 0)
return new Polygon();
// Find the largest polygon (by area) as the outer boundary.
var largest = result[0];
var largestArea = System.Math.Abs(Clipper.Area(largest));
for (var i = 1; i < result.Count; i++)
{
var area = System.Math.Abs(Clipper.Area(result[i]));
if (area > largestArea)
{
largest = result[i];
largestArea = area;
}
}
return FromClipperPath(largest);
}
/// <summary>
/// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset.
/// </summary>
public static PathD ToClipperPath(Polygon polygon, Vector offset = default)
{
var path = new PathD();
var verts = polygon.Vertices;
var n = verts.Count;
// Skip closing vertex if present.
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
n--;
for (var i = 0; i < n; i++)
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
return path;
}
/// <summary>
/// Converts a Clipper2 PathD to an OpenNest Polygon.
/// </summary>
public static Polygon FromClipperPath(PathD path)
{
var polygon = new Polygon();
foreach (var pt in path)
polygon.Vertices.Add(new Vector(pt.x, pt.y));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
} }
} }
+22 -11
View File
@@ -4,12 +4,14 @@ namespace OpenNest.Geometry
{ {
public static class PolyLabel public static class PolyLabel
{ {
public static Vector Find(Polygon outer, IList<Polygon> holes = null, double precision = 0.5) public static Vector Find(
Polygon outer,
IList<Polygon> holes = null,
double precision = 0.5
)
{ {
if (outer.Vertices.Count < 3) if (outer.Vertices.Count < 3)
return outer.Vertices.Count > 0 return outer.Vertices.Count > 0 ? outer.Vertices[0] : new Vector();
? outer.Vertices[0]
: new Vector();
var minX = double.MaxValue; var minX = double.MaxValue;
var minY = double.MaxValue; var minY = double.MaxValue;
@@ -19,10 +21,14 @@ namespace OpenNest.Geometry
for (var i = 0; i < outer.Vertices.Count; i++) for (var i = 0; i < outer.Vertices.Count; i++)
{ {
var v = outer.Vertices[i]; var v = outer.Vertices[i];
if (v.X < minX) minX = v.X; if (v.X < minX)
if (v.Y < minY) minY = v.Y; minX = v.X;
if (v.X > maxX) maxX = v.X; if (v.Y < minY)
if (v.Y > maxY) maxY = v.Y; minY = v.Y;
if (v.X > maxX)
maxX = v.X;
if (v.Y > maxY)
maxY = v.Y;
} }
var width = maxX - minX; var width = maxX - minX;
@@ -37,8 +43,8 @@ namespace OpenNest.Geometry
var queue = new List<Cell>(); var queue = new List<Cell>();
for (var x = minX; x < maxX; x += cellSize) for (var x = minX; x < maxX; x += cellSize)
for (var y = minY; y < maxY; y += cellSize) for (var y = minY; y < maxY; y += cellSize)
queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes)); queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist)); queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
@@ -194,7 +200,12 @@ namespace OpenNest.Geometry
} }
} }
private static double PointToAllEdgesDist(double x, double y, Polygon outer, IList<Polygon> holes) private static double PointToAllEdgesDist(
double x,
double y,
Polygon outer,
IList<Polygon> holes
)
{ {
var minDist = PointToPolygonDist(x, y, outer); var minDist = PointToPolygonDist(x, y, outer);
+33 -176
View File
@@ -1,7 +1,7 @@
using OpenNest.Math; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -107,7 +107,9 @@ namespace OpenNest.Geometry
public RotationType RotationDirection() public RotationType RotationDirection()
{ {
if (Vertices.Count < 3) if (Vertices.Count < 3)
throw new Exception("Not enough points to determine direction. Must have at least 3 points."); throw new Exception(
"Not enough points to determine direction. Must have at least 3 points."
);
return CalculateArea() > 0 ? RotationType.CCW : RotationType.CW; return CalculateArea() > 0 ? RotationType.CCW : RotationType.CW;
} }
@@ -309,11 +311,15 @@ namespace OpenNest.Geometry
{ {
var vertex = Vertices[i]; var vertex = Vertices[i];
if (vertex.X < minX) minX = vertex.X; if (vertex.X < minX)
else if (vertex.X > maxX) maxX = vertex.X; minX = vertex.X;
else if (vertex.X > maxX)
maxX = vertex.X;
if (vertex.Y < minY) minY = vertex.Y; if (vertex.Y < minY)
else if (vertex.Y > maxY) maxY = vertex.Y; minY = vertex.Y;
else if (vertex.Y > maxY)
maxY = vertex.Y;
} }
boundingBox.X = minX; boundingBox.X = minX;
@@ -322,55 +328,29 @@ namespace OpenNest.Geometry
boundingBox.Width = maxY - minY; boundingBox.Width = maxY - minY;
} }
/// <summary>
/// Miter-offsets the closed polygon to the given side, keeping its winding.
/// Corners sharper than the miter limit are squared off, and features that
/// collapse under the offset are dropped. When the offset splits the polygon,
/// the largest piece is returned.
/// </summary>
public override Entity OffsetEntity(double distance, OffsetSide side) public override Entity OffsetEntity(double distance, OffsetSide side)
{ {
if (Vertices.Count < 3) if (Vertices.Count < 3)
return null; return null;
var isClosed = IsClosed();
var count = isClosed ? Vertices.Count - 1 : Vertices.Count;
if (count < 3)
return null;
var ccw = CalculateArea() > 0; var ccw = CalculateArea() > 0;
var outward = ccw ? OffsetSide.Left : OffsetSide.Right; var outward = ccw ? OffsetSide.Left : OffsetSide.Right;
var sign = side == outward ? 1.0 : -1.0; var delta = side == outward ? distance : -distance;
var d = distance * sign;
var normals = new Vector[count]; var result = ClipperBridge.OffsetMiter(this, delta);
for (var i = 0; i < count; i++)
{
var next = (i + 1) % count;
var dx = Vertices[next].X - Vertices[i].X;
var dy = Vertices[next].Y - Vertices[i].Y;
var len = System.Math.Sqrt(dx * dx + dy * dy);
if (len < Tolerance.Epsilon)
return null;
normals[i] = new Vector(-dy / len * d, dx / len * d);
}
var result = new Polygon(); if (result == null)
for (var i = 0; i < count; i++) return null;
{
var prev = (i - 1 + count) % count;
var a1 = new Vector(Vertices[prev].X + normals[prev].X, Vertices[prev].Y + normals[prev].Y); if (!ccw)
var a2 = new Vector(Vertices[i].X + normals[prev].X, Vertices[i].Y + normals[prev].Y); result.Reverse();
var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y);
var b2 = new Vector(Vertices[(i + 1) % count].X + normals[i].X, Vertices[(i + 1) % count].Y + normals[i].Y);
var edgeA = new Line(a1, a2);
var edgeB = new Line(b1, b2);
if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid())
result.Vertices.Add(pt);
else
result.Vertices.Add(new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y));
}
result.Close();
result.RemoveSelfIntersections();
result.UpdateBounds();
return result; return result;
} }
@@ -379,8 +359,10 @@ namespace OpenNest.Geometry
var left = OffsetEntity(distance, OffsetSide.Left); var left = OffsetEntity(distance, OffsetSide.Left);
var right = OffsetEntity(distance, OffsetSide.Right); var right = OffsetEntity(distance, OffsetSide.Right);
if (left == null) return right; if (left == null)
if (right == null) return left; return right;
if (right == null)
return left;
var distLeft = left.ClosestPointTo(pt).DistanceTo(pt); var distLeft = left.ClosestPointTo(pt).DistanceTo(pt);
var distRight = right.ClosestPointTo(pt).DistanceTo(pt); var distRight = right.ClosestPointTo(pt).DistanceTo(pt);
@@ -537,133 +519,6 @@ namespace OpenNest.Geometry
get { return EntityType.Polygon; } get { return EntityType.Polygon; }
} }
/// <summary>
/// Removes self-intersecting loops from the polygon by finding non-adjacent
/// edge crossings and keeping the larger contour at each crossing.
/// </summary>
public void RemoveSelfIntersections()
{
if (!IsClosed() || Vertices.Count < 5)
return;
while (FindCrossing(out var edgeI, out var edgeJ, out var pt))
{
Vertices = SplitAtCrossing(edgeI, edgeJ, pt);
}
}
private bool FindCrossing(out int edgeI, out int edgeJ, out Vector pt)
{
var n = Vertices.Count - 1;
// Pre-calculate edge bounding boxes to speed up intersection checks.
var edgeBounds = new (double minX, double maxX, double minY, double maxY)[n];
for (var i = 0; i < n; i++)
{
var v1 = Vertices[i];
var v2 = Vertices[i + 1];
edgeBounds[i] = (
System.Math.Min(v1.X, v2.X) - Tolerance.Epsilon,
System.Math.Max(v1.X, v2.X) + Tolerance.Epsilon,
System.Math.Min(v1.Y, v2.Y) - Tolerance.Epsilon,
System.Math.Max(v1.Y, v2.Y) + Tolerance.Epsilon
);
}
for (var i = 0; i < n; i++)
{
var bi = edgeBounds[i];
for (var j = i + 2; j < n; j++)
{
if (i == 0 && j == n - 1)
continue;
var bj = edgeBounds[j];
// Prune with bounding box check.
if (bi.maxX < bj.minX || bj.maxX < bi.minX ||
bi.maxY < bj.minY || bj.maxY < bi.minY)
{
continue;
}
if (SegmentsIntersect(Vertices[i], Vertices[i + 1], Vertices[j], Vertices[j + 1], out pt))
{
edgeI = i;
edgeJ = j;
return true;
}
}
}
edgeI = edgeJ = -1;
pt = Vector.Zero;
return false;
}
private List<Vector> SplitAtCrossing(int edgeI, int edgeJ, Vector pt)
{
var n = Vertices.Count - 1;
var loopA = Vertices.GetRange(0, edgeI + 1);
loopA.Add(pt);
loopA.AddRange(Vertices.GetRange(edgeJ + 1, n - edgeJ - 1));
loopA.Add(loopA[0]);
var loopB = new List<Vector> { pt };
loopB.AddRange(Vertices.GetRange(edgeI + 1, edgeJ - edgeI));
loopB.Add(pt);
var areaA = System.Math.Abs(CalculateArea(loopA));
var areaB = System.Math.Abs(CalculateArea(loopB));
return areaA >= areaB ? loopA : loopB;
}
private static bool SegmentsIntersect(Vector a1, Vector a2, Vector b1, Vector b2, out Vector pt)
{
var da = a2 - a1;
var db = b2 - b1;
var cross = da.X * db.Y - da.Y * db.X;
if (cross.IsEqualTo(0.0))
{
pt = Vector.Zero;
return false;
}
var dc = b1 - a1;
var t = (dc.X * db.Y - dc.Y * db.X) / cross;
var u = (dc.X * da.Y - dc.Y * da.X) / cross;
if (t > Tolerance.Epsilon && t < 1.0 - Tolerance.Epsilon &&
u > Tolerance.Epsilon && u < 1.0 - Tolerance.Epsilon)
{
pt = new Vector(a1.X + t * da.X, a1.Y + t * da.Y);
return true;
}
pt = Vector.Zero;
return false;
}
private static double CalculateArea(List<Vector> vertices)
{
double xsum = 0;
double ysum = 0;
for (int i = 0; i < vertices.Count - 1; i++)
{
var current = vertices[i];
var next = vertices[i + 1];
xsum += current.X * next.Y;
ysum += current.Y * next.X;
}
return (xsum - ysum) * 0.5;
}
internal void Cleanup() internal void Cleanup()
{ {
for (int i = Vertices.Count - 1; i > 0; i--) for (int i = Vertices.Count - 1; i > 0; i--)
@@ -701,8 +556,10 @@ namespace OpenNest.Geometry
var vi = Vertices[i]; var vi = Vertices[i];
var vj = Vertices[j]; var vj = Vertices[j];
if ((vi.Y > pt.Y) != (vj.Y > pt.Y) && if (
pt.X < (vj.X - vi.X) * (pt.Y - vi.Y) / (vj.Y - vi.Y) + vi.X) (vi.Y > pt.Y) != (vj.Y > pt.Y)
&& pt.X < (vj.X - vi.X) * (pt.Y - vi.Y) / (vj.Y - vi.Y) + vi.X
)
{ {
inside = !inside; inside = !inside;
} }
+35 -15
View File
@@ -1,5 +1,5 @@
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -71,18 +71,24 @@ namespace OpenNest.Geometry
var vy = ux; var vy = ux;
// Project all hull vertices onto edge direction (u) and perpendicular (v) // Project all hull vertices onto edge direction (u) and perpendicular (v)
double minU = double.MaxValue, maxU = double.MinValue; double minU = double.MaxValue,
double minV = double.MaxValue, maxV = double.MinValue; maxU = double.MinValue;
double minV = double.MaxValue,
maxV = double.MinValue;
for (int j = 0; j < n; j++) for (int j = 0; j < n; j++)
{ {
var projU = vertices[j].X * ux + vertices[j].Y * uy; var projU = vertices[j].X * ux + vertices[j].Y * uy;
var projV = vertices[j].X * vx + vertices[j].Y * vy; var projV = vertices[j].X * vx + vertices[j].Y * vy;
if (projU < minU) minU = projU; if (projU < minU)
if (projU > maxU) maxU = projU; minU = projU;
if (projV < minV) minV = projV; if (projU > maxU)
if (projV > maxV) maxV = projV; maxU = projU;
if (projV < minV)
minV = projV;
if (projV > maxV)
maxV = projV;
} }
var width = maxU - minU; var width = maxU - minU;
@@ -99,7 +105,11 @@ namespace OpenNest.Geometry
return best ?? new BoundingRectangleResult(0, 0, 0); return best ?? new BoundingRectangleResult(0, 0, 0);
} }
public static BoundingRectangleResult MinimumBoundingRectangle(Polygon hull, double startAngle, double endAngle) public static BoundingRectangleResult MinimumBoundingRectangle(
Polygon hull,
double startAngle,
double endAngle
)
{ {
var vertices = hull.Vertices; var vertices = hull.Vertices;
int n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count; int n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
@@ -153,23 +163,33 @@ namespace OpenNest.Geometry
return best ?? new BoundingRectangleResult(startAngle, 0, 0); return best ?? new BoundingRectangleResult(startAngle, 0, 0);
} }
private static BoundingRectangleResult EvaluateAtAngle(IList<Vector> vertices, int n, double angle) private static BoundingRectangleResult EvaluateAtAngle(
IList<Vector> vertices,
int n,
double angle
)
{ {
var cos = System.Math.Cos(angle); var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle); var sin = System.Math.Sin(angle);
double minU = double.MaxValue, maxU = double.MinValue; double minU = double.MaxValue,
double minV = double.MaxValue, maxV = double.MinValue; maxU = double.MinValue;
double minV = double.MaxValue,
maxV = double.MinValue;
for (int j = 0; j < n; j++) for (int j = 0; j < n; j++)
{ {
var projU = vertices[j].X * cos + vertices[j].Y * sin; var projU = vertices[j].X * cos + vertices[j].Y * sin;
var projV = -vertices[j].X * sin + vertices[j].Y * cos; var projV = -vertices[j].X * sin + vertices[j].Y * cos;
if (projU < minU) minU = projU; if (projU < minU)
if (projU > maxU) maxU = projU; minU = projU;
if (projV < minV) minV = projV; if (projU > maxU)
if (projV > maxV) maxV = projV; maxU = projU;
if (projV < minV)
minV = projV;
if (projV > maxV)
maxV = projV;
} }
var width = maxU - minU; var width = maxU - minU;
+250 -78
View File
@@ -282,11 +282,7 @@ namespace OpenNest.Geometry
case EntityType.Line: case EntityType.Line:
var line = (Line)entity; var line = (Line)entity;
polygon.Vertices.AddRange(new[] polygon.Vertices.AddRange(new[] { line.StartPoint, line.EndPoint });
{
line.StartPoint,
line.EndPoint
});
break; break;
case EntityType.Circle: case EntityType.Circle:
@@ -302,6 +298,7 @@ namespace OpenNest.Geometry
polygon.Close(); polygon.Close();
polygon.Cleanup(); polygon.Cleanup();
polygon.UpdateBounds();
return polygon; return polygon;
} }
@@ -320,21 +317,21 @@ namespace OpenNest.Geometry
{ {
case EntityType.Arc: case EntityType.Arc:
var arc = (Arc)entity; var arc = (Arc)entity;
polygon.Vertices.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance), circumscribe)); polygon.Vertices.AddRange(
arc.ToPoints(arc.SegmentsForTolerance(tolerance), circumscribe)
);
break; break;
case EntityType.Line: case EntityType.Line:
var line = (Line)entity; var line = (Line)entity;
polygon.Vertices.AddRange(new[] polygon.Vertices.AddRange(new[] { line.StartPoint, line.EndPoint });
{
line.StartPoint,
line.EndPoint
});
break; break;
case EntityType.Circle: case EntityType.Circle:
var circle = (Circle)entity; var circle = (Circle)entity;
polygon.Vertices.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance), circumscribe)); polygon.Vertices.AddRange(
circle.ToPoints(circle.SegmentsForTolerance(tolerance), circumscribe)
);
break; break;
default: default:
@@ -345,6 +342,7 @@ namespace OpenNest.Geometry
polygon.Close(); polygon.Close();
polygon.Cleanup(); polygon.Cleanup();
polygon.UpdateBounds();
return polygon; return polygon;
} }
@@ -462,84 +460,227 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
public override void UpdateBounds() public override void UpdateBounds()
{ {
boundingBox = Entities.Select(geo => geo.BoundingBox) boundingBox = Entities.Select(geo => geo.BoundingBox).ToList().GetBoundingBox();
.ToList()
.GetBoundingBox();
} }
/// <summary>
/// Offsets each perimeter entity to the given side and joins the pieces into a
/// closed chain: line-line corners get a round join (convex) or a miter (concave),
/// other convex corners get a round join, and any remaining gap (a concave corner
/// involving an arc, or an entity that collapsed under the offset) is bridged
/// with a line. Cutouts are offset the same way.
/// <para>
/// Where a feature is narrower than twice the distance, the result keeps zero-area
/// spikes and inverted loops. They lie inside the true offset envelope, so they are
/// harmless to directional-distance queries, which only need a closed boundary
/// that never falls inside the envelope. Use <see cref="ClipperBridge"/> when a
/// clean region is needed.
/// </para>
/// </summary>
public override Entity OffsetEntity(double distance, OffsetSide side) public override Entity OffsetEntity(double distance, OffsetSide side)
{ {
var offsetShape = new Shape(); var offsetShape = new Shape();
var definedShape = new ShapeProfile(this); var definedShape = new ShapeProfile(this);
Entity firstEntity = null; var pieces = new List<OffsetPiece>();
Entity firstOffsetEntity = null; var collapsed = false;
Entity lastEntity = null;
Entity lastOffsetEntity = null;
foreach (var entity in definedShape.Perimeter.Entities) foreach (var entity in definedShape.Perimeter.Entities)
{ {
var offsetEntity = entity.OffsetEntity(distance, side); var offsetEntity = entity.OffsetEntity(distance, side);
if (offsetEntity == null) if (offsetEntity == null)
{
collapsed = true;
continue; continue;
if (firstEntity == null)
{
firstEntity = entity;
firstOffsetEntity = offsetEntity;
} }
switch (entity.Type) pieces.Add(new OffsetPiece(entity, offsetEntity, collapsed));
{ collapsed = false;
case EntityType.Line:
{
var line = (Line)entity;
var offsetLine = (Line)offsetEntity;
if (lastOffsetEntity != null && lastOffsetEntity.Type == EntityType.Line)
{
JoinOffsetLines(
(Line)lastEntity, (Line)lastOffsetEntity,
line, offsetLine,
distance, side, offsetShape);
}
offsetShape.Entities.Add(offsetLine);
break;
}
default:
offsetShape.Entities.Add(offsetEntity);
break;
}
lastOffsetEntity = offsetEntity;
lastEntity = entity;
} }
// Close the shape: join last offset entity back to first // Entities that collapsed at the end of the loop sit before the first piece.
if (lastOffsetEntity != null && firstOffsetEntity != null if (collapsed && pieces.Count > 0)
&& lastOffsetEntity != firstOffsetEntity pieces[0] = pieces[0] with { CollapsedBefore = true };
&& lastOffsetEntity.Type == EntityType.Line
&& firstOffsetEntity.Type == EntityType.Line) for (var i = 0; i < pieces.Count; i++)
{ {
JoinOffsetLines( offsetShape.Entities.Add(pieces[i].Offset);
(Line)lastEntity, (Line)lastOffsetEntity,
(Line)firstEntity, (Line)firstOffsetEntity, if (pieces.Count > 1)
distance, side, offsetShape); {
JoinOffsetPieces(
pieces[i],
pieces[(i + 1) % pieces.Count],
distance,
side,
offsetShape
);
}
} }
foreach (var cutout in definedShape.Cutouts) foreach (var cutout in definedShape.Cutouts)
offsetShape.Entities.AddRange(((Shape)cutout.OffsetEntity(distance, side)).Entities); offsetShape.Entities.AddRange(
((Shape)cutout.OffsetEntity(distance, side)).Entities
);
return offsetShape; return offsetShape;
} }
private readonly record struct OffsetPiece(
Entity Source,
Entity Offset,
bool CollapsedBefore
);
private static void JoinOffsetPieces(
OffsetPiece last,
OffsetPiece next,
double distance,
OffsetSide side,
Shape offsetShape
)
{
// Lines meeting across a collapsed fillet are concave, so a miter trims both at
// their intersection. Parallel ones (a round-bottomed slot) fall through to
// the bridge below.
if (
next.CollapsedBefore
&& last.Offset is Line lastOffsetLine
&& next.Offset is Line nextOffsetLine
&& Intersect.IntersectsUnbounded(nextOffsetLine, lastOffsetLine, out var miter)
)
{
lastOffsetLine.EndPoint = miter;
nextOffsetLine.StartPoint = miter;
return;
}
if (!next.CollapsedBefore && last.Source is Line lastLine && next.Source is Line nextLine)
{
JoinOffsetLines(
lastLine,
(Line)last.Offset,
nextLine,
(Line)next.Offset,
distance,
side,
offsetShape
);
return;
}
if (
!TryGetEnds(last.Offset, out _, out var gapStart)
|| !TryGetEnds(next.Offset, out var gapEnd, out _)
)
return;
if (gapStart.DistanceTo(gapEnd) <= OpenNest.Math.Tolerance.Epsilon)
return;
if (
!next.CollapsedBefore
&& IsConvexCorner(last.Source, next.Source, side, out var corner)
)
{
offsetShape.Entities.Add(
new Arc(
corner,
distance,
corner.AngleTo(gapStart),
corner.AngleTo(gapEnd),
side == OffsetSide.Left
)
);
return;
}
// Concave corner or collapsed entity: the neighbors' offsets overlap, so a
// straight bridge stays inside the offset envelope and closes the chain.
offsetShape.Entities.Add(new Line(gapStart, gapEnd));
}
private static bool IsConvexCorner(
Entity last,
Entity next,
OffsetSide side,
out Vector corner
)
{
corner = default;
if (
!TryGetEnds(last, out _, out corner)
|| !TryGetTangents(last, out _, out var d1)
|| !TryGetTangents(next, out var d2, out _)
)
return false;
var cross = d1.X * d2.Y - d1.Y * d2.X;
return (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon)
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
}
private static bool TryGetEnds(Entity entity, out Vector start, out Vector end)
{
switch (entity)
{
case Line line:
start = line.StartPoint;
end = line.EndPoint;
return true;
case Arc arc:
start = arc.StartPoint();
end = arc.EndPoint();
return true;
default:
start = end = default;
return false;
}
}
/// <summary>
/// Direction of travel at the start and end of a line or arc.
/// </summary>
private static bool TryGetTangents(Entity entity, out Vector start, out Vector end)
{
switch (entity)
{
case Line line:
start = end = line.EndPoint - line.StartPoint;
return true;
case Arc arc:
start = ArcTangent(arc, arc.StartAngle);
end = ArcTangent(arc, arc.EndAngle);
return true;
default:
start = end = default;
return false;
}
}
private static Vector ArcTangent(Arc arc, double angle)
{
var sin = System.Math.Sin(angle);
var cos = System.Math.Cos(angle);
return arc.IsReversed ? new Vector(sin, -cos) : new Vector(-sin, cos);
}
private static void JoinOffsetLines( private static void JoinOffsetLines(
Line lastLine, Line lastOffsetLine, Line lastLine,
Line line, Line offsetLine, Line lastOffsetLine,
double distance, OffsetSide side, Shape offsetShape) Line line,
Line offsetLine,
double distance,
OffsetSide side,
Shape offsetShape
)
{ {
// Determine if this is a convex corner using the cross product of // Determine if this is a convex corner using the cross product of
// the original line directions. Convex corners need an arc; concave // the original line directions. Convex corners need an arc; concave
@@ -548,8 +689,9 @@ namespace OpenNest.Geometry
var d2 = line.EndPoint - line.StartPoint; var d2 = line.EndPoint - line.StartPoint;
var cross = d1.X * d2.Y - d1.Y * d2.X; var cross = d1.X * d2.Y - d1.Y * d2.X;
var isConvex = (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon) || var isConvex =
(side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon); (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon)
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
if (isConvex) if (isConvex)
{ {
@@ -559,11 +701,13 @@ namespace OpenNest.Geometry
line.StartPoint.AngleTo(lastOffsetLine.EndPoint), line.StartPoint.AngleTo(lastOffsetLine.EndPoint),
line.StartPoint.AngleTo(offsetLine.StartPoint), line.StartPoint.AngleTo(offsetLine.StartPoint),
side == OffsetSide.Left side == OffsetSide.Left
); );
offsetShape.Entities.Add(arc); offsetShape.Entities.Add(arc);
} }
else if (Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out var intersection)) else if (
Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out var intersection)
)
{ {
offsetLine.StartPoint = intersection; offsetLine.StartPoint = intersection;
lastOffsetLine.EndPoint = intersection; lastOffsetLine.EndPoint = intersection;
@@ -576,7 +720,7 @@ namespace OpenNest.Geometry
line.StartPoint.AngleTo(lastOffsetLine.EndPoint), line.StartPoint.AngleTo(lastOffsetLine.EndPoint),
line.StartPoint.AngleTo(offsetLine.StartPoint), line.StartPoint.AngleTo(offsetLine.StartPoint),
side == OffsetSide.Left side == OffsetSide.Left
); );
offsetShape.Entities.Add(arc); offsetShape.Entities.Add(arc);
} }
@@ -592,12 +736,15 @@ namespace OpenNest.Geometry
/// Normalizes to CW winding before offsetting Left (which is outward for CW), /// Normalizes to CW winding before offsetting Left (which is outward for CW),
/// making the method independent of the original contour winding direction. /// making the method independent of the original contour winding direction.
/// </summary> /// </summary>
public Shape OffsetOutward(double distance) internal Shape OffsetOutward(double distance)
{ {
var poly = ToPolygon(); var poly = ToPolygon();
if (poly == null || poly.Vertices.Count < 3 if (
|| poly.RotationDirection() == RotationType.CW) poly == null
|| poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CW
)
return OffsetEntity(distance, OffsetSide.Left) as Shape; return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Shape is CCW — reverse to CW so Left offset goes outward. // Shape is CCW — reverse to CW so Left offset goes outward.
@@ -611,10 +758,21 @@ namespace OpenNest.Geometry
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer }); copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break; break;
case Arc a: case Arc a:
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer }); copy.Entities.Add(
new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed)
{
Layer = a.Layer,
}
);
break; break;
case Circle c: case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW }); copy.Entities.Add(
new Circle(c.Center, c.Radius)
{
Layer = c.Layer,
Rotation = RotationType.CW,
}
);
break; break;
} }
} }
@@ -627,12 +785,15 @@ namespace OpenNest.Geometry
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW), /// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
/// making the method independent of the original contour winding direction. /// making the method independent of the original contour winding direction.
/// </summary> /// </summary>
public Shape OffsetInward(double distance) internal Shape OffsetInward(double distance)
{ {
var poly = ToPolygon(); var poly = ToPolygon();
if (poly == null || poly.Vertices.Count < 3 if (
|| poly.RotationDirection() == RotationType.CCW) poly == null
|| poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CCW
)
return OffsetEntity(distance, OffsetSide.Left) as Shape; return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Create a reversed copy to avoid mutating shared entity objects. // Create a reversed copy to avoid mutating shared entity objects.
@@ -646,10 +807,21 @@ namespace OpenNest.Geometry
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer }); copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break; break;
case Arc a: case Arc a:
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer }); copy.Entities.Add(
new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed)
{
Layer = a.Layer,
}
);
break; break;
case Circle c: case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW }); copy.Entities.Add(
new Circle(c.Center, c.Radius)
{
Layer = c.Layer,
Rotation = RotationType.CCW,
}
);
break; break;
} }
} }
+10 -3
View File
@@ -1,13 +1,16 @@
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
public static class ShapeBuilder public static class ShapeBuilder
{ {
public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null) public static List<Shape> GetShapes(
IEnumerable<Entity> entities,
double? weldTolerance = null
)
{ {
var lines = new List<Line>(); var lines = new List<Line>();
var arcs = new List<Arc>(); var arcs = new List<Arc>();
@@ -141,7 +144,11 @@ namespace OpenNest.Geometry
private static void AddToGroup( private static void AddToGroup(
List<List<(Entity entity, bool isStart, Vector point)>> groups, List<List<(Entity entity, bool isStart, Vector point)>> groups,
Entity entity, bool isStart, Vector point, double tolerance) Entity entity,
bool isStart,
Vector point,
double tolerance
)
{ {
foreach (var group in groups) foreach (var group in groups)
{ {
+1 -2
View File
@@ -84,8 +84,7 @@ namespace OpenNest.Geometry
{ {
var poly = shape.ToPolygon(); var poly = shape.ToPolygon();
if (poly != null && poly.Vertices.Count >= 3 if (poly != null && poly.Vertices.Count >= 3 && poly.RotationDirection() != desired)
&& poly.RotationDirection() != desired)
{ {
shape.Reverse(); shape.Reverse();
} }
+2 -1
View File
@@ -44,6 +44,7 @@ namespace OpenNest.Geometry
public override string ToString() => $"{Width} x {Length}"; public override string ToString() => $"{Width} x {Length}";
public string ToString(int decimalPlaces) => $"{System.Math.Round(Width, decimalPlaces)} x {System.Math.Round(Length, decimalPlaces)}"; public string ToString(int decimalPlaces) =>
$"{System.Math.Round(Width, decimalPlaces)} x {System.Math.Round(Length, decimalPlaces)}";
} }
} }
+408 -157
View File
@@ -1,6 +1,6 @@
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -13,57 +13,72 @@ namespace OpenNest.Geometry
private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction) private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction)
{ {
return RayEdgeDistance( return RayEdgeDistance(
vertex.X, vertex.Y, vertex.X,
edge.pt1.X, edge.pt1.Y, edge.pt2.X, edge.pt2.Y, vertex.Y,
direction); edge.pt1.X,
edge.pt1.Y,
edge.pt2.X,
edge.pt2.Y,
direction
);
} }
[System.Runtime.CompilerServices.MethodImpl( [System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
)]
private static double RayEdgeDistance( private static double RayEdgeDistance(
double vx, double vy, double vx,
double p1x, double p1y, double p2x, double p2y, double vy,
PushDirection direction) double p1x,
double p1y,
double p2x,
double p2y,
PushDirection direction
)
{ {
switch (direction) switch (direction)
{ {
case PushDirection.Left: case PushDirection.Left:
case PushDirection.Right: case PushDirection.Right:
{ {
var dy = p2y - p1y; var dy = p2y - p1y;
if (System.Math.Abs(dy) < Tolerance.Epsilon) if (System.Math.Abs(dy) < Tolerance.Epsilon)
return double.MaxValue;
var t = (vy - p1y) / dy;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var ix = p1x + t * (p2x - p1x);
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue; return double.MaxValue;
}
var t = (vy - p1y) / dy;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var ix = p1x + t * (p2x - p1x);
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
if (dist > Tolerance.Epsilon)
return dist;
if (dist >= -Tolerance.Epsilon)
return 0;
return double.MaxValue;
}
case PushDirection.Down: case PushDirection.Down:
case PushDirection.Up: case PushDirection.Up:
{ {
var dx = p2x - p1x; var dx = p2x - p1x;
if (System.Math.Abs(dx) < Tolerance.Epsilon) if (System.Math.Abs(dx) < Tolerance.Epsilon)
return double.MaxValue;
var t = (vx - p1x) / dx;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var iy = p1y + t * (p2y - p1y);
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue; return double.MaxValue;
}
var t = (vx - p1x) / dx;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var iy = p1y + t * (p2y - p1y);
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
if (dist > Tolerance.Epsilon)
return dist;
if (dist >= -Tolerance.Epsilon)
return 0;
return double.MaxValue;
}
default: default:
return double.MaxValue; return double.MaxValue;
@@ -75,11 +90,18 @@ namespace OpenNest.Geometry
/// Returns double.MaxValue if the ray does not hit the segment. /// Returns double.MaxValue if the ray does not hit the segment.
/// </summary> /// </summary>
[System.Runtime.CompilerServices.MethodImpl( [System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
)]
public static double RayEdgeDistance( public static double RayEdgeDistance(
double vx, double vy, double vx,
double p1x, double p1y, double p2x, double p2y, double vy,
double dirX, double dirY) double p1x,
double p1y,
double p2x,
double p2y,
double dirX,
double dirY
)
{ {
var ex = p2x - p1x; var ex = p2x - p1x;
var ey = p2y - p1y; var ey = p2y - p1y;
@@ -99,8 +121,10 @@ namespace OpenNest.Geometry
if (s < -Tolerance.Epsilon || s > 1.0 + Tolerance.Epsilon) if (s < -Tolerance.Epsilon || s > 1.0 + Tolerance.Epsilon)
return double.MaxValue; return double.MaxValue;
if (t > Tolerance.Epsilon) return t; if (t > Tolerance.Epsilon)
if (t >= -Tolerance.Epsilon) return 0; return t;
if (t >= -Tolerance.Epsilon)
return 0;
return double.MaxValue; return double.MaxValue;
} }
@@ -109,12 +133,19 @@ namespace OpenNest.Geometry
/// Returns false if no real intersection exists. /// Returns false if no real intersection exists.
/// </summary> /// </summary>
[System.Runtime.CompilerServices.MethodImpl( [System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
)]
private static bool SolveRayCircle( private static bool SolveRayCircle(
double vx, double vy, double vx,
double cx, double cy, double r, double vy,
double dirX, double dirY, double cx,
out double t1, out double t2) double cy,
double r,
double dirX,
double dirY,
out double t1,
out double t2
)
{ {
var ox = vx - cx; var ox = vx - cx;
var oy = vy - cy; var oy = vy - cy;
@@ -143,12 +174,20 @@ namespace OpenNest.Geometry
/// angular span. Returns double.MaxValue if no hit. /// angular span. Returns double.MaxValue if no hit.
/// </summary> /// </summary>
[System.Runtime.CompilerServices.MethodImpl( [System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
)]
public static double RayArcDistance( public static double RayArcDistance(
double vx, double vy, double vx,
double cx, double cy, double r, double vy,
double startAngle, double endAngle, bool reversed, double cx,
double dirX, double dirY) 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)) if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
return double.MaxValue; return double.MaxValue;
@@ -157,16 +196,18 @@ namespace OpenNest.Geometry
if (t1 > -Tolerance.Epsilon) if (t1 > -Tolerance.Epsilon)
{ {
var hitAngle = Angle.NormalizeRad(System.Math.Atan2( var hitAngle = Angle.NormalizeRad(
vy + t1 * dirY - cy, vx + t1 * dirX - cx)); System.Math.Atan2(vy + t1 * dirY - cy, vx + t1 * dirX - cx)
);
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed)) if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t1 > Tolerance.Epsilon ? t1 : 0; best = t1 > Tolerance.Epsilon ? t1 : 0;
} }
if (t2 > -Tolerance.Epsilon && t2 < best) if (t2 > -Tolerance.Epsilon && t2 < best)
{ {
var hitAngle = Angle.NormalizeRad(System.Math.Atan2( var hitAngle = Angle.NormalizeRad(
vy + t2 * dirY - cy, vx + t2 * dirX - cx)); System.Math.Atan2(vy + t2 * dirY - cy, vx + t2 * dirX - cx)
);
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed)) if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t2 > Tolerance.Epsilon ? t2 : 0; best = t2 > Tolerance.Epsilon ? t2 : 0;
} }
@@ -179,19 +220,29 @@ namespace OpenNest.Geometry
/// Returns double.MaxValue if no hit. /// Returns double.MaxValue if no hit.
/// </summary> /// </summary>
[System.Runtime.CompilerServices.MethodImpl( [System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
)]
public static double RayCircleDistance( public static double RayCircleDistance(
double vx, double vy, double vx,
double cx, double cy, double r, double vy,
double dirX, double dirY) 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)) if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
return double.MaxValue; return double.MaxValue;
if (t1 > Tolerance.Epsilon) return t1; if (t1 > Tolerance.Epsilon)
if (t1 >= -Tolerance.Epsilon) return 0; return t1;
if (t2 > Tolerance.Epsilon) return t2; if (t1 >= -Tolerance.Epsilon)
if (t2 >= -Tolerance.Epsilon) return 0; return 0;
if (t2 > Tolerance.Epsilon)
return t2;
if (t2 >= -Tolerance.Epsilon)
return 0;
return double.MaxValue; return double.MaxValue;
} }
@@ -201,7 +252,11 @@ namespace OpenNest.Geometry
/// any edge of movingLines contacts any edge of stationaryLines. /// any edge of movingLines contacts any edge of stationaryLines.
/// Returns double.MaxValue if no collision path exists. /// Returns double.MaxValue if no collision path exists.
/// </summary> /// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction) public static double DirectionalDistance(
List<Line> movingLines,
List<Line> stationaryLines,
PushDirection direction
)
{ {
return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction); return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
} }
@@ -211,8 +266,12 @@ namespace OpenNest.Geometry
/// by (movingDx, movingDy) without creating new Line objects. /// by (movingDx, movingDy) without creating new Line objects.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
List<Line> movingLines, double movingDx, double movingDy, List<Line> movingLines,
List<Line> stationaryLines, PushDirection direction) double movingDx,
double movingDy,
List<Line> stationaryLines,
PushDirection direction
)
{ {
var minDist = double.MaxValue; var minDist = double.MaxValue;
var movingOffset = new Vector(movingDx, movingDy); var movingOffset = new Vector(movingDx, movingDy);
@@ -226,7 +285,8 @@ namespace OpenNest.Geometry
foreach (var mv in movingVertices) foreach (var mv in movingVertices)
{ {
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction); var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist) minDist = d; if (d < minDist)
minDist = d;
} }
// Case 2: Each stationary vertex -> each moving edge (opposite direction) // Case 2: Each stationary vertex -> each moving edge (opposite direction)
@@ -239,7 +299,8 @@ namespace OpenNest.Geometry
foreach (var sv in stationaryVertices) foreach (var sv in stationaryVertices)
{ {
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist) minDist = d; if (d < minDist)
minDist = d;
} }
return minDist; return minDist;
@@ -267,9 +328,12 @@ namespace OpenNest.Geometry
/// to avoid all intermediate object allocations. /// to avoid all intermediate object allocations.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
(Vector start, Vector end)[] movingEdges, Vector movingOffset, (Vector start, Vector end)[] movingEdges,
(Vector start, Vector end)[] stationaryEdges, Vector stationaryOffset, Vector movingOffset,
PushDirection direction) (Vector start, Vector end)[] stationaryEdges,
Vector stationaryOffset,
PushDirection direction
)
{ {
var minDist = double.MaxValue; var minDist = double.MaxValue;
@@ -281,7 +345,8 @@ namespace OpenNest.Geometry
foreach (var mv in movingVertices) foreach (var mv in movingVertices)
{ {
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction); var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
if (d < minDist) minDist = d; if (d < minDist)
minDist = d;
} }
// Case 2: Each stationary vertex -> each moving edge (opposite direction) // Case 2: Each stationary vertex -> each moving edge (opposite direction)
@@ -293,15 +358,19 @@ namespace OpenNest.Geometry
foreach (var sv in stationaryVertices) foreach (var sv in stationaryVertices)
{ {
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist) minDist = d; if (d < minDist)
minDist = d;
} }
return minDist; return minDist;
} }
public static double OneWayDistance( public static double OneWayDistance(
Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset, Vector vertex,
PushDirection direction) (Vector start, Vector end)[] edges,
Vector edgeOffset,
PushDirection direction
)
{ {
var minDist = double.MaxValue; var minDist = double.MaxValue;
var vx = vertex.X; var vx = vertex.X;
@@ -315,7 +384,9 @@ namespace OpenNest.Geometry
var e1 = edges[i].start + edgeOffset; var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset; var e2 = edges[i].end + edgeOffset;
double perpValue, edgeMin, edgeMax; double perpValue,
edgeMin,
edgeMax;
if (horizontal) if (horizontal)
{ {
perpValue = vy; perpValue = vy;
@@ -337,7 +408,8 @@ namespace OpenNest.Geometry
continue; continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d; if (d < minDist)
minDist = d;
} }
return minDist; return minDist;
@@ -347,11 +419,16 @@ namespace OpenNest.Geometry
{ {
switch (direction) switch (direction)
{ {
case PushDirection.Left: return PushDirection.Right; case PushDirection.Left:
case PushDirection.Right: return PushDirection.Left; return PushDirection.Right;
case PushDirection.Up: return PushDirection.Down; case PushDirection.Right:
case PushDirection.Down: return PushDirection.Up; return PushDirection.Left;
default: return direction; case PushDirection.Up:
return PushDirection.Down;
case PushDirection.Down:
return PushDirection.Up;
default:
return direction;
} }
} }
@@ -364,11 +441,16 @@ namespace OpenNest.Geometry
{ {
switch (direction) switch (direction)
{ {
case PushDirection.Left: return box.Left - boundary.Left; case PushDirection.Left:
case PushDirection.Right: return boundary.Right - box.Right; return box.Left - boundary.Left;
case PushDirection.Up: return boundary.Top - box.Top; case PushDirection.Right:
case PushDirection.Down: return box.Bottom - boundary.Bottom; return boundary.Right - box.Right;
default: return double.MaxValue; case PushDirection.Up:
return boundary.Top - box.Top;
case PushDirection.Down:
return box.Bottom - boundary.Bottom;
default:
return double.MaxValue;
} }
} }
@@ -376,11 +458,16 @@ namespace OpenNest.Geometry
{ {
switch (direction) switch (direction)
{ {
case PushDirection.Left: return new Vector(-distance, 0); case PushDirection.Left:
case PushDirection.Right: return new Vector(distance, 0); return new Vector(-distance, 0);
case PushDirection.Up: return new Vector(0, distance); case PushDirection.Right:
case PushDirection.Down: return new Vector(0, -distance); return new Vector(distance, 0);
default: return new Vector(); case PushDirection.Up:
return new Vector(0, distance);
case PushDirection.Down:
return new Vector(0, -distance);
default:
return new Vector();
} }
} }
@@ -388,11 +475,16 @@ namespace OpenNest.Geometry
{ {
switch (direction) switch (direction)
{ {
case PushDirection.Left: return from.Left - to.Right; case PushDirection.Left:
case PushDirection.Right: return to.Left - from.Right; return from.Left - to.Right;
case PushDirection.Up: return to.Bottom - from.Top; case PushDirection.Right:
case PushDirection.Down: return from.Bottom - to.Top; return to.Left - from.Right;
default: return double.MaxValue; case PushDirection.Up:
return to.Bottom - from.Top;
case PushDirection.Down:
return from.Bottom - to.Top;
default:
return double.MaxValue;
} }
} }
@@ -409,23 +501,27 @@ namespace OpenNest.Geometry
if (direction.X < -Tolerance.Epsilon) if (direction.X < -Tolerance.Epsilon)
{ {
var d = (box.Left - boundary.Left) / -direction.X; var d = (box.Left - boundary.Left) / -direction.X;
if (d < dist) dist = d; if (d < dist)
dist = d;
} }
else if (direction.X > Tolerance.Epsilon) else if (direction.X > Tolerance.Epsilon)
{ {
var d = (boundary.Right - box.Right) / direction.X; var d = (boundary.Right - box.Right) / direction.X;
if (d < dist) dist = d; if (d < dist)
dist = d;
} }
if (direction.Y < -Tolerance.Epsilon) if (direction.Y < -Tolerance.Epsilon)
{ {
var d = (box.Bottom - boundary.Bottom) / -direction.Y; var d = (box.Bottom - boundary.Bottom) / -direction.Y;
if (d < dist) dist = d; if (d < dist)
dist = d;
} }
else if (direction.Y > Tolerance.Epsilon) else if (direction.Y > Tolerance.Epsilon)
{ {
var d = (boundary.Top - box.Top) / direction.Y; var d = (boundary.Top - box.Top) / direction.Y;
if (d < dist) dist = d; if (d < dist)
dist = d;
} }
return dist < 0 ? 0 : dist; return dist < 0 ? 0 : dist;
@@ -463,7 +559,11 @@ namespace OpenNest.Geometry
/// Computes the minimum translation distance along an arbitrary unit direction /// Computes the minimum translation distance along an arbitrary unit direction
/// before any edge of movingLines contacts any edge of stationaryLines. /// before any edge of movingLines contacts any edge of stationaryLines.
/// </summary> /// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, Vector direction) public static double DirectionalDistance(
List<Line> movingLines,
List<Line> stationaryLines,
Vector direction
)
{ {
var minDist = double.MaxValue; var minDist = double.MaxValue;
var dirX = direction.X; var dirX = direction.X;
@@ -476,8 +576,18 @@ namespace OpenNest.Geometry
for (var i = 0; i < stationaryLines.Count; i++) for (var i = 0; i < stationaryLines.Count; i++)
{ {
var e = stationaryLines[i]; var e = stationaryLines[i];
var d = RayEdgeDistance(mv.X, mv.Y, e.pt1.X, e.pt1.Y, e.pt2.X, e.pt2.Y, dirX, dirY); var d = RayEdgeDistance(
if (d < minDist) minDist = d; mv.X,
mv.Y,
e.pt1.X,
e.pt1.Y,
e.pt2.X,
e.pt2.Y,
dirX,
dirY
);
if (d < minDist)
minDist = d;
} }
} }
@@ -491,8 +601,18 @@ namespace OpenNest.Geometry
for (var i = 0; i < movingLines.Count; i++) for (var i = 0; i < movingLines.Count; i++)
{ {
var e = movingLines[i]; var e = movingLines[i];
var d = RayEdgeDistance(sv.X, sv.Y, e.pt1.X, e.pt1.Y, e.pt2.X, e.pt2.Y, oppX, oppY); var d = RayEdgeDistance(
if (d < minDist) minDist = d; sv.X,
sv.Y,
e.pt1.X,
e.pt1.Y,
e.pt2.X,
e.pt2.Y,
oppX,
oppY
);
if (d < minDist)
minDist = d;
} }
} }
@@ -505,9 +625,16 @@ namespace OpenNest.Geometry
/// stationaryEntities. Delegates to the Vector-based overload. /// stationaryEntities. Delegates to the Vector-based overload.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction) List<Entity> movingEntities,
List<Entity> stationaryEntities,
PushDirection direction
)
{ {
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0)); return DirectionalDistance(
movingEntities,
stationaryEntities,
DirectionToOffset(direction, 1.0)
);
} }
/// <summary> /// <summary>
@@ -517,7 +644,10 @@ namespace OpenNest.Geometry
/// without tessellation. /// without tessellation.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, Vector direction) List<Entity> movingEntities,
List<Entity> stationaryEntities,
Vector direction
)
{ {
var minDist = double.MaxValue; var minDist = double.MaxValue;
var dirX = direction.X; var dirX = direction.X;
@@ -536,7 +666,8 @@ namespace OpenNest.Geometry
if (d < minDist) if (d < minDist)
{ {
minDist = d; minDist = d;
if (d <= 0) return 0; if (d <= 0)
return 0;
} }
} }
} }
@@ -557,7 +688,8 @@ namespace OpenNest.Geometry
if (d < minDist) if (d < minDist)
{ {
minDist = d; minDist = d;
if (d <= 0) return 0; if (d <= 0)
return 0;
} }
} }
} }
@@ -566,10 +698,24 @@ namespace OpenNest.Geometry
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual // 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 // 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. // those samples. Use ClosestPointTo to find it and fire a ray from there.
minDist = ArcToLineClosestDistance(movingEntities, stationaryEntities, dirX, dirY, minDist); minDist = ArcToLineClosestDistance(
if (minDist <= 0) return 0; movingEntities,
minDist = ArcToLineClosestDistance(stationaryEntities, movingEntities, oppX, oppY, minDist); stationaryEntities,
if (minDist <= 0) return 0; 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. // Phase 4: Curve-to-curve direct distance.
// The vertex-to-entity approach misses the closest contact between two // The vertex-to-entity approach misses the closest contact between two
@@ -605,20 +751,35 @@ namespace OpenNest.Geometry
if (me is Arc mArc) if (me is Arc mArc)
{ {
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx)); var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed)) if (
!Angle.IsBetweenRad(
angle,
mArc.StartAngle,
mArc.EndAngle,
mArc.IsReversed
)
)
continue; continue;
} }
if (se is Arc sArc) if (se is Arc sArc)
{ {
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx)); var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed)) if (
!Angle.IsBetweenRad(
angle,
sArc.StartAngle,
sArc.EndAngle,
sArc.IsReversed
)
)
continue; continue;
} }
} }
minDist = d; minDist = d;
if (d <= 0) return 0; if (d <= 0)
return 0;
} }
} }
@@ -626,8 +787,12 @@ namespace OpenNest.Geometry
} }
private static double ArcToLineClosestDistance( private static double ArcToLineClosestDistance(
List<Entity> arcEntities, List<Entity> lineEntities, List<Entity> arcEntities,
double dirX, double dirY, double minDist) List<Entity> lineEntities,
double dirX,
double dirY,
double minDist
)
{ {
for (var i = 0; i < arcEntities.Count; i++) for (var i = 0; i < arcEntities.Count; i++)
{ {
@@ -662,15 +827,30 @@ namespace OpenNest.Geometry
{ {
var theta = k == 0 ? theta1 : theta2; var theta = k == 0 ? theta1 : theta2;
if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)) if (
!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)
)
continue; continue;
var qx = cx + r * System.Math.Cos(theta); var qx = cx + r * System.Math.Cos(theta);
var qy = cy + r * System.Math.Sin(theta); var qy = cy + r * System.Math.Sin(theta);
var d = RayEdgeDistance(qx, qy, p1x, p1y, line.pt2.X, line.pt2.Y, var d = RayEdgeDistance(
dirX, dirY); qx,
if (d < minDist) { minDist = d; if (d <= 0) return 0; } qy,
p1x,
p1y,
line.pt2.X,
line.pt2.Y,
dirX,
dirY
);
if (d < minDist)
{
minDist = d;
if (d <= 0)
return 0;
}
} }
} }
} }
@@ -678,28 +858,54 @@ namespace OpenNest.Geometry
} }
private static double RayEntityDistance( private static double RayEntityDistance(
double vx, double vy, Entity entity, double dirX, double dirY) double vx,
double vy,
Entity entity,
double dirX,
double dirY
)
{ {
if (entity is Line line) if (entity is Line line)
{ {
return RayEdgeDistance(vx, vy, return RayEdgeDistance(
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, vx,
dirX, dirY); vy,
line.pt1.X,
line.pt1.Y,
line.pt2.X,
line.pt2.Y,
dirX,
dirY
);
} }
if (entity is Arc arc) if (entity is Arc arc)
{ {
return RayArcDistance(vx, vy, return RayArcDistance(
arc.Center.X, arc.Center.Y, arc.Radius, vx,
arc.StartAngle, arc.EndAngle, arc.IsReversed, vy,
dirX, dirY); arc.Center.X,
arc.Center.Y,
arc.Radius,
arc.StartAngle,
arc.EndAngle,
arc.IsReversed,
dirX,
dirY
);
} }
if (entity is Circle circle) if (entity is Circle circle)
{ {
return RayCircleDistance(vx, vy, return RayCircleDistance(
circle.Center.X, circle.Center.Y, circle.Radius, vx,
dirX, dirY); vy,
circle.Center.X,
circle.Center.Y,
circle.Radius,
dirX,
dirY
);
} }
return double.MaxValue; return double.MaxValue;
@@ -759,7 +965,10 @@ namespace OpenNest.Geometry
return CollectVertices(ToEdgeArray(lines), offset); return CollectVertices(ToEdgeArray(lines), offset);
} }
private static HashSet<Vector> CollectVertices((Vector start, Vector end)[] edges, Vector offset) private static HashSet<Vector> CollectVertices(
(Vector start, Vector end)[] edges,
Vector offset
)
{ {
var vertices = new HashSet<Vector>(); var vertices = new HashSet<Vector>();
for (var i = 0; i < edges.Length; i++) for (var i = 0; i < edges.Length; i++)
@@ -778,26 +987,48 @@ namespace OpenNest.Geometry
return edges; return edges;
} }
private static void SortEdgesForPruning((Vector start, Vector end)[] edges, PushDirection direction) private static void SortEdgesForPruning(
(Vector start, Vector end)[] edges,
PushDirection direction
)
{ {
if (direction == PushDirection.Left || direction == PushDirection.Right) if (direction == PushDirection.Left || direction == PushDirection.Right)
System.Array.Sort(edges, (a, b) => System.Array.Sort(
System.Math.Min(a.start.Y, a.end.Y).CompareTo(System.Math.Min(b.start.Y, b.end.Y))); edges,
(a, b) =>
System
.Math.Min(a.start.Y, a.end.Y)
.CompareTo(System.Math.Min(b.start.Y, b.end.Y))
);
else else
System.Array.Sort(edges, (a, b) => System.Array.Sort(
System.Math.Min(a.start.X, a.end.X).CompareTo(System.Math.Min(b.start.X, b.end.X))); 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) private static bool TryGetCurveParams(
Entity entity,
out double cx,
out double cy,
out double r
)
{ {
if (entity is Circle circle) if (entity is Circle circle)
{ {
cx = circle.Center.X; cy = circle.Center.Y; r = circle.Radius; cx = circle.Center.X;
cy = circle.Center.Y;
r = circle.Radius;
return true; return true;
} }
if (entity is Arc arc) if (entity is Arc arc)
{ {
cx = arc.Center.X; cy = arc.Center.Y; r = arc.Radius; cx = arc.Center.X;
cy = arc.Center.Y;
r = arc.Radius;
return true; return true;
} }
cx = cy = r = 0; cx = cy = r = 0;
@@ -850,7 +1081,13 @@ namespace OpenNest.Geometry
return new Box(lft, btm, rgt - lft, top - btm); return new Box(lft, btm, rgt - lft, top - btm);
} }
private static bool FindVerticalLimits(Vector pt, Box bounds, List<Box> boxes, out double top, out double btm) private static bool FindVerticalLimits(
Vector pt,
Box bounds,
List<Box> boxes,
out double top,
out double btm
)
{ {
top = double.MaxValue; top = double.MaxValue;
btm = double.MinValue; btm = double.MinValue;
@@ -868,20 +1105,30 @@ namespace OpenNest.Geometry
if (top == double.MaxValue) if (top == double.MaxValue)
{ {
if (bounds.Top > pt.Y) top = bounds.Top; if (bounds.Top > pt.Y)
else return false; top = bounds.Top;
else
return false;
} }
if (btm == double.MinValue) if (btm == double.MinValue)
{ {
if (bounds.Bottom < pt.Y) btm = bounds.Bottom; if (bounds.Bottom < pt.Y)
else return false; btm = bounds.Bottom;
else
return false;
} }
return true; return true;
} }
private static bool FindHorizontalLimits(Vector pt, Box bounds, List<Box> boxes, out double lft, out double rgt) private static bool FindHorizontalLimits(
Vector pt,
Box bounds,
List<Box> boxes,
out double lft,
out double rgt
)
{ {
lft = double.MinValue; lft = double.MinValue;
rgt = double.MaxValue; rgt = double.MaxValue;
@@ -899,14 +1146,18 @@ namespace OpenNest.Geometry
if (rgt == double.MaxValue) if (rgt == double.MaxValue)
{ {
if (bounds.Right > pt.X) rgt = bounds.Right; if (bounds.Right > pt.X)
else return false; rgt = bounds.Right;
else
return false;
} }
if (lft == double.MinValue) if (lft == double.MinValue)
{ {
if (bounds.Left < pt.X) lft = bounds.Left; if (bounds.Left < pt.X)
else return false; lft = bounds.Left;
else
return false;
} }
return true; return true;
+32 -16
View File
@@ -1,6 +1,6 @@
using OpenNest.Math;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -8,7 +8,11 @@ namespace OpenNest.Geometry
{ {
private const int MinPointsForArc = 3; private const int MinPointsForArc = 3;
public static List<Entity> Convert(List<Vector> points, bool isClosed, double tolerance = 0.001) public static List<Entity> Convert(
List<Vector> points,
bool isClosed,
double tolerance = 0.001
)
{ {
if (points == null || points.Count < 2) if (points == null || points.Count < 2)
return new List<Entity>(); return new List<Entity>();
@@ -37,8 +41,12 @@ namespace OpenNest.Geometry
return entities; return entities;
} }
private static ArcFitResult TryFitArc(List<Vector> points, int start, private static ArcFitResult TryFitArc(
Vector chainedTangent, double tolerance) List<Vector> points,
int start,
Vector chainedTangent,
double tolerance
)
{ {
var minEnd = start + MinPointsForArc - 1; var minEnd = start + MinPointsForArc - 1;
if (minEnd >= points.Count) if (minEnd >= points.Count)
@@ -83,7 +91,8 @@ namespace OpenNest.Geometry
} }
private static (Vector center, double radius, double deviation) FitCircumscribed( private static (Vector center, double radius, double deviation) FitCircumscribed(
List<Vector> points) List<Vector> points
)
{ {
if (points.Count < 3) if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue); return (Vector.Invalid, 0, double.MaxValue);
@@ -131,11 +140,16 @@ namespace OpenNest.Geometry
} }
private static (Vector center, double radius, double deviation) FitWithStartTangent( private static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent) => List<Vector> points,
ArcFit.FitWithStartTangent(points, tangent); Vector tangent
) => ArcFit.FitWithStartTangent(points, tangent);
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius) => private static double MaxRadialDeviation(
ArcFit.MaxRadialDeviation(points, cx, cy, radius); List<Vector> points,
double cx,
double cy,
double radius
) => ArcFit.MaxRadialDeviation(points, cx, cy, radius);
private static double SumSignedAngles(Vector center, List<Vector> points) private static double SumSignedAngles(Vector center, List<Vector> points)
{ {
@@ -145,8 +159,10 @@ namespace OpenNest.Geometry
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X); var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X); var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1; var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI; while (da > System.Math.PI)
while (da < -System.Math.PI) da += Angle.TwoPI; da -= Angle.TwoPI;
while (da < -System.Math.PI)
da += Angle.TwoPI;
total += da; total += da;
} }
return total; return total;
@@ -160,9 +176,7 @@ namespace OpenNest.Geometry
var rx = lastPt.X - center.X; var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y; var ry = lastPt.Y - center.Y;
return totalAngle >= 0 return totalAngle >= 0 ? new Vector(-ry, rx) : new Vector(ry, -rx);
? new Vector(-ry, rx)
: new Vector(ry, -rx);
} }
private static Arc CreateArc(Vector center, double radius, List<Vector> points) private static Arc CreateArc(Vector center, double radius, List<Vector> points)
@@ -174,8 +188,10 @@ namespace OpenNest.Geometry
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X); var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
var isReversed = SumSignedAngles(center, points) < 0; var isReversed = SumSignedAngles(center, points) < 0;
if (startAngle < 0) startAngle += Angle.TwoPI; if (startAngle < 0)
if (endAngle < 0) endAngle += Angle.TwoPI; startAngle += Angle.TwoPI;
if (endAngle < 0)
endAngle += Angle.TwoPI;
return new Arc(center, radius, startAngle, endAngle, isReversed); return new Arc(center, radius, startAngle, endAngle, isReversed);
} }
@@ -0,0 +1,541 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry
{
/// <summary>
/// Immutable triangulation of a simple, closed, lines-only perimeter and its holes.
/// Cached triangles use the reference Collision clipping and hole-subtraction rules.
/// Translation is a parameter; preparation never retains mutable input polygons.
/// Scratch arrays and hole-piece lists are allocated per query, with a bounded
/// thread-local buffer pool. Null means the caller must use Collision.HasOverlap.
/// </summary>
public sealed class TriangulatedRegion
{
// Flat vertex pool (local frame) and triangle index triples (CCW).
private readonly double[] X;
private readonly double[] Y;
private readonly int[] _ia;
private readonly int[] _ib;
private readonly int[] _ic;
private readonly double[] _tMinX;
private readonly double[] _tMinY;
private readonly double[] _tMaxX;
private readonly double[] _tMaxY;
private double MinX { get; }
private double MinY { get; }
private double MaxX { get; }
private double MaxY { get; }
/// <summary>Triangulated holes in the same local frame (null when none).</summary>
private readonly TriangulatedRegion?[]? Holes;
// Scratch bound: clipped convex pieces stay small; anything larger bails.
private const int MaxClipVertices = 48;
private const int MaxPieces = 2048;
private TriangulatedRegion(
double[] x,
double[] y,
int[] ia,
int[] ib,
int[] ic,
double[] tMinX,
double[] tMinY,
double[] tMaxX,
double[] tMaxY,
TriangulatedRegion?[]? holes
)
{
X = x;
Y = y;
_ia = ia;
_ib = ib;
_ic = ic;
_tMinX = tMinX;
_tMinY = tMinY;
_tMaxX = tMaxX;
_tMaxY = tMaxY;
Holes = holes;
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var i = 0; i < x.Length; i++)
{
if (x[i] < minX)
minX = x[i];
if (x[i] > maxX)
maxX = x[i];
if (y[i] < minY)
minY = y[i];
if (y[i] > maxY)
maxY = y[i];
}
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
}
/// <summary>
/// Ear-clips a polygon ring into cached triangles. Returns null when
/// triangulation yields nothing usable - the caller falls back to Polygon gates.
/// </summary>
public static TriangulatedRegion? Build(Polygon perimeter, IReadOnlyList<Polygon>? holes = null)
{
try
{
var tris = ConvexDecomposition.Triangulate(perimeter);
var count = tris.Count;
if (count == 0)
return null;
var xs = new double[count * 3];
var ys = new double[count * 3];
var ia = new int[count];
var ib = new int[count];
var ic = new int[count];
var minXA = new double[count];
var minYA = new double[count];
var maxXA = new double[count];
var maxYA = new double[count];
var k = 0;
for (var t = 0; t < count; t++)
{
var v = tris[t].Vertices; // closed: prev, curr, next, prev
ia[t] = k;
xs[k] = v[0].X;
ys[k] = v[0].Y;
k++;
ib[t] = k;
xs[k] = v[1].X;
ys[k] = v[1].Y;
k++;
ic[t] = k;
xs[k] = v[2].X;
ys[k] = v[2].Y;
k++;
minXA[t] = System.Math.Min(v[0].X, System.Math.Min(v[1].X, v[2].X));
minYA[t] = System.Math.Min(v[0].Y, System.Math.Min(v[1].Y, v[2].Y));
maxXA[t] = System.Math.Max(v[0].X, System.Math.Max(v[1].X, v[2].X));
maxYA[t] = System.Math.Max(v[0].Y, System.Math.Max(v[1].Y, v[2].Y));
}
TriangulatedRegion[]? holeSets = null;
if (holes != null && holes.Count > 0)
{
holeSets = new TriangulatedRegion[holes.Count];
for (var h = 0; h < holes.Count; h++)
{
var holeTris = ConvexDecomposition.Triangulate(holes[h]);
if (holeTris.Count == 0)
continue;
var hx = new double[holeTris.Count * 3];
var hy = new double[holeTris.Count * 3];
var hia = new int[holeTris.Count];
var hib = new int[holeTris.Count];
var hic = new int[holeTris.Count];
var hminX = new double[holeTris.Count];
var hminY = new double[holeTris.Count];
var hmaxX = new double[holeTris.Count];
var hmaxY = new double[holeTris.Count];
var hk = 0;
for (var t = 0; t < holeTris.Count; t++)
{
var v = holeTris[t].Vertices;
hia[t] = hk;
hx[hk] = v[0].X;
hy[hk] = v[0].Y;
hk++;
hib[t] = hk;
hx[hk] = v[1].X;
hy[hk] = v[1].Y;
hk++;
hic[t] = hk;
hx[hk] = v[2].X;
hy[hk] = v[2].Y;
hk++;
hminX[t] = System.Math.Min(v[0].X, System.Math.Min(v[1].X, v[2].X));
hminY[t] = System.Math.Min(v[0].Y, System.Math.Min(v[1].Y, v[2].Y));
hmaxX[t] = System.Math.Max(v[0].X, System.Math.Max(v[1].X, v[2].X));
hmaxY[t] = System.Math.Max(v[0].Y, System.Math.Max(v[1].Y, v[2].Y));
}
holeSets[h] = new TriangulatedRegion(hx, hy, hia, hib, hic, hminX, hminY, hmaxX, hmaxY, null);
}
}
return new TriangulatedRegion(xs, ys, ia, ib, ic, minXA, minYA, maxXA, maxYA, holeSets);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Positive shared area (surviving both polygons' hole sets) between this
/// translated by (adx, ady) and other translated by (bdx, bdy). Returns null
/// when the scratch bounds are exceeded and the question cannot be decided.
/// Inputs and translations must have finite coordinates.
/// </summary>
public bool? Overlaps(TriangulatedRegion other, double adx, double ady, double bdx, double bdy)
{
// Same bbox rule as Collision.BoundingBoxesOverlap: overlap must exceed
// Tolerance.Epsilon on both axes, so a hairline box overlap never reaches the
// clip stage.
var eps = OpenNest.Math.Tolerance.Epsilon;
var overlapX =
System.Math.Min(MaxX + adx, other.MaxX + bdx) - System.Math.Max(MinX + adx, other.MinX + bdx);
var overlapY =
System.Math.Min(MaxY + ady, other.MaxY + bdy) - System.Math.Max(MinY + ady, other.MinY + bdy);
if (overlapX <= eps || overlapY <= eps)
return false;
var areaFloor = 2 * OpenNest.Math.Tolerance.Epsilon;
var clipA = new double[MaxClipVertices * 2];
var clipB = new double[MaxClipVertices * 2];
var piece = new double[MaxClipVertices * 2];
for (var ta = 0; ta < _ia.Length; ta++)
{
var aMinX = _tMinX[ta] + adx;
var aMaxX = _tMaxX[ta] + adx;
var aMinY = _tMinY[ta] + ady;
var aMaxY = _tMaxY[ta] + ady;
for (var tb = 0; tb < other._ia.Length; tb++)
{
var bMinX = other._tMinX[tb] + bdx;
var bMaxX = other._tMaxX[tb] + bdx;
var bMinY = other._tMinY[tb] + bdy;
var bMaxY = other._tMaxY[tb] + bdy;
if (
System.Math.Min(aMaxX, bMaxX) - System.Math.Max(aMinX, bMinX) <= eps
|| System.Math.Min(aMaxY, bMaxY) - System.Math.Max(aMinY, bMinY) <= eps
)
continue;
var count = ClipTriangle(
ta, adx, ady, other, tb, bdx, bdy, clipA, clipB, piece
);
if (count >= MaxClipVertices)
return null;
if (count < 3)
continue;
if (TwiceArea(piece, count) <= areaFloor)
continue;
var (hasHoles, undecided, survived) = SubtractAllHoles(
other, adx, ady, bdx, bdy, piece, count, areaFloor
);
if (undecided)
return null;
if (hasHoles)
{
if (survived)
return true;
}
else
{
return true; // no holes on either side: the clipped region is overlap
}
}
}
return false;
}
/// <summary>
/// Subtracts both polygons' hole triangles from one clipped region, mirroring
/// Collision.SubtractHoles: for every hole triangle, every surviving piece is
/// split per edge into outside pieces (survivors) and the inside remainder
/// (consumed). True means a positive-area piece survived ALL holes.
/// </summary>
[ThreadStatic]
private static List<double[]>? s_pool;
[ThreadStatic]
private static double[]? s_tmpA;
[ThreadStatic]
private static double[]? s_tmpB;
private static double[] AcquireBuffer()
{
var pool = s_pool ??= new List<double[]>();
var n = pool.Count;
if (n == 0)
return new double[MaxClipVertices * 2];
var buf = pool[n - 1];
pool.RemoveAt(n - 1);
return buf;
}
private static void ReleaseBuffer(double[] buf)
{
var pool = s_pool ??= new List<double[]>();
if (pool.Count < 64)
pool.Add(buf);
}
private static (double[] Tmp, double[] Inside) ScratchPair()
{
s_tmpA ??= new double[MaxClipVertices * 2];
s_tmpB ??= new double[MaxClipVertices * 2];
return (s_tmpA, s_tmpB);
}
private (bool hasHoles, bool undecided, bool survived) SubtractAllHoles(
TriangulatedRegion other,
double adx,
double ady,
double bdx,
double bdy,
double[] piece,
int count,
double areaFloor
)
{
var allHoles = 0;
if (Holes != null)
allHoles += Holes.Length;
if (other.Holes != null)
allHoles += other.Holes.Length;
if (allHoles == 0)
return (false, false, false);
// pieces[0] is the caller's own buffer - never release it back to the pool.
var pieces = new List<(double[] Buf, int Count)> { (piece, count) };
var owned = new HashSet<double[]>();
bool SubtractOwner(TriangulatedRegion owner, double odx, double ody)
{
if (owner.Holes == null)
return true;
for (var h = 0; h < owner.Holes.Length && pieces.Count > 0; h++)
{
var hole = owner.Holes[h];
if (hole == null)
continue; // untriangulatable hole: nothing to subtract
for (var t = 0; t < hole._ia.Length && pieces.Count > 0; t++)
{
var hMinX = hole._tMinX[t] + odx;
var hMaxX = hole._tMaxX[t] + odx;
var hMinY = hole._tMinY[t] + ody;
var hMaxY = hole._tMaxY[t] + ody;
var next = new List<(double[], int)>();
for (var p = 0; p < pieces.Count; p++)
{
var (buf, pc) = pieces[p];
// Piece bbox (built-in uses <=: touching skips subtraction).
var pMinX = double.MaxValue;
var pMinY = double.MaxValue;
var pMaxX = double.MinValue;
var pMaxY = double.MinValue;
for (var v = 0; v < pc; v++)
{
var px = buf[v * 2];
var py = buf[v * 2 + 1];
if (px < pMinX)
pMinX = px;
if (px > pMaxX)
pMaxX = px;
if (py < pMinY)
pMinY = py;
if (py > pMaxY)
pMaxY = py;
}
if (pMaxX <= hMinX || hMaxX <= pMinX || pMaxY <= hMinY || hMaxY <= pMinY)
{
if (next.Count >= MaxPieces)
return false;
next.Add((buf, pc));
continue;
}
// Clip the piece against the hole triangle's three edges: the
// outside of each edge survives as its own piece; the inside
// remainder continues into the next edge. The remainder inside
// all three edges is consumed (the hole ate it).
var rem = AcquireBuffer();
owned.Add(rem);
Array.Copy(buf, rem, pc * 2);
var remCount = pc;
var (tmp, insideBuf) = ScratchPair();
for (var e = 0; e < 3 && remCount >= 3; e++)
{
var ei = e == 0 ? hole._ia[t] : e == 1 ? hole._ib[t] : hole._ic[t];
var ej = e == 0 ? hole._ib[t] : e == 1 ? hole._ic[t] : hole._ia[t];
var sx = hole.X[ei] + odx;
var sy = hole.Y[ei] + ody;
var ex = hole.X[ej] + odx;
var ey = hole.Y[ej] + ody;
var outCount =
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, false, tmp);
if (outCount >= MaxClipVertices)
return false;
if (outCount >= 3 && TwiceArea(tmp, outCount) > areaFloor)
{
if (next.Count >= MaxPieces)
return false; // undecided
var keep = AcquireBuffer();
owned.Add(keep);
Array.Copy(tmp, keep, outCount * 2);
next.Add((keep, outCount));
}
remCount =
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, true, insideBuf);
if (remCount >= MaxClipVertices)
return false; // undecided
Array.Copy(insideBuf, rem, remCount * 2);
}
// The inside-all-edges remainder is consumed by the hole: drop it.
owned.Remove(rem);
ReleaseBuffer(rem);
if (owned.Remove(buf))
ReleaseBuffer(buf);
}
pieces = next;
}
}
return true;
}
try
{
if (!SubtractOwner(this, adx, ady) || !SubtractOwner(other, bdx, bdy))
return (true, true, false);
foreach (var (buf, pc) in pieces)
if (pc >= 3 && TwiceArea(buf, pc) > areaFloor)
return (true, false, true);
return (true, false, false);
}
finally
{
foreach (var buffer in owned)
ReleaseBuffer(buffer);
}
}
/// <summary>Clip this' triangle against other's triangle; returns count into piece.</summary>
private int ClipTriangle(
int ta,
double adx,
double ady,
TriangulatedRegion other,
int tb,
double bdx,
double bdy,
double[] bufA,
double[] bufB,
double[] piece
)
{
var ia = _ia[ta];
var ib = _ib[ta];
var ic = _ic[ta];
bufA[0] = X[ia] + adx;
bufA[1] = Y[ia] + ady;
bufA[2] = X[ib] + adx;
bufA[3] = Y[ib] + ady;
bufA[4] = X[ic] + adx;
bufA[5] = Y[ic] + ady;
var count = 3;
for (var e = 0; e < 3 && count >= 3; e++)
{
var ei = e == 0 ? other._ia[tb] : e == 1 ? other._ib[tb] : other._ic[tb];
var ej = e == 0 ? other._ib[tb] : e == 1 ? other._ic[tb] : other._ia[tb];
var sx = other.X[ei] + bdx;
var sy = other.Y[ei] + bdy;
var ex = other.X[ej] + bdx;
var ey = other.Y[ej] + bdy;
count = ClipHalfSpace(bufA, count, sx, sy, ex, ey, true, bufB);
if (count >= MaxClipVertices)
return count;
for (var v = 0; v < count * 2; v++)
bufA[v] = bufB[v];
}
for (var v = 0; v < System.Math.Min(count, MaxClipVertices) * 2; v++)
piece[v] = bufA[v];
return count;
}
/// <summary>
/// Sutherland-Hodgman clip against one directed edge's half-plane; identical
/// classification, interpolation and dedupe to Collision.ClipHalfSpace.
/// </summary>
private static int ClipHalfSpace(
double[] verts,
int count,
double sx,
double sy,
double ex,
double ey,
bool inside,
double[] outBuf
)
{
var kept = 0;
var cap = outBuf.Length / 2;
var edgeX = ex - sx;
var edgeY = ey - sy;
for (var i = 0; i < count; i++)
{
var j = (i + 1) % count;
var cx = verts[i * 2];
var cy = verts[i * 2 + 1];
var nx = verts[j * 2];
var ny = verts[j * 2 + 1];
var cd = edgeX * (cy - sy) - edgeY * (cx - sx);
var nd = edgeX * (ny - sy) - edgeY * (nx - sx);
if (inside ? cd >= 0 : cd <= 0)
{
if (kept >= cap)
return cap; // overflow: caller treats as undecided
kept = AddDistinct(outBuf, kept, cx, cy);
}
if ((cd < 0 && nd > 0) || (cd > 0 && nd < 0))
{
if (kept >= cap)
return cap; // overflow
var t = cd / (cd - nd);
kept = AddDistinct(
outBuf, kept, cx + t * (nx - cx), cy + t * (ny - cy)
);
}
}
if (kept > 1 && outBuf[0] == outBuf[(kept - 1) * 2] && outBuf[1] == outBuf[(kept - 1) * 2 + 1])
kept--;
return kept;
}
private static int AddDistinct(double[] buf, int count, double x, double y)
{
if (count > 0 && buf[(count - 1) * 2] == x && buf[(count - 1) * 2 + 1] == y)
return count;
buf[count * 2] = x;
buf[count * 2 + 1] = y;
return count + 1;
}
/// <summary>Twice the area, relative to vertex 0 (cancellation-safe).</summary>
private static double TwiceArea(double[] verts, int count)
{
var twiceArea = 0.0;
for (var i = 1; i + 1 < count; i++)
twiceArea +=
(verts[i * 2] - verts[0]) * (verts[(i + 1) * 2 + 1] - verts[1])
- (verts[i * 2 + 1] - verts[1]) * (verts[(i + 1) * 2] - verts[0]);
return System.Math.Abs(twiceArea);
}
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
using OpenNest.Math; using System;
using System; using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -31,7 +31,7 @@ namespace OpenNest.Geometry
{ {
unchecked unchecked
{ {
// Use a simple but effective hash combine. // Use a simple but effective hash combine.
// We use a small epsilon-safe rounding if needed, but for uniqueness in HashSet // We use a small epsilon-safe rounding if needed, but for uniqueness in HashSet
// during a single operation, raw bits or slightly rounded is usually fine. // during a single operation, raw bits or slightly rounded is usually fine.
// However, IsEqualTo uses Tolerance.Epsilon, so we should probably round to some precision. // However, IsEqualTo uses Tolerance.Epsilon, so we should probably round to some precision.

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