Author SHA1 Message Date
aj 4954bdf515 ci: run cross-platform suites on pull requests and master 2026-10-01 09:55:16 -04:00
aj 591d08c627 docs: make GitHub the primary repository
GitHub (ajisaacs/OpenNest) now owns branches, tags and pull requests.
Gitea keeps a read-only pull mirror that syncs hourly and refuses pushes,
so PRs merged on GitHub are no longer closed by the old Gitea push mirror.
2026-10-01 08:34:01 -04:00
aj 6b6928213d fix(packing): apply work-area slack only at plate boundaries 2026-10-01 08:15:57 -04:00
aj a26e663f01 feat(geometry): find maximal rectangles inside any region
MaximalRectangles.InRegion finds the largest axis-aligned rectangles that fit
wholly inside a Clipper region, such as a cutout shrunk by the part spacing.
It grids the region on its vertex coordinates plus even divisions, keeps a cell
only when no edge enters it and an even-odd row scan puts it inside, then runs
the shared histogram search.

Exact for regions with only horizontal and vertical edges; slanted and curved
edges are followed as a staircase that never crosses the boundary, short of the
true maximum by up to about one cell per side. Tests cover a rectangle, an
L shape, a frame with a hole, a round hole (inscribed square), a diamond and a
star; disabling the edge-crossing check fails the three slanted-edge tests.
2026-10-01 07:29:05 -04:00
aj 694d4b28cc refactor(geometry): move maximal-rectangle search out of RemnantFinder
The histogram search over a free/blocked cell grid never depended on the
obstacles being boxes. Move it to OpenNest.Core as MaximalRectangles.FromGrid
so other callers can build their own grids; RemnantFinder keeps building its
obstacle grid and calls it. No behavior change.
2026-10-01 07:28:13 -04:00
aj d14071692b feat(geometry): seed drawing alignment with robust contour ICP
Add DrawingAligner: bounded multi-start rigid ICP that maps a revised
drawing's geometry into the old drawing-local frame as an operator-review
seed. Material-filtered programs flatten through the shared chord-error
machinery, resample via the shared ContourSampler ring scheduler, and fit
closest bounded target segments with trimmed weighted least squares
(reflection never solved, never applied). Seeds come from outer-centroid
translation and MBR angle deltas with the 90-degree family plus identity.
The stable outer boundary is fitted first; retained hole evidence only
reorders candidates inside the outer tie band, so a moved hole cannot
drag the perimeter. Structured AlignmentResult reports convergence,
residual quantiles, bidirectional coverage, symmetry ambiguity,
reflection uncertainty, and a bounded diagnostic IoU. Refusals
(invalid input, insufficient support, unsupported topology, sample
budget, cancellation) are reported as reasons, not thrown.

Tests verify recovered poses against exact fixture geometry (true
segments/arcs/circles, not sample clouds) for known transforms,
symmetric rectangle/circle ambiguity, mirror non-selection, moved-hole
perimeter preservation, and bounded region arithmetic (identical=1,
disjoint=0).
2026-10-01 04:57:24 -04:00
aj a368ef0115 refactor(geometry): share cut-direction sampling primitives
Extract the pure world-space position/tangent math from the WinForms
cut-direction arrow renderer into Core (ContourSampler), keeping screen
conversion, arrowheads, and the per-move display policy in the view.
Add a contour-wide arclength scheduler (RingMoves) for measurement use:
distance carries across segment boundaries, short segments are never
omitted, and the closing vertex is not duplicated. Characterization
tests pin the arrow policy (counts, short-move skipping, CW/CCW and
full-circle sweeps, subprogram offsets, suppressed/rapid moves,
incremental mode) and the scheduler's start-vertex invariance.
2026-10-01 01:54:00 -04:00
aj 62fd42346e fix(irregular): prevent overlaps from false NFP gaps 2026-10-01 01:05:07 -04:00
aj 56ab3a5f30 style(irregular): sort NoFitCache imports 2026-10-01 01:04:56 -04:00
aj 7a52058b0f fix(ui): refresh plate list when parts are added or removed
The plates ListView showed stale part-count and utilization columns
after interactive placement, clone, or fill, because only the drawing
list was refreshed on the debounced PartAdded/PartRemoved timer. Also
refresh the plate rows when a drawing is deleted from every plate.
2026-09-30 23:16:07 -04:00
aj 7ce53008f1 fix(rectangles): place parts within the layout check's work-area slack
Parts drawn a few millionths over their sheet's work area (for example a
36.125006 panel on a 36.125 sheet with no edge spacing) pass NestLayoutCheck,
which allows Tolerance.Epsilon of overhang, but the Rectangles engine refused
them and left them unplaced. The engine now allows 90% of that slack: a box
that exceeds the sheet by no more than the allowance packs as exactly the
sheet's size, so it spans the axis and the overhang lands only past the edge.

The check's bounds slack is named NestTolerances.WorkAreaSlack (same value,
no behavior change) so the engine and the test layout assertion share it.

Rectangle-lane benchmark (91 strict + 77 boxable jobs): every layout valid;
4 jobs that left panels unplaced now place them (strict complete 86 -> 88,
boxable complete 74 -> 75, one more boxable job places 2 more parts); no other
job changed.
2026-09-30 22:58:41 -04:00
aj 4b6e131cec style(tests): apply dotnet format to RectanglesNestingEngineTests 2026-09-30 22:58:41 -04:00
aj 2e700df8ed feat(fill): pack leftover parts with maximal rectangles
DefaultPlateFiller.PackArea (Default, Strip and both Remnant strategies)
now packs leftover parts with the shared maximal-rectangles packer instead
of bottom-left corner points. Every fit rule and pick mode is tried; the
layout placing the most parts, then box area, per priority tier wins, and
the strategy's own fill comparer breaks ties so remnant strategies keep
their clear side. PackBottomLeft and PackEngine are removed.

Rectangle-lane benchmark (91 strict + 77 boxable jobs) against the old
packer: no strategy lost a valid layout; Default, Vertical Remnant,
Horizontal Remnant and Strip complete 2-3 more strict jobs, and Strip
gains a valid one. Cost on jobs complete in both runs falls for every
strategy (Default -0.6% strict, -2.7% boxable). Default is about 7-10%
slower on the changed jobs.

Golden layouts for Default and both Remnant strategies are re-captured;
each is complete, passes NestLayoutCheck and repeats exactly.
2026-09-30 22:19:25 -04:00
aj c6f8a06dc7 refactor(packing): share the maximal-rectangles packing loop
Move the Rectangles engine's global and ordered pick loops into
RectanglePacking.MaxRectsPacker, which works on plain box sizes so other
fill paths can reuse it. SheetPacker maps part orientations onto it.
No behavior change: rectangle-lane benchmark results are identical job for
job (91 strict and 77 boxable jobs).
2026-09-30 22:11:12 -04:00
aj 96bcf6ded1 refactor(packing): move maximal-rectangles sheet into shared rectangle packing
MaxRectsSheet only tracks free rectangles and has no job dependencies, so it
moves from the Rectangles engine into OpenNest.Engine.RectanglePacking where
the interactive fill packer can use it. No behavior change.
2026-09-30 22:11:12 -04:00
aj e9c312b9fb refactor(packing): remove unused rectangle fill and pack engines
FillNoRotation, FillSameRotation and FirstFitDecreasing had no callers
in production code or tests.
2026-09-30 22:11:12 -04:00
aj a854e34a3d test(geometry): pin near-zero-sweep arc bounds without a local DXF
DxfImport_ArcBoundingBoxes_Diagnostic read a drawing from one user's
desktop, returned early elsewhere and asserted nothing, so the near-zero
sweep bounding-box fix (4053f1f) had no regression test that could fail.

Replace it with ArcBoundingBoxTests: a forward and a reversed arc with a
sweep below Tolerance.Epsilon must be bounded by its endpoints, and an
ordinary arc crossing 90 degrees must still reach the top of its circle.
With the 4053f1f guard reverted, both near-zero cases fail (the box grows
to the full circle); with it in place all three pass.
2026-09-30 22:02:46 -04:00
aj 3b9f5ca5fa test: remove customer drawing names from tests
GeometrySimplifierTests hardcoded a customer DXF on one user's desktop and
returned early when it was missing, so the test passed without running
anywhere else. Read the path from the optional "SimplifierGapDxfPath"
entry in test-config.json and report a skip when it is not configured,
matching the other external-fixture tests.

Replace the customer part name in a RemnantFinderTests comment with a
neutral description. Customer drawings stay outside the repository.
2026-09-30 21:46:29 -04:00
aj 0209a24008 style(tests): apply dotnet format to RemnantFinderTests 2026-09-30 21:45:12 -04:00
aj 8450ceee40 refactor(geometry): share signed-angle accumulation through ArcFit
EllipseConverter, SplineConverter and GeometrySimplifier each carried an
identical private SumSignedAngles. Move the unchanged body to
ArcFit.SumSignedAngles and call it from all three, keeping the ordered
accumulation, strict half-turn comparisons and empty/single-point result.

ArcFitTests compares the shared method bit-for-bit with a separate
test-local accumulator across half turns, the atan2 seam, multiple
turns, translated centres and NaN inputs, and checks that inputs are not
mutated. The converter winding characterization from 8664656 still
passes unchanged.
2026-09-30 21:20:58 -04:00
aj c55e375cf3 refactor(engine): remove legacy multi-plate auto-nest orchestration
Desktop Auto Nest, Console --autonest, MCP autonest_plate and the API
NestRunner all run through NestPipeline, so nothing calls the old
orchestration any more.

Delete MultiPlateNester (with MultiPlateNestOptions, MultiPlateResult,
PlateResult, PartClass and PartSortOrder), PlateOptimizer and
PlateOptimizerResult, plus their tests. The explicit-strategy contract
those tests checked now lives at PlateFillService.ResolveStrategy, which
keeps its null-means-Default, canonical-name and unknown-name tests.
CreateFiller loses its internal visibility, which only the deleted
orchestrators used.

This breaks source and binary compatibility for external callers of the
removed types; whole-job callers use NestPipeline.Run or INestingEngine.
2026-09-30 21:07:32 -04:00
aj 5e9eaf8a4c perf(fill): reuse validation for an unchanged row
Skip FillGrid's Step 2 overlap check only when Step 1 checked this exact
row clean and the perpendicular tiling appended zero parts, so gridResult
still holds the same Part objects in the same order and poses. The local
rowIsVerified flag is cleared by every Step 1 skip or bbox fallback, and
any nonzero append (including partial-copy parts) keeps the eager check.

Debug work assertions pin the reduced overlap work (horizontal stripe
8->4 exact calls, 4->2 preparations, 16->8 triangulations; vertical
stripe 36->18, 4->2, 38->19) and keep single-seed, Horizontal full-grid,
partial-only and PerpOnly work at base; the invalid overlapping-seed
control pins both fallback stages plus the retained exact-call total.
Three mutants (forced eager check, dropped flag, complete-rows-only)
are caught by their designated controls. Serial whole-job layout is
byte-identical to the frozen 62b5a8d0 oracle before and after.
2026-09-30 17:01:11 -04:00
aj dbcf7dea65 refactor(geometry): share directional part-line preparation 2026-09-30 13:40:22 -04:00
aj 98870e78a7 refactor(cincinnati): share hole subprogram call output 2026-09-30 13:40:22 -04:00
aj c9bb32470c refactor(geometry): share ray and contact-angle primitives 2026-09-30 13:40:22 -04:00
aj e84d7f0e72 fix(ui): traverse nested active controls when restoring focus 2026-09-30 13:40:22 -04:00
aj 6426a98f82 fix(geometry): identify invalid ellipse axes in exceptions 2026-09-30 13:16:00 -04:00
aj 23ffc14f3f fix(controls): dispose owned separator pens 2026-09-30 13:16:00 -04:00
aj b2deed6c5a test(bestfit): observe posted async failures during dispatcher drainage 2026-09-30 13:16:00 -04:00
aj 1cc95c8115 fix(bestfit): own async viewer operations and report failures 2026-09-30 13:16:00 -04:00
aj 038886a757 fix(mcp): bound engine harness execution and output drainage 2026-09-30 13:16:00 -04:00
aj 8664656658 test(geometry): characterize converter winding before ArcFit extraction 2026-09-30 08:57:34 -04:00
aj d7fd2f8468 style(geometry): normalize diagnostic interpolation alignment 2026-09-30 08:56:14 -04:00
aj 5bf3c5f9aa docs(packing): clarify PackingId is caller-supplied and clone-preserved 2026-09-30 07:40:54 -04:00
aj 984d31a391 fix(packing): preserve item type and separate packing identity in clones 2026-09-30 07:24:39 -04:00
aj 4f46fa15ea style(packing): normalize circle-packing file encoding 2026-09-30 07:24:02 -04:00
aj 38324628b0 test(editor): prove per-operation highlighting with a status sentinel
The reorder event snapshot could previously show the reverse pass's
stale timeout label. Set a sentinel before each click so the
ProgramChanged handler can only observe the label applied by the
current operation's highlighting pass.
2026-09-30 01:39:26 -04:00
aj 132bb07bc9 test(editor): make control assertions handle-state independent and order-proving
Quality review of 50884da found the unshown RichTextBox oracle compared
cached CRLF text against the highlighted editor's native LF-normalized
text, which would fail on first Windows execution. Compare generated
text with normalized line endings on both sides instead, capture the
status label and preview state inside the ProgramChanged handler to
prove fallback-before-notify ordering, document the HighlightSpan
UTF-16 and rule-index contract, and pin the null-text argument check.
2026-09-30 01:29:11 -04:00
aj 50884daca1 fix(editor): bound highlighting without interrupting program updates 2026-09-30 00:14:43 -04:00
aj fc53c7732e fix(ui): format stock sizes invariantly and bound validation notices
Address Task 3b quality review:
- FormatSize now emits invariant decimal notation so a comma-decimal
  locale cannot produce stock text its own invariant parser rejects.
- Validation notices embed a bounded single-line preview instead of the
  full cell value.
- Documented GetPlateOptions' all-or-nothing throwing contract.
- STA test helpers run the worker as a background thread with a
  diagnostic timeout message; added a de-DE fractional stock round-trip
  regression.
2026-09-29 23:29:20 -04:00
aj ab1f3a28d1 fix(import): abort failed CAD text extraction 2026-09-29 22:31:26 -04:00
aj 43ebd61fdf fix(ui): reject timed-out stock and shape input 2026-09-29 22:27:05 -04:00
aj bf608fa271 test(math): name fraction culture coverage accurately 2026-09-29 22:02:32 -04:00
aj fa5e5a8e29 fix(io): bound bend-note regex execution without partial imports 2026-09-29 21:41:27 -04:00
aj 388816b015 fix(math): bound fraction regex execution 2026-09-29 21:41:20 -04:00
aj 3bdefb1d1c docs(engines): document built-in engines, renames and change rules
Adds docs/nesting-engines.md: which engine suits which jobs, the old plug-in
names the registry maps, retired engines, and the rule that an engine change
lands only when it beats that engine's current benchmark result with every
layout valid. README, release and automatic-nesting docs drop the bundled
plug-in wording; AGENTS.md now places built-in engines in
OpenNest.Engine/NestingEngines and keeps only external plug-ins out of the
solution.
2026-09-29 21:19:27 -04:00
aj 7f648e6400 build(release): package built-in engines instead of external plug-ins
Rectangles and Irregular now ship inside OpenNest.Engine.dll, so the Windows
package no longer fetches, tests and bundles OpenNest-Engines at a pinned
commit. Removes scripts/external-engines.json and the Engines/ folder,
manifest and license from the package; build-info.json drops enginesCommit.

ReleaseSmoke now checks the packaged registry: every built-in engine must
instantiate from the packaged OpenNest.Engine.dll, the Opus55NestingEngine
name must resolve to Irregular, and an unknown name must be rejected. Linux
check against a published OpenNest.Engine: pass case exit 0; missing engine
DLL exit 1. Full Windows packaging still needs the Windows runner.
2026-09-29 21:18:53 -04:00
aj b688ce896f feat(engine): add built-in Rectangles and Irregular nesting engines
Moves the two production plug-in engines into OpenNest.Engine under names
that describe the jobs they suit:

- Rectangles: plain and near-rectangular plates, maximal-rectangles box
  packing (was the RectanglesNestingEngine plug-in)
- Irregular: irregular profiles, no-fit-polygon frontier packing (was the
  Opus55NestingEngine plug-in)

Their tests and the shared engine contract/layout test kit move into
OpenNest.Engine.Tests/NestingEngines.

The registry maps the old plug-in names to the new engines, so saved desktop
selections, scripts and API requests keep working, and a leftover plug-in DLL
under an old name cannot shadow its replacement. Desktop startup passes the
registry's lookup when restoring the saved Auto Nest engine.
2026-09-29 21:18:37 -04:00
aj 1eb509740e test(export): assert drawing reference identity and dispose form on setup failure 2026-09-29 20:40:03 -04:00
aj ad36101b7f fix(export): report write failures and return truthful outcomes 2026-09-29 20:16:49 -04:00
aj 00ede7c5d0 fix(math): normalize degree interval checks in degrees 2026-09-29 19:27:23 -04:00
aj 314ca2f2a6 feat(desktop): add File -> Export Nest Report command
Capture the report target and reject whole-job nesting, open progress
windows, interactive fill and busy plate actions across every view
sharing the nest; revalidate after the save dialog, capture the snapshot
synchronously on the UI thread and render it through OpenNest.Reporting.
Windows adapter tests cover enablement, guards, cancel, success and a
write failure against an existing destination (compile-only on Linux;
Windows runtime acceptance still owed).
2026-09-29 18:29:05 -04:00
aj 64045be968 feat(ui): wire file and database nest storage 2026-09-29 16:21:17 -04:00
aj 437d659c9d feat(server): add OpenNest.Server SQLite nest storage API
SQLite-backed (Microsoft.Data.Sqlite, WAL) minimal API storing NestRecord
metadata plus the .nest archive as a BLOB. Endpoints: GET/POST /api/nests,
GET /api/nests/{id}[/file], PUT /api/nests/{id}/file, PUT
/api/nests/{id}/metadata, DELETE /api/nests/{id}, GET /healthz. Multipart
upload contract matches RemoteNestRepository (metadata JSON part + file
part). Added to OpenNest.sln, builds standalone on Linux. Dockerfile
publishes to a runtime image listening on :8090 with a /app/data volume
for the SQLite file. docs/nest-storage.md documents the wire contract,
endpoints and deployment.

Full curl round trip verified manually against a running instance:
upload (server-assigned id + computed fileSize), list, get, byte-exact
file download, metadata-only update (archive unchanged), file update
(new archive persisted), 404s for unknown ids, delete, post-delete 404.
2026-09-29 16:21:17 -04:00
aj 10ed3d3a30 feat(data): storage-mode settings and remote nest repository
NestStorageSettings persists the file/database toggle and server URL at
%APPDATA%\OpenNest\storage.json following the EngineSelectionSettings
never-throwing pattern; missing config means File mode. NestRecord carries
the shareable job metadata, INestRepository defines the backend contract,
and RemoteNestRepository implements it against the nest server with
multipart uploads (metadata JSON part + .nest file part).
2026-09-29 16:21:17 -04:00
aj ef2f9aa714 feat(core,io): persist nest status and made-by in nest files
Add NestStatus (Quote/ToBeCut/HasBeenCut) plus MadeBy on Nest, written as
additive camelCase nest.json fields with PascalCase enum strings matching
the units convention. Legacy files and unknown status values fall back to
Quote. The nest info dialog gains a Status dropdown and Made By box.
2026-09-29 16:21:17 -04:00
aj b15c4cf6c6 feat(reporting): add multi-plate pagination and dense-label fallback
Slice 2 of the nest report plan: pagination, overflow and dense-label
coverage on top of the Slice 1 one-plate library.

- NestPdfWriter: general multi-plate pagination. Summary/plate tables
  continue across pages with repeated (HeadingFormat) header rows; notes
  flow as an ordinary paragraph instead of a bounded cell; page headers
  are wrapped and sized into the top margin; drawing areas are located
  per page via DocumentRenderer.GetRenderInfoFromPage.
- ReportText: lossless pre-wrapping (MigraDoc clips an over-tall row and
  lets an unbroken token overflow a narrow cell without warning), capped
  table-cell line counts, plate-range compression ("1-2, 4"), and
  A/B/.../AA map-grid row names.
- NestReportDiagram: label placement centers each part ID on its
  PolyLabel pole (matching PlateView's LayoutPart), computed on a
  placement-independent quantized copy so identical parts share one
  label position. Parts whose ID cannot fit legibly at overview scale
  get a lettered/numbered map grid and a zoomed, framed detail page per
  crowded cell; the writer fails with plate/part/ID when even that
  cannot place a label, or a plate would need more than 24 detail views.
- Tests: NestPdfLayoutTests (multi-plate totals/ranges/same-named
  references, table continuation with no lost rows, long name/notes
  wrapping without column overflow, mm units in all four quadrants,
  dense-label detail views with hole avoidance, save/reload of the
  tabbed/lead-in fixture with stale tab flags, invalid later-plate data,
  late write failures against an existing destination, source
  unchanged on success/failure), ReportPdf test helper (page/word/
  content-stream extraction).
- docs/nest-reports.md: replace the Slice 1 one-plate limits section
  with the general pagination/dense-label contract and the process-wide
  PDFsharp/MigraDoc render lock.

Verification: Reporting filter 70/70; full OpenNest.Tests 2524
passed/21 skipped/0 failed; Engine 351/351; IO 77/77;
EnableWindowsTargeting=true full-solution build 0 errors; scoped
dotnet format --verify-no-changes exit 0. Preview PDFs rendered and
visually inspected (evidence: /home/aj/extracted/2026-09-29/opennest-report-slice2/).
Windows runtime, the desktop adapter and packaged-app font deployment
remain unverified (Slice 3).
2026-09-29 15:43:00 -04:00
aj e4d64a88eb fix(reporting): serialize concurrent nest report exports
PDFsharp/MigraDoc layout and font state is process-wide. Parallel exports
of the same snapshot laid text out differently (for example merged words
like "Total physicalsheets" and shifted table columns). Serialize the
whole render-and-save in NestPdfWriter.Write; reports are rare.

Regression test exports one snapshot 128 times in parallel and compares
page content streams with a sequential export; it failed with dozens of
mismatches per run before the lock.
2026-09-29 14:04:53 -04:00
aj 74d2b6aa68 feat(reporting): add one-plate nest report PDF library slice
Add OpenNest.Reporting (net8.0, PDFsharp-MigraDoc 6.2.4) with a detached
report snapshot and a PDF writer for an empty/demand-only job or a single
plate layout: Letter portrait summary with vector part thumbnails and a
landscape plate page with a vector sheet diagram, both with Page X of Y.

- Snapshot uses reference identity for document-local R### IDs, recounts
  nested quantities from placements x copies with checked wide integers,
  excludes cutoffs from accounting, and retains no live domain objects.
- Geometry keeps Cut/Display material paths, drops rapid/scribe/lead paths,
  preserves native arcs, holes and intentional tab gaps, and rejects
  malformed programs with plate/part identification.
- Open contours and cutoffs are stroked per contour so PDFsharp cannot join
  a tab gap to the next contour.
- Bundled DejaVu Sans via a custom resolver (including MigraDoc's error
  font); unsupported text fails with the field and code point.
- Layouts beyond this slice (multiple plates, page overflow, labels that
  do not fit) fail with NotSupportedException before the destination is
  replaced; output is rendered to a temporary sibling and moved atomically.
2026-09-29 13:13:32 -04:00
aj 64d0e50df3 fix(ui): keep an add-new row in Auto Nest stock options 2026-09-29 12:43:55 -04:00
aj 7d179c696a fix(sequencing): snapshot plates before sequencing the entire nest 2026-09-29 12:23:19 -04:00
aj 662bc133b1 refactor(nesting): unify console MCP and API validation and commit policies 2026-09-29 11:15:05 -04:00
aj b3a4390169 refactor(ui): route every Auto Nest engine through the validated pipeline 2026-09-29 10:56:38 -04:00
aj b8bded1b09 fix(engine): reject unrepresentable nesting output before geometry checks 2026-09-29 10:47:36 -04:00
aj ac901f7ee9 feat(ui): remember the selected Auto Nest engine 2026-09-29 10:43:36 -04:00
aj 43253cd9ab feat(engine): share stock construction across nesting callers 2026-09-29 10:33:16 -04:00
aj 7aeb636ca2 fix(engine): discard cancelled pipeline results from uncooperative engines 2026-09-29 10:31:23 -04:00
aj c914d2bbd9 Add NestPipeline: one validated engine path for all front ends
build NestJob -> resolve engine by name -> Solve -> independent
NestLayoutCheck validation -> bind placements to caller drawings.
The pipeline never commits to plates and never mutates caller items;
validation failures are returned as messages naming real drawings
(unknown-requirement placements are reported, not dropped). Console,
MCP, API and desktop Auto Nest will adopt this path in later phases.

- NestPipeline.Run(request): registry-resolved engine, unknown names
  list the registered engines; cancellation propagates untouched.
- NestPipeline.Run(engine, ...): stub/plug-in engines take the same path.
- NestResultBinder: pose semantics identical to NestResultMaterializer.
- NestLayoutCheck: Violations overload with per-requirement display names.

Tests: overlap stub -> violations w/ drawing names, no throw; ghost
placement -> violation + excluded from further checks; unknown engine
-> NotSupportedException listing engines; cancelled token -> no result;
happy path -> bound by reference, caller quantity untouched.
2026-09-29 10:23:19 -04:00
aj 481c3e5128 docs: trim agent instructions and remove historical performance report 2026-09-29 10:18:50 -04:00
aj 29953ea601 feat(overlap): auto-recheck the active plate after layout edits settle
Nest windows now rerun the overlap check once the layout has been
unchanged for 0.5 s, instead of leaving 'Overlaps: not checked'. Edits
show 'Overlaps: check pending...'; drags are caught by the paint-time
pose stamp, collection edits by their events. The check waits while a
mouse button, modal dialog or fill is active, supersedes a running check
when the layout moves again, and does not retry a canceled or failed
layout until it changes. Rechecks use the incremental analyzer, so only
the moved parts' neighbors are recomputed.

Automatic results update only the canvas label (the status bar keeps the
last command's message) and keep Display > Off. Check Active Plate still
runs immediately. InvalidateOverlapCheck now also drops cached material.
2026-09-29 09:31:51 -04:00
aj 99c31748ba feat(diagnostics): cache overlap material and recheck incrementally
PlateOverlapAnalyzer.Capture(parts, OverlapMaterialCache) reuses each clean
program's converted entities and prepared material across requests, and
Analyze(snapshot, previous) reuses pair results whose two parts kept the
same source, exact pose and relative order, renumbering them. A recheck
after moving one part only clips that part's neighbors again.

On 501 real PEP plates: full check median 1 ms / max 6.4 s; incremental
recheck after one move median 0.1 ms / max 38 ms. Incremental results
matched uncached full analysis exactly across 2505 edits.
2026-09-29 09:31:26 -04:00
aj 2b8a3ce7db fix(leadin): add close button to Place Lead-in side panel
The docked side panel could only be closed with Escape, which was
unreliable: MainForm consumes Escape before ActionLeadIn's KeyDown
handler, so with a part selected ActionManager called the action's
empty CancelAction and nothing happened; with nothing selected the
panel closed but was stored as the previous action, so the next
Escape reopened it.

- EditNestForm side panel gets a header with the panel title and an
  always-visible close button that ends the action.
- ActionLeadIn.CancelAction now steps back (unlock contour, then
  deselect part), replacing the dead KeyDown handler.
- Actions can opt out of Escape-resume (ResumeOnEscape); the lead-in
  action does, so a closed panel stays closed.
2026-09-29 08:38:48 -04:00
aj 36de55f10f style(actions): remove BOM from Action.cs per .editorconfig 2026-09-29 08:38:41 -04:00
aj 55fe0ef228 fix(cnc): extend first-cut edge for outside corner lead-ins
A straight lead-in at a convex outside-perimeter corner now runs along the
extension of the edge cut first, so the torch enters on that line and keeps
cutting it. The result no longer depends on which of the corner's two edges
auto-assign or the manual cursor picked, which made placement flip between
straight and 90 degrees. The approach angle is ignored at such corners.

The straight lead falls back to the first-cut edge normal when its pierce
would be closer than PierceClearance to the contour (very flat or tessellated
corners). Reflex perimeter corners bisect the notch. Line lead-outs run on
straight past a convex corner along the last-cut edge, except on tabbed
perimeters. Program generation and the Place Lead-in preview share
ResolveLeadIn/ResolveLeadOut.
2026-09-29 07:46:20 -04:00
aj 8720580004 fix(sequencing): cut scrap cutoffs before crossed parts 2026-09-29 00:05:28 -04:00
aj c3dd346b7a feat(cutoffs): apply nest-wide with minimum retained tail 2026-09-28 23:43:06 -04:00
aj 4afab63046 fix(cnc): advance rapid display through cutoff cutting moves 2026-09-28 23:19:08 -04:00
aj 2cc06d1dc7 fix(cutoffs): exclude leads from material perimeter chaining 2026-09-28 23:08:47 -04:00
aj 45d2dfced4 feat(posting): require verification and explicit risk acknowledgment 2026-09-28 22:50:03 -04:00
aj a5fddec0e9 fix(cutoffs): account for bounds translation roundoff safely 2026-09-28 22:19:02 -04:00
aj a8204bb51f feat(plateview): annotate overlap centroids 2026-09-28 22:17:49 -04:00
aj e4d07121c8 feat(plateview): show material overlap areas 2026-09-28 21:35:05 -04:00
aj 76044f6bd9 merge: integrate polygon overlap diagnostics 2026-09-28 20:56:46 -04:00
aj 1581260e31 feat(cutoffs): add automatic skeleton cutoffs with tail preservation 2026-09-28 20:50:53 -04:00
aj 19c3a89c32 feat(core): report hole-aware part overlap polygons 2026-09-28 20:44:51 -04:00
aj 4dbdc2678d fix(cutoffs): preserve clearance at ambiguous vertex intersections 2026-09-28 20:37:49 -04:00
aj fb32d508f8 fix(geometry): stabilize triangulation winding at large coordinates 2026-09-28 20:37:38 -04:00
aj 1f637ca43c merge: integrate cutout corner lead-in bisectors 2026-09-28 19:40:02 -04:00
aj 648b0eaca5 fix(cnc): bisect inside cutout corners for straight lead-ins 2026-09-28 19:25:40 -04:00
aj b254a0bd3e feat(io): persist part cutting programs and plate parameters 2026-09-28 19:17:59 -04:00
aj 74302761ce fix(io): restore hole sub-programs with negative IDs 2026-09-28 19:05:11 -04:00
aj 4da48eed68 fix(core): keep cut-offs in their cut sequence through regenerate and save
A cut-off's place in Plate.Parts is its cut sequence number, but
RegenerateCutOffs removed every cut-off part and appended it again, so
any part drag, fill or cut-off move sent the cut-offs to the end. The
nest file didn't store the position either, so reopening did the same.

RegenerateCutOffs now puts each cut-off back at its previous index (new
cut-offs go at the end), and CutOffDto.Sequence saves the index. Older
files without it load the cut-offs at the end, as before.
2026-09-28 18:44:04 -04:00
aj 6803d7519f fix(posts): post CI Fiber cut-offs in their sequence, not last
b0997f6 moved every cut-off after the parts. Plate.Parts order is the
cut sequence, and the user sets each cut-off's place in it (Set
Sequence), so the post must follow it rather than reorder. Cut-offs
still post as uncompensated open lines with no lead-in.
2026-09-28 18:41:06 -04:00
aj 86dbd8819b style(core): apply dotnet format to Plate.cs
Formatter-only: drops the UTF-8 BOM (.editorconfig charset = utf-8).
No behavior change.
2026-09-28 18:40:50 -04:00
aj 20395eed9b fix(core): keep lead-in state and rotation through part copy and rotate
Part.Clone and CloneAtOffset copied the lead-in program but not
HasManualLeadIns, LeadInsLocked, CuttingParameters or the tracked
pre-lead-in rotation. A copy of a rotated lead-in part reported
rotation 0 (the rebuilt program's), so saving it wrote the wrong
rotation and Remove Lead-ins restored an unrotated part.

Part.Rotate on a lead-in part set the tracked rotation from the
lead-in program's own Rotation, which starts at zero when the cutting
strategy rebuilds it, so a further quarter turn left Rotation unchanged.
Lead-in parts now accumulate the applied angle instead.
2026-09-28 18:22:36 -04:00
aj a886735040 fix(cnc): keep hole sub-programs private to each program copy
Program.Clone deep-copied the SubPrograms dictionary but left every
SubProgramCall pointing at the source's sub-program, and
SubProgramCall.Clone went through the Rotation setter, which re-rotated
that shared program to the call's stale angle. Copying a program with
hole lead-ins therefore rotated the source's holes, and rotating the
copy rotated the source again.

Program.Rotate also rotated a shared sub-program once per call, so two
identical holes (one deduplicated sub-program) turned twice.

Clone now binds calls to one private copy per shared sub-program
without re-aligning it, and Rotate turns each distinct sub-program once.
2026-09-28 18:22:36 -04:00
aj 75d41f3bb7 style(cnc): apply dotnet format to Program and SubProgramCall
Formatter-only: re-indents braced switch sections in Program.cs and drops
the UTF-8 BOM from SubProgramCall.cs (.editorconfig charset = utf-8).
No behavior change.
2026-09-28 18:22:36 -04:00
aj 26252b333a docs(posts): record M50 as the confirmed CI Fiber pallet change
The machine owner confirmed M50 swaps pallets (the sample's M50 before
M30 moves the cut sheet out for unloading). Only the single-program
between-sheet sequence remains unconfirmed.
2026-09-28 18:22:36 -04:00
aj c219ec4e66 feat(posts): CI Fiber one program per sheet, configurable pallet change
Multi-sheet nests previously went into one program with a single header
size and one M50 at the end, so sheet 2 would cut into sheet 1's
skeleton. New Sheets settings:

- One program per sheet (default on): JOB.cnc -> JOB-1.cnc, JOB-2.cnc,
  each a full program with its own size and pallet change (CL-series
  batch rule, EM-423 7.4). Single-sheet nests keep the chosen name.
- Off: one program, with /L "L0" + pallet change between sheets;
  mixed sheet sizes are rejected.
- Pallet change code (default M50, unconfirmed for multi-sheet CI Fiber
  runs; documented as a release blocker).

All sheets are validated and rendered before any file is written.
IMultiFilePostProcessor lets the desktop app confirm overwrites of every
target file and list what was saved, and the console print each file.
2026-09-28 18:22:36 -04:00
aj 32586149c6 refactor(posts): remove CI Fiber part comment override
A fixed override wrote the same text on every part, making parts
indistinguishable in the program. Each part's ( PART:... ) comment now
always uses its source file or drawing name. Saved configs that still
contain PartComment load normally; the key is ignored.
2026-09-28 18:21:21 -04:00
aj 9ec28add80 fix(posts): keep CI Fiber material codes case-insensitive after reload
System.Text.Json rebuilds MaterialCodes with the default ordinal comparer,
so a saved config matched 'Mild Steel' but not 'mild steel' and silently
fell back to the default code. The setter now re-keys assigned maps
case-insensitively.
2026-09-28 18:21:07 -04:00
aj 35aa64fe4e feat(posts): sectioned settings editor for CI Fiber post
Replace the generic PropertyGrid for configs that opt in via
PostSettingAttribute: a section list (Machine, Material, Program output,
Macros) with labelled fields, help text, numeric ranges and an editable
material-code table. Edits apply only when OK validates every field.
Unannotated configs (Cincinnati CL, GravographIS) keep the PropertyGrid.
2026-09-28 18:21:07 -04:00
ajandClaude Opus 5.5 c33337cea2 fix(cnc): stop double-counting first incremental rapid in rapid display
RapidEnumerator primed the walk position at the first pierce point, then
the skipped first rapid advanced it again. Raw programs start with a zero
rapid so this was invisible, but lead-in programs start with a real
incremental offset to the pierce, which shifted every later rapid by that
delta and drew rapids off the sheet. Start the walk at the program origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:21:37 -04:00
ajandClaude Opus 5.5 b0997f614b fix(post): post sheet cut-offs in CI Fiber without comp or lead-in
Posting a nest with a sheet cut-off threw because the CI Fiber writer
treated every contour as a compensated part contour and requires a
linear lead-in after G41/G42 (TF5200 13.2.4.1). Cut-offs are open
centreline cuts with no lead-in and no inside/outside, so they now post
without G41/G42 and run after every part on the sheet so the sheet is
not severed before the parts are cut (matching the CL post).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:14:07 -04:00
aj 6d7c98ef4a docs: document defaults.json nest-defaults mechanism replacing .nstdot 2026-09-28 08:22:49 -04:00
aj f44c1d465f feat(ui): defaults.json-backed new nests; retire .nstdot templates
New nests now load plate defaults from %APPDATA%/OpenNest/defaults.json
instead of a .nstdot nest template:

- Tools > Nest Defaults... edits the file directly (new dialog).
- Tools > Save Current Plate as Defaults captures the active plate with
  no dialog (visible when a nest is open).
- New_Click no longer unzips a template; a corrupt defaults file warns
  once per session and falls back to built-in values.
- First run converts an existing NestTemplatePath .nstdot to
  defaults.json and clears the legacy setting (kept readable on a
  failed conversion); the setting itself stays, marked legacy.
- Save As no longer offers the .nstdot filter; SaveTemplate removed.
- BomImportForm and OptionsForm template plumbing removed in favor of
  the shared NestDefaults store.
2026-09-28 08:20:49 -04:00
aj bdb78a689d feat(defaults): JSON nest defaults store with per-field fallback
Replaces the .nstdot nest-template mechanism for new-nest plate
defaults. NestDefaults persists units, plate size, quadrant, part
spacing, and edge spacing to a single JSON file (DefaultPath:
%APPDATA%/OpenNest/defaults.json). Load never throws: a missing,
corrupt, or partially valid file degrades field-by-field to the
built-in fallback values, and unknown fields or a future version
number are ignored.
2026-09-28 07:09:52 -04:00
aj 3f89f8f5dd feat(ui): Tools > Expand Spacing for selected parts
Mirrors PushSelected: PlateView/SelectionManager.ExpandSelected runs
Expander.Expand on the selection, marks parts dirty, regenerates cut
offs, and reports achieved spacing plus blocked-pair count in the
status bar. Menu item joins Align Selected's enable surface.
2026-09-28 03:17:55 -04:00
aj 3cab90747a feat(expand): plateview spacing expander
Grows part-to-part spacing of a selected group with the work area and
non-selected parts as hard boundaries. Doubling + bisection search over
the target spacing; Gauss-Seidel straight-line relaxation with anchor
mover policy (later-index selected part moves, first selection never).
Overlapping input is separated along penetration MTVs instead of being
rejected. Cancel/failure never mutates part positions. Clearance gains
BoundaryDistance for ring-pair gaps (part-in-cutout legality).
2026-09-28 03:05:45 -04:00
aj fff3bef4e4 feat(geom): signed clearance kernel between polygons
Omnidirectional minimum distance with separating direction (positive)
and penetration depth with minimum-translation direction (negative),
for the PlateView spacing expander. Overlap verdict defers to
Collision.HasOverlap so kernels never disagree. Basis for the fixed-s
separation solver.
2026-09-27 23:36:26 -04:00
aj 2b78fb3a75 fix(geometry): resolve blocking contacts across directional slide paths 2026-09-27 23:24:31 -04:00
aj a04460c57b refactor(ui): drop duplicate plate preview from nesting progress dialog
The dialog rendered the same best-parts layout as transparent ghost
parts already shown live on the main plate view, and its embedded view
reset zoom on every improvement while lacking placed parts, work-area
and cut-off context. The progress dialog is now a compact stats strip
(266px wide) with the plate counter preserved for multi-plate runs.
Removes the never-called SetStationaryParts stationary-preview path
along with PreviewPlate/UpdatePreview/CreatePreviewPlate.
2026-09-27 20:43:34 -04:00
aj 27948ba8cf docs(posts): replace bundled manual with implementation references 2026-09-27 19:52:25 -04:00
322 changed files with 43449 additions and 6914 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Cross-platform tests
on:
pull_request:
push:
branches: [master]
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Run OpenNest.Tests
run: dotnet test OpenNest.Tests/OpenNest.Tests.csproj
- name: Run OpenNest.Engine.Tests
run: dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
- name: Run OpenNest.IO.Tests
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
- name: Run all test projects on Windows
run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests')) {
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
}
+9 -1
View File
@@ -207,13 +207,21 @@ FakesAssemblies/
*.db
*.db-journal
# Claude Code
# Local agent state and temporary planning/progress documents
.claude/
/.hermes/plans/
/.hermes/progress/
.superpowers/
docs/superpowers/
/docs/*-plan.md
/docs/*-progress.md
# Launch settings
**/Properties/launchSettings.json
# Local test config (contains user-specific paths to proprietary test assets)
OpenNest.Tests/test-config.json
# Vendor programming manuals: keep reference copies outside source control.
CINCINNATI LASER PROGRAMMING MANUAL.pdf
TF5200_programming_manual_en.pdf
+47 -142
View File
@@ -1,157 +1,62 @@
# AGENTS.md
# OpenNest agent instructions
This file contains shared repository instructions for coding agents working on OpenNest. It is the single source of truth; `CLAUDE.md` imports it for Claude Code compatibility.
Shared instructions; keep `CLAUDE.md` as the thin `@AGENTS.md` import.
OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform libraries.
## Project Overview
## Working rules
OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using NFP-based (No Fit Polygon) and rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines.
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
## Build
## Build and test
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
```sh
# Full solution: Windows
dotnet build OpenNest.sln
# Cross-platform suites: run independently on Linux/macOS/Windows
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
# Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
```
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, and post-processor libraries target `net8.0`. Post-processor projects live under `Posts/` (`Posts/OpenNest.Posts.<Name>/`, referencing `..\..\OpenNest.Core`); their 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.
Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
`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.
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
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`.
## Project map and boundaries
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).
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
### Windows release packaging
## Geometry and ownership safeguards
The GitHub `Windows release build` workflow runs on `release/vX.Y.Z` branches, `vX.Y.Z` tags, or manual dispatch. It builds with .NET 8 on `windows-2022`, runs all four test projects in Release plus the main Debug suite, and executes `scripts/Publish-Windows.ps1`. The script creates a self-contained win-x64 desktop ZIP with all three shipped posts plus pinned Gpt6Astra/Opus55/Qwen38FlashNext plug-ins from `scripts/external-engines.json`, runs their tests against this host, validates package contents/version and registry discovery (including a missing-DLL failure case), launches the extracted app, and writes a SHA-256 checksum. It refuses an existing output directory. Workflow artifacts are release candidates, not automatically published releases; follow [the release procedure](docs/releasing.md). Keep Gitea authoritative for Git refs.
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
- Clipper is for cached CPU region preparation, never per-pair hot loops. Preserve the hand-written `Collision` kernel's GPU-port contract. Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding. Do not loosen tolerances to hide failures.
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
### Fill performance verification
Read the relevant contract before changing its behavior:
See [fill verification](docs/performance/fill-verification.md) for opt-in measurements, targeted tests, Debug counter isolation, and predictor initialization rules. Keep training bitmaps by default; the angle builder checks predictor availability before scalar-only extraction.
## Architecture
Nine projects form a layered architecture:
### OpenNest.Core (class library)
Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) 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` (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).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
### OpenNest.Engine (class library, depends on Core)
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.
- **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.
- **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.
- **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.
- **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`.
- **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.
- **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.Engine.CirclePacking`): Alternative packing for circular parts.
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features; `Extract(drawing, includeBitmask: false)` skips the 32x32 training bitmap for inference while scalars stay identical; the default overload keeps it for training), `BruteForceRunner` (full angle sweep for training data).
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
### OpenNest.IO (class library, depends on Core)
File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp.
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
- `ProgramReader` — G-code text parser.
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
- `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)
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)
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
### 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.
### 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. `--progress` passes each solve a `JobProgressLog`, which writes `[job/engine]` lines for start, finish, every `PlateCommitted`, and `EvaluatingCandidate` throttled to one line per 2 s.
- `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)
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
- **Tools/InputTools**: `load_nest`, `import_dxf`, `create_drawing` (built-in shapes or G-code).
- **Tools/SetupTools**: `create_plate`, `clear_plate`.
- **Tools/NestingTools**: `fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`.
- **Tools/InspectionTools**: `get_plate_info`, `get_parts`, `check_overlaps`.
- `NestSession` — in-memory state across tool calls (current Nest, standalone plates/drawings).
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
The UI application with MDI interface.
- **Auto Nest engine routing**: when the selected engine is not a built-in fill strategy (`EngineSelection.IsFillStrategy` is false, i.e. StockLadder or an `Engines/` plug-in), `MainForm.RunJobEngineAsync` solves the whole job through `INestingEngine.Solve`. `JobEngineNest` builds the `NestJob` from the auto-nest items and either the plate options or the current plate, and it converts `NestJobProgress` for `NestProgressForm`: an engine's `LegacyProgress` passes through, and otherwise the stage and committed counts become the description. It then binds the result poses back onto the nest's own drawings. Whole-job engines throw on cancel, so the progress form hides Accept (`AllowAccept = false`) and Stop discards the run. Built-in strategies keep the existing per-plate fill path.
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. Plugin sources live in the repository's `Posts/` folder (the solution's `PostProcessors` folder).
## File Format
Nest files (`.nest`, ZIP-based) use v2 JSON format:
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
## Tool Preferences
Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary method for exploring and analyzing this codebase. It is faster and more efficient than file-based searches. Use it for finding symbols, references, diagnostics, type hierarchies, and code navigation. Only fall back to Glob/Grep when Roslyn Bridge cannot fulfill the query.
## Code Style
- Always use `var` instead of explicit types (e.g., `var parts = new List<Part>();` not `List<Part> parts = new List<Part>();`).
## Documentation Maintenance
Always keep `README.md` and `AGENTS.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change. Keep `CLAUDE.md` as a thin `@AGENTS.md` import rather than duplicating shared instructions.
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
## Key Patterns
- 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.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.
- 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.
- 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, inflated by the spacing less `NestTolerances.SpacingSlack` (0.0005), so a layout exactly at the spacing passes even after rotation and coordinate rounding leave it ~1e-4 short. `NestJobPlacementValidator` applies the same slack to its edge-distance check. `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. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Both `HasOverlappingParts` loops use one `PartOverlapChecker` per call (same keying; it also caches each part's triangles; parts and programs must not change while it is in use). 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`.
- **Native curve contact**: CPU best-fit and shared directional queries use `SpatialQuery.CurveTangencyDistance` for sum- and difference-radius tangency, checking both forward roots and both arc spans. It supplements vertex/line phases, not a complete collision/clearance validator. See [pair-spacing checks](docs/geometry/pair-spacing.md) for the regression and remaining limits.
- `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. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring.
- **Extents column pitch**: for finite valid geometry, finite pair height, and finite nonnegative spacing, `FillExtents.BuildColumn` uses `pair.Bbox.Width + partSpacing` directly. The old vertical slide calculation clamps to the same pitch, so it need not prepare boundaries or temporary test clones. Negative/nonfinite spacing or nonfinite pair height retains the legacy calculation: public/interactive callers do not all validate spacing. Do not remove `BuildPair` boundary preparation or the adjusted-column overlap fallback, or turn this shortcut into a geometry/validation policy change.
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). 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).
- [Nest file format](docs/nest-file-format.md)
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md)
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md)
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
+3
View File
@@ -16,6 +16,9 @@ public class NestRequest
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
public string PlacementStrategy { get; init; } = "Default";
/// <summary>Registered whole-job engine. Null uses PlacementStrategy (or legacy Strategy).</summary>
public string Engine { get; init; }
public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1;
+14 -2
View File
@@ -5,8 +5,8 @@ using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using OpenNest.IO;
using OpenNest.Engine.Jobs;
using OpenNest.IO;
namespace OpenNest.Api;
@@ -19,9 +19,12 @@ public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
public class NestResponse
{
public const int CurrentSchemaVersion = 2;
public const int CurrentSchemaVersion = 3;
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
@@ -35,6 +38,9 @@ public class NestResponse
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; }
/// <summary>Fulfillment status is not geometry acceptance. Review this status before using Nest.</summary>
public NestValidationStatus? ValidationStatus { get; init; }
public IReadOnlyList<string> Violations { get; init; } = [];
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
@@ -75,6 +81,8 @@ public class NestResponse
ElapsedTicks = Elapsed.Ticks,
Status = Status,
StopReason = StopReason,
ValidationStatus = ValidationStatus,
Violations = Violations is null ? [] : new List<string>(Violations),
Fulfillment = Fulfillment is null
? []
: new List<NestPartFulfillment>(Fulfillment),
@@ -158,6 +166,8 @@ public class NestResponse
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
Status = hasStatusMetadata ? archive.Status : null,
StopReason = hasStatusMetadata ? archive.StopReason : null,
ValidationStatus = archive.ValidationStatus,
Violations = archive.Violations ?? [],
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
@@ -175,6 +185,8 @@ public class NestResponse
public long ElapsedTicks { get; init; }
public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; }
public NestValidationStatus? ValidationStatus { get; init; }
public List<string> Violations { get; init; } = [];
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
public List<NestStockUsage> StockUsage { get; init; } = [];
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
+67 -39
View File
@@ -5,10 +5,9 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.IO;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.IO;
namespace OpenNest.Api;
@@ -32,7 +31,7 @@ public static class NestRunner
var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts);
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
var jobParts = new List<NestJobPart>(parts.Count);
var items = new List<NestItem>(parts.Count);
foreach (var part in parts)
{
@@ -68,23 +67,64 @@ public static class NestRunner
importedByPath.Add(part.Request.DxfPath, drawing);
}
ConfigureDrawingForRequirement(drawing, part.Request);
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
// Each requirement keeps its own identity/constraints even when paths are shared.
var requirementDrawing = new Drawing(part.Id, drawing.Program);
ConfigureDrawingForRequirement(requirementDrawing, part.Request);
requirementDrawing.Quantity.Required = part.Request.Quantity;
items.Add(new NestItem
{
Drawing = requirementDrawing,
Quantity = part.Request.Quantity,
Priority = part.Request.Priority,
StepAngle = requirementDrawing.Constraints.StepAngle,
RotationStart = requirementDrawing.Constraints.StartAngle,
RotationEnd = requirementDrawing.Constraints.EndAngle,
});
}
var job = new NestJob(
jobParts,
CreateStock(request),
new NestJobOptions(ResolvePlacementStrategy(request))
);
var stock = CreateStock(request);
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
var result = NestPipeline.Run(new NestPipelineRequest(
engineName, items, stock, new NestJobOptions(engineName)), jobProgress, token);
// This is the sole translation from immutable result poses to mutable legacy output objects.
var materialized = NestResultMaterializer.Materialize(job, result);
var nest = materialized.Nest;
nest.Thickness = request.Thickness;
nest.Material = new Material(request.Material);
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
// Invalid but representable proposals retain every pose and carry explicit validation status.
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
foreach (var item in items)
nest.Drawings.Add(item.Drawing);
foreach (var proposed in result.Plates)
{
var plate = new Plate(proposed.Stock.Size)
{
PartSpacing = proposed.Stock.PartSpacing,
EdgeSpacing = proposed.Stock.EdgeSpacing,
Quadrant = proposed.Stock.Quadrant,
Quantity = 1,
};
plate.Parts.AddRange(proposed.Parts);
nest.Plates.Add(plate);
}
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
// returned, bound placements and the original request, never plug-in summaries.
var counts = result.Plates.SelectMany(p => p.Parts)
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count());
var fulfillment = parts.Select((part, i) =>
{
var placed = counts.GetValueOrDefault(items[i].Drawing);
return new NestPartFulfillment(part.Id, part.Request.Quantity, placed,
System.Math.Max(0, part.Request.Quantity - placed));
}).ToArray();
var usage = stock.Select(s =>
{
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
}).ToArray();
var complete = fulfillment.All(f => f.Placed == f.Requested);
var stopReason = complete ? NestJobStopReason.Completed
: usage.All(u => u.Remaining == 0) ? NestJobStopReason.StockExhausted
: NestJobStopReason.NoPlacementFound;
var timingInfo = Timing.GetTimingInfo(nest);
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
@@ -97,29 +137,15 @@ public static class NestRunner
Utilization = CalculateUtilization(nest),
CutTime = cutTime,
Elapsed = sw.Elapsed,
Status = result.Status,
StopReason = result.StopReason,
Fulfillment = result
.Fulfillment.Select(value => new NestPartFulfillment(
value.PartId,
value.Requested,
value.Placed,
value.Unplaced
))
.ToArray(),
StockUsage = result
.StockUsage.Select(value => new NestStockUsage(
value.StockId,
value.Used,
value.Remaining
))
.ToArray(),
PlateStockMappings = result
.Plates.Select(value => new NestPlateStockMapping(
value.PlateIndex,
value.StockId
))
.ToArray(),
Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
StopReason = stopReason,
ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
Violations = result.Violations,
Fulfillment = fulfillment,
StockUsage = usage,
PlateStockMappings = result.Plates.Select((value, index) =>
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
Nest = nest,
Request = request,
}
@@ -140,6 +166,8 @@ public static class NestRunner
"Request parts must not contain null entries.",
nameof(requestParts)
);
if (part.Quantity < 0)
throw new ArgumentException("Part quantities must be nonnegative.", nameof(requestParts));
var id = part.Id ?? $"part-{index}";
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
+126 -57
View File
@@ -7,9 +7,9 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using OpenNest;
using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.IO;
@@ -21,6 +21,33 @@ static class NestConsole
{
public static int Run(string[] args)
{
using var cancellation = new CancellationTokenSource();
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; cancellation.Cancel(); };
Console.CancelKeyPress += cancel;
try
{
return RunCore(args, cancellation.Token);
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
return 2;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
finally
{
Console.CancelKeyPress -= cancel;
}
}
static int RunCore(string[] args, CancellationToken token)
{
token.ThrowIfCancellationRequested();
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
var options = ParseArgs(args);
if (options == null)
@@ -100,6 +127,12 @@ static class NestConsole
var plate = nest.Plates[options.PlateIndex];
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
{
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
return 2;
}
ApplyTemplate(plate, options);
ApplyOverrides(plate, options);
@@ -110,18 +143,21 @@ static class NestConsole
var existingCount = plate.Parts.Count;
if (!options.KeepParts)
if (!options.AutoNest && !options.KeepParts)
plate.Parts.Clear();
PrintHeader(nest, plate, drawing, existingCount, options);
var (success, elapsed) = Fill(nest, plate, drawing, options);
var (success, elapsed, accepted) = Fill(nest, plate, drawing, options, token);
if (!accepted)
return 2; // No save or post may run after a rejected proposal.
var overlapCount = CheckOverlaps(plate, options);
PrintResults(success, plate, elapsed);
Save(nest, options);
PostProcess(nest, options);
token.ThrowIfCancellationRequested();
if (!SaveAndPost(nest, options))
return 1;
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
}
@@ -185,6 +221,9 @@ static class NestConsole
case "--template" when i + 1 < args.Length:
o.TemplateFile = args[++i];
break;
case "--allow-invalid":
o.AllowInvalid = true;
break;
case "--autonest":
o.AutoNest = true;
break;
@@ -194,6 +233,9 @@ static class NestConsole
case "--post" when i + 1 < args.Length:
o.PostName = args[++i];
break;
case "--acknowledge-post-risks":
o.AcknowledgePostRisks = true;
break;
case "--post-output" when i + 1 < args.Length:
o.PostOutput = args[++i];
break;
@@ -394,7 +436,9 @@ static class NestConsole
);
var existingPartsMessage = options.KeepParts
? $"Keeping {existingCount} existing parts"
: $"Cleared {existingCount} existing parts";
: options.AutoNest
? $"Will replace {existingCount} existing parts only after acceptance"
: $"Cleared {existingCount} existing parts";
Console.WriteLine(
$"""
Drawing: {drawing.Name}
@@ -404,15 +448,17 @@ static class NestConsole
);
}
static (bool success, long elapsedMs) Fill(
static (bool success, long elapsedMs, bool accepted) Fill(
Nest nest,
Plate plate,
Drawing drawing,
Options options
Options options,
CancellationToken token
)
{
var sw = Stopwatch.StartNew();
bool success;
var accepted = true;
if (options.AutoNest)
{
@@ -433,7 +479,9 @@ static class NestConsole
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
);
success = AutoNestJob(plate, nestItems, options.Engine);
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
accepted = outcome.accepted;
success = outcome.committed > 0;
}
else
{
@@ -447,8 +495,9 @@ static class NestConsole
item,
plate.WorkArea(),
null,
CancellationToken.None
token
);
token.ThrowIfCancellationRequested();
if (parts.Count > 0)
plate.Parts.AddRange(parts);
@@ -456,52 +505,45 @@ static class NestConsole
}
sw.Stop();
return (success, sw.ElapsedMilliseconds);
return (success, sw.ElapsedMilliseconds, accepted);
}
/// <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)
static (bool accepted, int committed) AutoNestJob(
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
{
var engine = NestingEngineRegistry.Create(engineName);
var result = NestPipeline.Run(new NestPipelineRequest(
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
foreach (var violation in result.Violations)
Console.Error.WriteLine($"Violation: {violation}");
var parts = new List<NestJobPart>(nestItems.Count);
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
for (var i = 0; i < nestItems.Count; i++)
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
{
var partId = $"part-{i}";
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
drawingsByPartId[partId] = nestItems[i].Drawing;
Console.Error.WriteLine(result.Plates.Count > 1
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
: !result.CanKeep
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
return (false, 0);
}
// 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)
token.ThrowIfCancellationRequested();
var proposed = result.Plates.SingleOrDefault();
var committed = proposed?.Parts.Count ?? 0;
if (committed == 0)
{
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.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
return (false, 0);
}
plate.Parts.Clear();
plate.Size = proposed.Stock.Size;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = 1;
plate.Parts.AddRange(proposed.Parts);
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
return committed > 0;
return (true, committed);
}
static string ResolveFillStrategy(string engineName)
@@ -557,18 +599,16 @@ static class NestConsole
if (options.NoSave)
return;
var firstInput = options.InputFiles[0];
var outputFile =
options.OutputFile
?? Path.Combine(
Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
);
var outputFile = NestOutputPath(options);
new NestWriter(nest).Write(outputFile);
Console.WriteLine($"Saved: {outputFile}");
}
static string NestOutputPath(Options options) => options.OutputFile
?? Path.Combine(Path.GetDirectoryName(options.InputFiles[0]),
$"{Path.GetFileNameWithoutExtension(options.InputFiles[0])}-result{NestFormat.FileExtension}");
static string ResolvePostsDir(Options options)
{
if (options.PostsDir != null)
@@ -633,10 +673,13 @@ static class NestConsole
Console.WriteLine($" {p.Name,-30} {p.Description}");
}
static void PostProcess(Nest nest, Options options)
static bool SaveAndPost(Nest nest, Options options)
{
if (options.PostName == null)
return;
{
Save(nest, options);
return true;
}
var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir);
@@ -655,7 +698,15 @@ static class NestConsole
else
Console.Error.WriteLine($"No post processors found in: {postsDir}");
return;
return false;
}
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
Console.WriteLine(verification.ToDisplayText());
if (!verification.CanPost(options.AcknowledgePostRisks))
{
Console.Error.WriteLine("Posting blocked: review these warnings. To accept the risks, including possible head crashes and machine or material damage, explicitly use --acknowledge-post-risks for this invocation.");
return false;
}
var outputFile = options.PostOutput;
@@ -669,8 +720,22 @@ static class NestConsole
);
}
var outputFiles = post is IMultiFilePostProcessor multiFile
? multiFile.GetOutputFiles(nest, outputFile)
: new[] { outputFile };
if (!options.NoSave && outputFiles.Any(file => string.Equals(
Path.GetFullPath(file), Path.GetFullPath(NestOutputPath(options)), StringComparison.OrdinalIgnoreCase)))
{
Console.Error.WriteLine("Error: nest save and CNC output paths must be different. No output was written.");
return false;
}
Save(nest, options);
post.Post(nest, outputFile);
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
foreach (var file in outputFiles)
Console.WriteLine($"Post: {post.Name} -> {file}");
return true;
}
static void PrintUsage()
@@ -697,7 +762,8 @@ static class NestConsole
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
--autonest Whole-job nesting via the jobs engine (--engine) instead of single-plate fill
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
--engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
--keep-parts Don't clear existing parts before filling
@@ -705,6 +771,7 @@ static class NestConsole
--no-save Skip saving output file
--post <name> Run a post processor after nesting
--post-output <path> Output file for post processor (default: <input>.cnc)
--acknowledge-post-risks Explicitly accept displayed verification risks for this invocation
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
--list-posts List available post processors and exit
-h, --help Show this help
@@ -725,9 +792,11 @@ static class NestConsole
public bool NoSave;
public bool KeepParts;
public bool AutoNest;
public bool AllowInvalid;
public string Engine = "Default";
public string TemplateFile;
public string PostName;
public bool AcknowledgePostRisks;
public string PostOutput;
public string PostsDir;
public bool ListPosts;
+119
View File
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest;
/// <summary>
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
/// Callers must prevent concurrent edits for the duration of either operation.
/// </summary>
public static class AutomaticCutOffBatch
{
/// <summary>Detached plans in plate order. Invalid input identifies the one-based plate number.</summary>
public static IReadOnlyList<AutomaticCutOffPlan> Create(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(settings);
if (plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
throw new ArgumentException("Each plate must occur only once.", nameof(plates));
var plans = new List<AutomaticCutOffPlan>(plates.Count);
for (var index = 0; index < plates.Count; index++)
{
try
{
plans.Add(AutomaticCutOffPlanner.Create(plates[index], options, settings));
}
catch (ArgumentException error)
{
throw new ArgumentException($"Plate {index + 1}: {error.Message}", nameof(plates), error);
}
}
return plans.AsReadOnly();
}
/// <summary>
/// Replans all plates before changing any. A blocking plan returns without applying any
/// definitions; empty/unchanged plates are untouched. On failure, restores cutoff state
/// on every touched plate, reporting explicitly if any restoration also fails.
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
/// </summary>
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings)
{
var plans = Create(plates, options, settings);
if (plans.Any(plan => plan.HasBlockingDiagnostics))
return plans;
var restoreActions = new List<(int PlateNumber, Action Restore)>();
try
{
for (var index = 0; index < plates.Count; index++)
{
var plan = plans[index];
if (plan.Definitions.Count == 0)
continue;
var plate = plates[index];
// Register recovery before the first observable mutation, including AddRange.
restoreActions.Add((index + 1, CaptureRestore(plate, plan)));
plate.CutOffs.AddRange(plan.Definitions);
plate.RegenerateCutOffs(settings);
}
}
catch (Exception applyError)
{
var rollbackErrors = new List<Exception>();
for (var index = restoreActions.Count - 1; index >= 0; index--)
{
var saved = restoreActions[index];
try
{
saved.Restore();
}
catch (Exception rollbackError)
{
// A broken observer on one plate must not prevent recovery of the others.
rollbackErrors.Add(new InvalidOperationException(
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
}
}
if (rollbackErrors.Count > 0)
throw new InvalidOperationException(
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
+ string.Join("; ", rollbackErrors.Select(e => e.Message))
+ "\nThe nest may be incomplete; review it before saving or cutting.",
new AggregateException(new[] { applyError }.Concat(rollbackErrors)));
throw new InvalidOperationException(
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
}
return plans;
}
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
{
// Regeneration replaces drawing programs and removes/reinserts cutoff parts.
// Save only that state; real parts, poses, programs and quantities stay untouched.
var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToArray();
var parts = plate.Parts.Select((part, index) => (Part: part, Index: index))
.Where(p => p.Part.BaseDrawing.IsCutOff).ToArray();
return () =>
{
foreach (var definition in plan.Definitions)
plate.CutOffs.Remove(definition);
for (var index = plate.Parts.Count - 1; index >= 0; index--)
{
if (plate.Parts[index].BaseDrawing.IsCutOff)
plate.Parts.RemoveAt(index);
}
foreach (var saved in programs)
saved.CutOff.Drawing.Program = saved.Program;
foreach (var saved in parts)
plate.Parts.Insert(saved.Index, saved.Part);
};
}
}
+384
View File
@@ -0,0 +1,384 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest;
public sealed class AutomaticCutOffOptions
{
/// <summary>
/// Nominal distance between vertical cut lines in model units. Must be finite and
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
/// </summary>
public double Spacing { get; set; }
/// <summary>
/// Minimum retained-tail length along X in model units. Zero permits any positive tail.
/// The desktop default is 12 inches (304.8 mm).
/// </summary>
public double MinimumTailLength { get; set; }
}
public enum AutomaticCutOffDiagnosticCode
{
ExistingCutOff,
LimitedCutOffConflict,
EmptyCut,
SegmentedCut,
NoSafeTailSeparator,
TailBelowMinimum,
}
public sealed record AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode Code, string Message, bool IsBlocking = false, double? X = null);
/// <summary>
/// Detached proposal. Collections are read-only; contained definitions/preview parts belong
/// to the caller. Never accept a blocked plan or a preview made for an older layout/settings.
/// </summary>
public sealed class AutomaticCutOffPlan
{
/// <summary>Only new, usable definitions; existing equivalent definitions are not returned.</summary>
public IReadOnlyList<CutOff> Definitions { get; internal set; } = Array.Empty<CutOff>();
/// <summary>Detached display parts, in the same order as Definitions. Not for acceptance.</summary>
public IReadOnlyList<Part> PreviewParts { get; internal set; } = Array.Empty<Part>();
public IReadOnlyList<AutomaticCutOffDiagnostic> Diagnostics { get; internal set; } =
Array.Empty<AutomaticCutOffDiagnostic>();
/// <summary>Furthest real-part X distance from the origin, excluding cut-off parts.</summary>
public double OccupiedSpan { get; internal set; }
/// <summary>
/// Distance to the verified separator, or full sheet length when no separated tail can
/// be claimed. Zero on empty sheets. This is not a disconnected-scrap-size guarantee.
/// </summary>
public double UsedSpan { get; internal set; }
/// <summary>Length beyond a verified separator, otherwise zero (also on empty sheets).</summary>
public double TailLength { get; internal set; }
/// <summary>Signed X coordinate of a verified new or equivalent existing separator.</summary>
public double? TailSeparatorX { get; internal set; }
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue;
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
}
/// <summary>Pure proposals for vertical scrap cuts, measured in the plate's model units.</summary>
public static class AutomaticCutOffPlanner
{
public const double GeometryTolerance = Tolerance.Epsilon;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
/// <summary>
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
/// Invalid inputs throw ArgumentException. Blocking diagnostics return no definitions.
/// Accept by adding Definitions to Plate.CutOffs and calling RegenerateCutOffs with the
/// same settings, only while the layout is unchanged. Do not add PreviewParts to the plate.
/// Nominal spacing is not a guarantee of fully disconnected, hopper-sized scrap.
/// </summary>
public static AutomaticCutOffPlan Create(Plate plate, AutomaticCutOffOptions options,
CutOffSettings settings)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(settings);
ValidateInputs(plate, options, settings);
var bounds = plate.BoundingBox(false);
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
var occupied = 0.0;
var hasParts = false;
foreach (var part in plate.Parts)
{
Require(part?.BaseDrawing != null, "Every part must have a drawing.", nameof(plate));
if (part.BaseDrawing.IsCutOff)
continue;
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
hasParts = true;
}
if (!hasParts)
return new AutomaticCutOffPlan();
// Use distances from the coordinate origin, not the sheet's lower-left corner.
var length = plate.Size.Length;
occupied = System.Math.Min(occupied, length);
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
+ GeometryTolerance;
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
var usedSpan = hasTailCandidate ? separator : length;
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
// Validate and bound the candidate count before preparing geometry or allocating lines.
ValidateExisting(plate, bounds, settings);
var cache = Plate.BuildPerimeterCache(plate);
var definitions = new List<CutOff>();
var diagnostics = new List<AutomaticCutOffDiagnostic>();
var separated = false;
var separatorX = (double?)null;
// Multiplication by an integer avoids drift from repeated floating-point addition.
for (var index = 1; index <= (int)candidateCount; index++)
{
var distance = index * options.Spacing;
if (distance >= usedSpan - GeometryTolerance)
break;
AddCandidate(sign * distance, false);
}
if (hasTailCandidate)
{
if (length - separator >= options.MinimumTailLength)
AddCandidate(sign * separator, true);
else
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
"The proposed tail is shorter than the minimum tail length; the final separator was skipped. "
+ "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.",
X: sign * separator));
}
if (diagnostics.Any(d => d.IsBlocking))
{
// A partial proposal must not accidentally be accepted around a manual conflict.
definitions.Clear();
separated = false;
separatorX = null;
}
return new AutomaticCutOffPlan
{
Definitions = definitions.AsReadOnly(),
PreviewParts = definitions.Select(c => new Part(c.Drawing)).ToList().AsReadOnly(),
Diagnostics = diagnostics.AsReadOnly(),
OccupiedSpan = occupied,
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
TailSeparatorX = separatorX,
};
void AddCandidate(double x, bool isSeparator)
{
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
Near(c.Position.X, x)).ToList();
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
"A same-line manual cut has different limits. Manual review is required; no cuts may be applied.",
true, x));
if (isSeparator)
WarnNoSeparator(x);
return;
}
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
var existing = matches.FirstOrDefault();
var candidate = existing == null
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
: new CutOff(existing.Position, existing.Axis)
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
candidate.Regenerate(plate, settings, cache);
var program = candidate.Drawing.Program;
var usable = HasUsableSegments(program);
if (existing != null)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.ExistingCutOff,
"An equivalent full-span cut-off already exists; no duplicate was added.", X: x));
if (!usable)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.EmptyCut,
"The line has no usable cut segments after part clearance and minimum-length filtering; it is not a partition.",
X: x));
if (isSeparator)
{
if (!usable || !IsFullSpanProgram(program, bounds))
{
WarnNoSeparator(x);
return;
}
separated = true;
separatorX = candidate.Position.X;
}
else if (usable && !IsFullSpanProgram(program, bounds))
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.SegmentedCut,
"Part clearance or segment filtering interrupts this line; nominal spacing does not guarantee disconnected scrap.",
X: x));
if (usable && existing == null)
definitions.Add(candidate);
}
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
"No safe full-width tail separator survives the current settings. No separated tail is claimed; review manually.",
X: x));
}
private static void ValidateInputs(Plate plate, AutomaticCutOffOptions options, CutOffSettings settings)
{
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
Require(Nonnegative(options.MinimumTailLength),
"Minimum tail length must be finite and nonnegative.", nameof(options));
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
"Sheet length and width must be positive and finite.", nameof(plate));
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
Require(plate.Parts != null && plate.CutOffs != null,
"Plate parts and cut-off collections are required.", nameof(plate));
}
private static double ValidatePart(Part part, Box sheet, int sign)
{
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
"Part pose must be finite.", "plate");
ValidateProgram(part.Program, new HashSet<Program>());
var box = part.Program.BoundingBox();
box.Offset(part.Location);
Require(ValidBox(box) && box.Length > 0 && box.Width > 0,
"Real parts must have finite, nonempty geometry.", "plate");
var cached = part.BoundingBox;
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
"Part geometry has stale bounds; update it before planning.", "plate");
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
// Checking converted entities catches overflowing incremental moves and curve bounds.
var hasMaterial = false;
var occupied = sign > 0 ? box.Right : -box.Left;
var roundoff = CutOff.GetBoundsRoundoff(part);
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
{
var entityBox = entity.BoundingBox;
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
if (!SpecialLayers.IsMaterial(entity.Layer))
continue;
entityBox = entityBox.Translate(part.Location);
// Permit only bounded floating-point roundoff, with the same conservative
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
// (including refitted arc centers) are still rejected, even below epsilon.
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
}
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
return occupied;
}
private static void ValidateProgram(Program program, HashSet<Program> path)
{
Require(program?.Codes != null && path.Count < 64 && path.Add(program),
"Part program is missing, recursive, or nested too deeply.", "plate");
foreach (var code in program.Codes)
{
Require(code != null, "Part program contains a missing instruction.", "plate");
if (code is Motion motion)
Require(Finite(motion.EndPoint), "Part motion coordinates must be finite.", "plate");
if (code is ArcMove arc)
Require(Finite(arc.CenterPoint) && Enum.IsDefined(arc.Rotation),
"Part arc geometry must be finite with a valid direction.", "plate");
if (code is SubProgramCall call)
{
Require(Finite(call.Offset) && double.IsFinite(call.Rotation),
"Part sub-program pose must be finite.", "plate");
ValidateProgram(call.Program, path);
}
}
path.Remove(program);
}
private static void ValidateExisting(Plate plate, Box bounds, CutOffSettings settings)
{
foreach (var cut in plate.CutOffs)
{
Require(cut != null && Enum.IsDefined(cut.Axis) && Finite(cut.Position) &&
(!cut.StartLimit.HasValue || double.IsFinite(cut.StartLimit.Value)) &&
(!cut.EndLimit.HasValue || double.IsFinite(cut.EndLimit.Value)),
"Existing cut-off definitions must be finite with a valid axis.", nameof(plate));
var start = cut.StartLimit ?? (cut.Axis == CutOffAxis.Vertical ? bounds.Bottom : bounds.Left);
var end = cut.EndLimit ?? ((cut.Axis == CutOffAxis.Vertical ? bounds.Top : bounds.Right)
+ settings.Overtravel);
Require(double.IsFinite(end) && start < end,
"Existing cut-off limits must be finite and ordered.", nameof(plate));
}
}
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
private static bool HasUsableSegments(Program program)
{
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
return false;
for (var i = 0; i < program.Codes.Count; i += 2)
{
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
!Near(from.EndPoint.X, to.EndPoint.X) ||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
return false;
}
return true;
}
private static bool IsFullSpanProgram(Program program, Box bounds)
{
// No gaps, bridges, or filtered middle segments can separate the tail.
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
program.Codes[1] is not LinearMove to)
return false;
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
}
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
Nonnegative(box.Length) && Nonnegative(box.Width) &&
double.IsFinite(box.Right) && double.IsFinite(box.Top);
private static bool Inside(Box box, Box sheet) =>
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
private static bool Near(double a, double b)
{
// An exact tolerance-sized offset can round just above epsilon after subtraction.
// Allow two representational steps, not a geometry-scale relative tolerance.
var magnitude = System.Math.Max(System.Math.Abs(a), System.Math.Abs(b));
var step = System.Math.BitIncrement(magnitude) - magnitude;
return System.Math.Abs(a - b) <= GeometryTolerance + (double.IsFinite(step) ? 2 * step : 0);
}
private static void Require(bool valid, string message, string parameter)
{
if (!valid)
throw new ArgumentException(message, parameter);
}
}
@@ -367,19 +367,26 @@ namespace OpenNest.CNC.CuttingStrategy
return;
}
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
Parameters.PierceClearance, out var leadInNormal);
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
var reindexedShape = shape.ReindexAt(point, entity);
if (
Parameters.TabsEnabled
var tabbed = Parameters.TabsEnabled
&& Parameters.TabConfig != null
&& contourType == ContourType.External
)
&& contourType == ContourType.External;
if (tabbed)
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
var leadOutNormal = normal;
if (!tabbed)
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
Parameters.PierceClearance, out leadOutNormal);
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
program.Codes.AddRange(leadOut.Generate(point, leadOutNormal, winding));
}
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
@@ -436,6 +443,259 @@ namespace OpenNest.CNC.CuttingStrategy
return ContourType.Internal;
}
/// <summary>
/// Uses the inward angle bisector for straight lead-ins at cutout corners.
/// Edge interiors and other lead-in styles keep the entity normal. Shared
/// by program generation and the manual placement preview.
/// </summary>
public static double ComputeLeadInNormal(
Shape shape,
Vector point,
Entity entity,
ContourType contourType,
LeadIn leadIn,
RotationType winding = RotationType.CW
)
{
var normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.Internal || leadIn is not LineLeadIn
|| !TryGetCorner(shape, point, entity, out var corner))
return normal;
return BisectCorner(point, corner, contourType, winding) ?? normal;
}
/// <summary>
/// Returns the lead-in to emit at <paramref name="point"/> and the normal to
/// generate it with. At a corner of an outside perimeter, a straight
/// (<see cref="LineLeadIn"/>) lead-in extends the edge cut first so the torch
/// enters on that edge's line, provided the pierce keeps
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
/// reflex corner it bisects the notch. The result does not depend on which of
/// the two edges meeting at the corner was picked. Other styles and contour
/// types keep <see cref="ComputeLeadInNormal"/>.
/// </summary>
public static LeadIn ResolveLeadIn(
Shape shape,
Vector point,
Entity entity,
ContourType contourType,
LeadIn leadIn,
RotationType winding,
double pierceClearance,
out double normal
)
{
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|| !TryGetCorner(shape, point, entity, out var corner))
return leadIn;
switch (ClassifyCorner(corner, winding))
{
case CornerKind.Convex:
var pierce = point - corner.TangentOut * line.Length;
if (IsClearStraightLead(shape, point, pierce, pierceClearance))
{
normal = Angle.NormalizeRad((-corner.TangentOut).Angle());
return new LineLeadIn { Length = line.Length, ApproachAngle = 90 };
}
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
return leadIn;
case CornerKind.Smooth:
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
return leadIn;
case CornerKind.Reflex:
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
return leadIn;
default:
return leadIn;
}
}
/// <summary>
/// Lead-out counterpart of <see cref="ResolveLeadIn"/>. At a convex outside
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
/// bisects the notch. Other styles and contour types keep the entity normal.
/// </summary>
public static LeadOut ResolveLeadOut(
Shape shape,
Vector point,
Entity entity,
ContourType contourType,
LeadOut leadOut,
RotationType winding,
double clearance,
out double normal
)
{
normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|| !TryGetCorner(shape, point, entity, out var corner))
return leadOut;
switch (ClassifyCorner(corner, winding))
{
case CornerKind.Convex:
var end = point + corner.TangentIn * line.Length;
if (IsClearStraightLead(shape, point, end, clearance))
{
normal = Angle.NormalizeRad(corner.TangentIn.Angle());
return new LineLeadOut { Length = line.Length, ApproachAngle = 90 };
}
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
return leadOut;
case CornerKind.Smooth:
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
return leadOut;
case CornerKind.Reflex:
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
return leadOut;
default:
return leadOut;
}
}
private enum CornerKind
{
Convex,
Reflex,
Smooth,
Cusp,
}
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
private readonly record struct ContourCorner(
Entity Incoming,
Entity Outgoing,
Vector TangentIn,
Vector TangentOut
);
private static bool TryGetCorner(Shape shape, Vector point, Entity entity, out ContourCorner corner)
{
corner = default;
if (entity is not (Line or Arc) || entity.Length <= Tolerance.Epsilon
|| shape.Entities.Count < 2 || !shape.IsClosed())
return false;
var index = shape.Entities.IndexOf(entity);
if (index < 0)
return false;
var atStart = point.DistanceTo(EntityStartPoint(entity)) <= Tolerance.Epsilon;
if (!atStart && point.DistanceTo(EntityEndPoint(entity)) > Tolerance.Epsilon)
return false;
var adjacentIndex = atStart
? (index + shape.Entities.Count - 1) % shape.Entities.Count
: (index + 1) % shape.Entities.Count;
var adjacent = shape.Entities[adjacentIndex];
var adjacentPoint = atStart ? EntityEndPoint(adjacent) : EntityStartPoint(adjacent);
if (adjacent is not (Line or Arc) || adjacent.Length <= Tolerance.Epsilon
|| point.DistanceTo(adjacentPoint) > Tolerance.Epsilon)
return false;
var incoming = atStart ? adjacent : entity;
var outgoing = atStart ? entity : adjacent;
var tangentIn = TravelTangent(incoming, point);
var tangentOut = TravelTangent(outgoing, point);
if (!IsFinite(tangentIn) || !IsFinite(tangentOut))
return false;
corner = new ContourCorner(incoming, outgoing, tangentIn, tangentOut);
return true;
}
/// <summary>Unit direction of travel along a line or arc at a point on it.</summary>
private static Vector TravelTangent(Entity entity, Vector point)
{
if (entity is Line line)
return (line.EndPoint - line.StartPoint).Normalize();
var arc = (Arc)entity;
var radial = (point - arc.Center).Normalize();
return arc.IsReversed ? new Vector(radial.Y, -radial.X) : new Vector(-radial.Y, radial.X);
}
private static bool IsFinite(Vector v) => double.IsFinite(v.X) && double.IsFinite(v.Y);
/// <summary>
/// Convex corners point away from the part (interior angle under 180 degrees).
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner.
/// </summary>
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
{
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
return dot > 0 ? CornerKind.Smooth : CornerKind.Cusp;
return turn > 0 ? CornerKind.Convex : CornerKind.Reflex;
}
private static double? BisectCorner(
Vector point,
ContourCorner corner,
ContourType contourType,
RotationType winding
)
{
var normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
// Sum unit normals rather than averaging angles (which fails at 0/2π).
// Winding makes this point into the scrap even at reflex corners.
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
if (!double.IsFinite(x) || !double.IsFinite(y)
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
return null; // Opposing normals at a cusp have no unique bisector.
return Angle.NormalizeRad(System.Math.Atan2(y, x));
}
/// <summary>
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
/// its free end keeps <paramref name="clearance"/> from the contour and the lead
/// crosses the contour nowhere but at the corner.
/// </summary>
private static bool IsClearStraightLead(Shape shape, Vector corner, Vector end, double clearance)
{
if (!IsFinite(end) || end.DistanceTo(corner) <= Tolerance.Epsilon)
return false;
var nearest = shape.ClosestPointTo(end, out _);
if (nearest.DistanceTo(end) < System.Math.Max(clearance, 0) - Tolerance.Epsilon)
return false;
if (shape.Intersects(new Line(end, corner), out var crossings))
{
foreach (var crossing in crossings)
{
if (crossing.DistanceTo(corner) > Tolerance.ChainTolerance)
return false;
}
}
return true;
}
private static Vector EntityEndPoint(Entity entity)
{
if (entity is Line line)
return line.EndPoint;
if (entity is Arc arc)
return arc.EndPoint();
return Vector.Invalid;
}
public static double ComputeNormal(
Vector point,
Entity entity,
+190 -164
View File
@@ -90,6 +90,9 @@ namespace OpenNest.CNC
SetModeAbs();
// Several calls can share one sub-program (identical holes); rotate each once.
var rotatedSubPrograms = new HashSet<Program>(ReferenceEqualityComparer.Instance);
for (int i = 0; i < Codes.Count; ++i)
{
var code = Codes[i];
@@ -110,7 +113,7 @@ namespace OpenNest.CNC
);
}
if (subpgm.Program != null)
if (subpgm.Program != null && rotatedSubPrograms.Add(subpgm.Program))
subpgm.Program.Rotate(angle, origin);
}
@@ -269,37 +272,37 @@ namespace OpenNest.CNC
switch (Mode)
{
case Mode.Absolute:
{
for (int i = Codes.Count; i >= 0; --i)
{
var code = Codes[i];
var motion = code as Motion;
for (int i = Codes.Count; i >= 0; --i)
{
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:
{
var pos = new Vector(0, 0);
for (int i = 0; i < Codes.Count; ++i)
{
var code = Codes[i];
var motion = code as Motion;
var pos = new Vector(0, 0);
if (motion == null)
continue;
for (int i = 0; i < Codes.Count; ++i)
{
var code = Codes[i];
var motion = code as Motion;
pos += motion.EndPoint;
if (motion == null)
continue;
pos += motion.EndPoint;
}
return pos;
}
return pos;
}
}
return new Vector(0, 0);
@@ -334,164 +337,164 @@ namespace OpenNest.CNC
switch (code.Type)
{
case CodeType.LinearMove:
{
var line = (LinearMove)code;
var pt =
Mode == Mode.Absolute
? frameOrigin + line.EndPoint
: line.EndPoint + pos;
{
var line = (LinearMove)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.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;
if (pt.Y > maxY)
maxY = pt.Y;
if (pt.Y < minY)
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;
{
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.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;
if (pt.Y > maxY)
maxY = pt.Y;
if (pt.Y < minY)
minY = pt.Y;
pos = pt;
pos = pt;
break;
}
break;
}
case CodeType.ArcMove:
{
var arc = (ArcMove)code;
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
Vector endpt;
Vector centerpt;
if (Mode == Mode.Incremental)
{
endpt = arc.EndPoint + pos;
centerpt = arc.CenterPoint + pos;
var arc = (ArcMove)code;
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
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:
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program == null)
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program == null)
break;
// Sub-program frame origin in this program's frame
// is frameOrigin + Offset, regardless of current pos.
pos = frameOrigin + subpgm.Offset;
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;
// 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;
}
}
}
}
@@ -519,8 +522,31 @@ namespace OpenNest.CNC
foreach (var kvp in Variables)
pgm.Variables[kvp.Key] = kvp.Value;
// The copy owns its sub-programs: rotating it must never turn the source's holes.
// Calls that shared one sub-program keep sharing one copy.
Dictionary<Program, Program> subCopies = null;
Program CopyOf(Program sub)
{
subCopies ??= new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
if (!subCopies.TryGetValue(sub, out var copy))
{
copy = (Program)sub.Clone();
subCopies[sub] = copy;
}
return copy;
}
foreach (var kvp in SubPrograms)
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
pgm.SubPrograms[kvp.Key] = CopyOf(kvp.Value);
foreach (var code in codes)
{
if (code is SubProgramCall call && call.Program != null)
call.BindProgram(CopyOf(call.Program));
}
return pgm;
}
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace OpenNest.CNC;
/// <summary>
/// A character range in generated G-code assigned by one highlighting rule.
/// </summary>
/// <param name="Index">Zero-based UTF-16 index of the first colored character.</param>
/// <param name="Length">Number of UTF-16 characters to color.</param>
/// <param name="RuleIndex">
/// Zero-based rule index: 0 comments, 1 motion modes (G90/G91), 2 rapid moves (G00),
/// 3 linear moves (G01), 4 arcs (G02/G03).
/// </param>
public readonly record struct HighlightSpan(int Index, int Length, int RuleIndex);
/// <summary>Computes cosmetic G-code highlight spans without changing the generated text.</summary>
public static class ProgramHighlighting
{
private static readonly Regex[] Rules =
{
new(@"^;.*$", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
new(@"^G9[01]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
new(@"^G00\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
new(@"^G01\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
new(@"^G0[23]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
};
/// <summary>
/// Materializes all matches in rule/application order (later rules overwrite earlier ones).
/// A timeout propagates before any result is published; no partial span list escapes.
/// The timeout is per regex match, not a deadline for the complete operation.
/// </summary>
/// <exception cref="RegexMatchTimeoutException">A rule exceeded its match budget.</exception>
public static IReadOnlyList<HighlightSpan> ComputeSpans(string text)
{
ArgumentNullException.ThrowIfNull(text);
var spans = new List<HighlightSpan>();
for (var ruleIndex = 0; ruleIndex < Rules.Length; ruleIndex++)
{
// MatchCollection is lazy: enumerate every rule before returning any spans.
foreach (Match match in Rules[ruleIndex].Matches(text))
spans.Add(new HighlightSpan(match.Index, match.Length, ruleIndex));
}
return spans.AsReadOnly();
}
}
+27 -5
View File
@@ -7,20 +7,42 @@ namespace OpenNest.CNC
{
public readonly record struct Segment(Vector From, Vector To);
/// <summary>
/// Enumerates plate rapids in cutting order, advancing through all cutting
/// motions before connecting to the next part (including scrap cutoffs).
/// </summary>
public static List<Segment> Enumerate(IEnumerable<Part> parts)
{
var results = new List<Segment>();
var pos = Vector.Zero;
foreach (var part in parts)
pos = AppendProgram(part.Program, part.Location, pos, results);
return results;
}
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
{
var results = new List<Segment>();
AppendProgram(pgm, basePos, startPos, results);
return results;
}
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
{
// Draw the rapid from the previous tool position to the program's first
// pierce point. This also primes pos so the interior walk interprets
// Incremental deltas from the correct absolute location (basePos), which
// matters for raw pre-lead-in programs that are emitted Incremental.
// pierce point. The walk then starts at the program origin (basePos), not
// the pierce: the skipped first rapid still advances pos, so starting at
// the pierce would apply a nonzero Incremental first delta twice (as in
// lead-in programs) and shift every later rapid by it.
var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce));
var pos = firstPierce;
var pos = basePos;
Walk(pgm, basePos, ref pos, skipFirst: true, results);
return results;
// The last rapid ends at a pierce, not necessarily the final tool position.
return pos;
}
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
+19 -3
View File
@@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -79,12 +79,28 @@ namespace OpenNest.CNC
}
/// <summary>
/// Gets a shallow copy.
/// Gets a shallow copy that references the same program. Copies the fields
/// directly: going through the setters would re-align (rotate) the shared program.
/// </summary>
/// <returns></returns>
public ICode Clone()
{
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
return new SubProgramCall
{
program = program,
rotation = rotation,
Id = Id,
Offset = Offset,
};
}
/// <summary>
/// Points the call at <paramref name="copy"/>, a copy of its current program, without
/// re-aligning its rotation: the copy already has the geometry the call executes.
/// </summary>
internal void BindProgram(Program copy)
{
program = copy;
}
public override string ToString()
+35
View File
@@ -135,8 +135,15 @@ namespace OpenNest
)
{
var bb = part.BoundingBox;
var roundoff = GetBoundsRoundoff(part);
var (partMin, partMax) = AxisBounds(bb, clearance);
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
// Match the planner's representational allowance in BOTH pruning and
// fallback exclusions. Tolerating it only in validation could cut an edge.
partMin -= roundoff;
partMax += roundoff;
partStart -= roundoff;
partEnd += roundoff;
if (cutPosition < partMin || cutPosition > partMax)
return EmptyExclusions;
@@ -157,6 +164,25 @@ namespace OpenNest
return new List<(double Start, double End)> { (partStart, partEnd) };
}
/// <summary>
/// Bounds reconstructed as (local minimum + placement) + size can differ
/// from translated material endpoints by a few floating-point steps. This
/// is not a geometry tolerance: cap it well below epsilon so inconsistent
/// arcs still require repair. Do not change shared Part bounds or programs.
/// </summary>
internal static double GetBoundsRoundoff(Part part)
{
var bb = part.BoundingBox;
var magnitude = System.Math.Max(System.Math.Abs(bb.Left), System.Math.Abs(bb.Right));
magnitude = System.Math.Max(magnitude,
System.Math.Max(System.Math.Abs(bb.Bottom), System.Math.Abs(bb.Top)));
magnitude = System.Math.Max(magnitude, System.Math.Max(bb.Length, bb.Width));
magnitude = System.Math.Max(magnitude,
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
var step = System.Math.BitIncrement(magnitude) - magnitude;
return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0;
}
private List<(double Start, double End)> IntersectPerimeter(
Entity perimeter,
double cutPosition,
@@ -191,6 +217,15 @@ namespace OpenNest
if (coords.Count % 2 != 0)
return null;
// Intersects reports both incident edges at a shared vertex. Such hits may be
// crossings or tangencies, so neither pairing nor deduplicating them preserves
// inside/outside parity. Fall back to the clearance-expanded part bounds.
for (var i = 1; i < coords.Count; i++)
{
if (coords[i] - coords[i - 1] <= Math.Tolerance.Epsilon)
return null;
}
var padding = usedOffset ? 0 : clearance;
var result = new List<(double Start, double End)>();
for (var i = 0; i < coords.Count; i += 2)
@@ -0,0 +1,80 @@
namespace OpenNest.Diagnostics;
public enum OverlapAutoCheckStep { None, Wait, Check }
/// <summary>
/// UI-thread debounce policy for automatic overlap rechecks; the host owns the timer.
/// Restart the quiet-period timer whenever <see cref="Observe"/> returns true, and call
/// <see cref="Elapsed"/> when it fires. A check starts only after the ordered layout stamp
/// has stayed unchanged for a whole quiet period with no interaction in progress.
/// A canceled or failed request is not retried until the layout differs from the one it
/// started from, so cancellation is respected and a failing layout cannot loop.
/// </summary>
public sealed class OverlapAutoCheckScheduler
{
private OverlapGeometryStamp pending;
private OverlapGeometryStamp lastRequest;
public bool IsWaiting => pending != null;
/// <summary>
/// Call after the report state's freshness check. True means (re)start the quiet-period
/// timer; false means leave it as it is.
/// </summary>
public bool Observe(Plate plate, OverlapReportState state)
{
if (plate == null || !NeedsCheck(plate, state))
{
pending = null;
return false;
}
if (pending != null && pending.Matches(plate))
return false;
pending = OverlapGeometryStamp.Capture(plate);
return true;
}
/// <summary>
/// Call when the quiet period ends. <see cref="OverlapAutoCheckStep.Wait"/> means restart the
/// timer (the layout moved or an interaction is still running); only
/// <see cref="OverlapAutoCheckStep.Check"/> starts a request.
/// </summary>
public OverlapAutoCheckStep Elapsed(Plate plate, OverlapReportState state, bool interactionActive)
{
if (pending == null)
return OverlapAutoCheckStep.None;
if (plate == null || !NeedsCheck(plate, state))
{
pending = null;
return OverlapAutoCheckStep.None;
}
if (interactionActive || !pending.Matches(plate))
{
pending = OverlapGeometryStamp.Capture(plate);
return OverlapAutoCheckStep.Wait;
}
pending = null;
return OverlapAutoCheckStep.Check;
}
/// <summary>Record every request start, manual or automatic.</summary>
public void Started(Plate plate)
{
pending = null;
lastRequest = OverlapGeometryStamp.Capture(plate);
}
/// <summary>Forget pending and previous requests (plate switch, handle loss, disable).</summary>
public void Reset()
{
pending = null;
lastRequest = null;
}
private bool NeedsCheck(Plate plate, OverlapReportState state) => state.Status switch
{
OverlapCheckStatus.NotChecked or OverlapCheckStatus.Stale => true,
OverlapCheckStatus.Canceled or OverlapCheckStatus.Failed => lastRequest?.Matches(plate) != true,
_ => false
};
}
@@ -0,0 +1,66 @@
using System;
using OpenNest.CNC;
namespace OpenNest.Diagnostics;
/// <summary>
/// Cheap ordered identity/pose check, not a geometry hash. In-place geometry editors must
/// explicitly invalidate before mutating. Capture and match only on the model's UI thread.
/// </summary>
public sealed class OverlapGeometryStamp
{
private readonly Plate plate;
private readonly Entry[] entries;
private OverlapGeometryStamp(Plate plate)
{
this.plate = plate;
entries = new Entry[plate.Parts.Count];
for (var i = 0; i < entries.Length; i++)
entries[i] = new Entry(plate.Parts[i]);
}
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
public bool Matches(Plate current)
{
if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
return false;
for (var i = 0; i < entries.Length; i++)
if (!entries[i].Matches(current.Parts[i]))
return false;
return true;
}
private readonly struct Entry
{
private readonly Part part;
private readonly Drawing drawing;
private readonly Program placedProgram;
private readonly Program cleanProgram;
private readonly long x, y, rotation;
private readonly bool isCutOff;
public Entry(Part part)
{
this.part = part;
drawing = part.BaseDrawing;
placedProgram = part.Program;
cleanProgram = drawing.Program;
x = BitConverter.DoubleToInt64Bits(part.Location.X);
y = BitConverter.DoubleToInt64Bits(part.Location.Y);
rotation = BitConverter.DoubleToInt64Bits(part.Rotation);
isCutOff = drawing.IsCutOff;
}
public bool Matches(Part current) =>
ReferenceEquals(part, current)
&& ReferenceEquals(drawing, current.BaseDrawing)
&& ReferenceEquals(placedProgram, current.Program)
&& ReferenceEquals(cleanProgram, current.BaseDrawing.Program)
&& x == BitConverter.DoubleToInt64Bits(current.Location.X)
&& y == BitConverter.DoubleToInt64Bits(current.Location.Y)
&& rotation == BitConverter.DoubleToInt64Bits(current.Rotation)
&& isCutOff == current.BaseDrawing.IsCutOff;
}
}
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace OpenNest.Diagnostics;
/// <summary>
/// Viewport-sized pages of cached overlap details, including continuation pages for a
/// single long pair. The caller supplies its actual single-line font measurement.
/// No names or numeric details are elided, and no geometry is queried here.
/// </summary>
public sealed class OverlapHoverPages
{
private readonly IReadOnlyList<string>[] pages;
private readonly IReadOnlyList<string>[] navigation;
private OverlapHoverPages(IReadOnlyList<string>[] pages, IReadOnlyList<string>[] navigation,
bool needsLargerViewport = false)
{
this.pages = pages;
this.navigation = navigation;
NeedsLargerViewport = needsLargerViewport;
}
public IReadOnlyList<string> Lines => PageCount == 0 ? Array.Empty<string>() : pages[PageIndex];
public IReadOnlyList<string> NavigationLines => PageCount == 0 ? Array.Empty<string>() : navigation[PageIndex];
public int PageIndex { get; private set; }
public int PageCount => pages.Length;
public bool NeedsLargerViewport { get; }
public void MovePage(int delta) => PageIndex = (int)System.Math.Clamp((long)PageIndex + delta, 0,
System.Math.Max(0, PageCount - 1));
public static OverlapHoverPages Create(string text, int pairCount, int maxRows,
double maxWidth, Func<string, double> measure)
{
var tooSmall = new OverlapHoverPages([], [], true);
if (maxRows < 1 || !double.IsFinite(maxWidth) || maxWidth <= 0)
return tooSmall;
var lines = Wrap(text, maxWidth, measure);
if (lines == null)
return tooSmall;
if (lines.Count <= maxRows)
return new OverlapHoverPages([lines.AsReadOnly()], [Array.Empty<string>()]);
// Reserve the real, wrapped hint as well as content. Increasing the reserved
// rows can increase the page count/digit count, so converge before slicing.
for (var hintRows = 2; hintRows < maxRows;)
{
var contentRows = maxRows - hintRows;
var count = (lines.Count - 1) / contentRows + 1;
var hints = new IReadOnlyList<string>[count];
var requiredHintRows = hintRows;
for (var page = 0; page < count; page++)
{
var hint = Wrap($"Page {page + 1}/{count} · {pairCount} pairs\nPgUp/PgDn", maxWidth, measure);
if (hint == null)
return tooSmall;
hints[page] = hint.AsReadOnly();
requiredHintRows = System.Math.Max(requiredHintRows, hint.Count);
}
if (requiredHintRows > hintRows)
{
hintRows = requiredHintRows;
continue;
}
var pages = Enumerable.Range(0, count)
.Select(page => (IReadOnlyList<string>)Array.AsReadOnly(lines.Skip(page * contentRows).Take(contentRows).ToArray()))
.ToArray();
return new OverlapHoverPages(pages, hints);
}
// There must be room for at least one complete content line AND navigation.
return tooSmall;
}
private static List<string> Wrap(string text, double width, Func<string, double> measure)
{
var lines = new List<string>();
foreach (var paragraph in text.Replace("\r\n", "\n").Split('\n'))
{
if (paragraph.Length == 0)
{
lines.Add("");
continue;
}
var starts = StringInfo.ParseCombiningCharacters(paragraph).Append(paragraph.Length).ToArray();
for (var first = 0; first < starts.Length - 1;)
{
var low = first;
var high = starts.Length - 1;
while (low < high)
{
var end = low + (high - low + 1) / 2;
if (measure(paragraph[starts[first]..starts[end]]) <= width)
low = end;
else
high = end - 1;
}
if (low == first)
return null; // Even one grapheme cannot fit; do not silently clip it.
var last = low;
if (last < starts.Length - 1)
{
for (var end = last; end > first; end--)
{
if (char.IsWhiteSpace(paragraph[starts[end - 1]]))
{
last = end;
break;
}
}
}
lines.Add(paragraph[starts[first]..starts[last]]);
first = last;
}
}
return lines;
}
}
@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Diagnostics;
/// <summary>Request-local, validated single-outer material. Never exposed to report consumers.</summary>
internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
{
internal static OverlapMaterial Read(List<Entity> entities, CancellationToken cancellationToken)
{
// ShapeBuilder can reverse entities while chaining. Own a fresh copy for each analysis.
var shapes = ShapeBuilder.GetShapes(entities.Select(entity => entity.Clone()));
if (shapes.Count == 0)
throw new ArgumentException("Drawing has no closed material contour.");
var polygons = new List<Polygon>();
foreach (var shape in shapes)
{
cancellationToken.ThrowIfCancellationRequested();
ValidateChain(shape);
var polygon = shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance);
// The analytic chain was validated above. Normalize its sampled seam (e.g.
// sin(2*pi) is not exactly zero), rather than adding a spurious microscopic edge.
if (polygon.IsClosed())
polygon.Vertices[^1] = polygon.Vertices[0];
ValidatePolygon(polygon, cancellationToken);
polygons.Add(polygon);
}
// Sampling can hide a crossing or tangency between curves. Reject native
// contour contact before asking the polygon approximation about containment.
for (var i = 0; i < shapes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
for (var j = 0; j < i; j++)
{
shapes[i].Intersects(shapes[j], out var intersections);
if (intersections.Count > 0)
throw new ArgumentException("Native material contours cross or touch.");
}
}
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
var outer = ordered[0];
var holes = ordered.Skip(1).ToList();
for (var i = 0; i < holes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
if (BoundariesTouch(outer, holes[i], cancellationToken)
|| !Inside(outer, holes[i].Vertices[0]))
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
for (var j = 0; j < i; j++)
{
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|| Inside(holes[i], holes[j].Vertices[0])
|| Inside(holes[j], holes[i].Vertices[0]))
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
}
}
return new OverlapMaterial(outer, holes);
}
internal OverlapMaterial Transform(double rotation, Vector offset) =>
new(TransformPolygon(Outer, rotation, offset),
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
private static Polygon TransformPolygon(Polygon polygon, double rotation, Vector offset)
{
var transformed = new Polygon();
transformed.Vertices.AddRange(polygon.Vertices.Select(point =>
(rotation == 0 ? point : point.Rotate(rotation)) + offset));
if (transformed.Vertices.Any(point => !IsFinite(point)))
throw new ArithmeticException("Transformed contour has nonfinite coordinates.");
transformed.UpdateBounds();
if (!double.IsFinite(transformed.BoundingBox.Length)
|| !double.IsFinite(transformed.BoundingBox.Width))
throw new ArithmeticException("Transformed contour bounds overflowed.");
var sourceArea = Area(polygon.Vertices);
var transformedArea = Area(transformed.Vertices);
if (!double.IsFinite(transformedArea) || transformedArea <= Tolerance.Epsilon
|| System.Math.Abs(sourceArea - transformedArea)
> System.Math.Max(Tolerance.Epsilon, sourceArea * 1e-8))
throw new ArithmeticException("Coordinate precision cannot preserve the contour area at this pose.");
for (var i = 0; i + 1 < transformed.Vertices.Count; i++)
{
var a = transformed.Vertices[i];
var b = transformed.Vertices[i + 1];
if (a.X == b.X && a.Y == b.Y)
throw new ArithmeticException("Coordinate precision collapsed a contour edge at this pose.");
}
return transformed;
}
internal static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
/// <summary>Translation-stable unsigned shoelace area; accepts an explicit closing vertex.</summary>
internal static double Area(IReadOnlyList<Vector> vertices)
{
var twiceArea = 0.0;
for (var i = 1; i + 1 < vertices.Count; i++)
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
return System.Math.Abs(twiceArea) * 0.5;
}
private static void ValidateChain(Shape shape)
{
if (!shape.IsClosed())
throw new ArgumentException("Material contour is open.");
foreach (var entity in shape.Entities)
{
if (!double.IsFinite(entity.Length) || entity.Length <= 0)
throw new ArgumentException("Material contour has a nonfinite or zero-length edge.");
}
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
{
if (!IsFinite(circle.Center) || !double.IsFinite(circle.Radius) || circle.Radius <= 0)
throw new ArgumentException("Material circle is invalid.");
return;
}
for (var i = 0; i < shape.Entities.Count; i++)
{
var end = Endpoints(shape.Entities[i]).End;
var start = Endpoints(shape.Entities[(i + 1) % shape.Entities.Count]).Start;
// Do not let ShapeBuilder's larger chain tolerance silently repair a broken cut.
if (!IsFinite(start) || !IsFinite(end) || end.DistanceTo(start) > Tolerance.Epsilon)
throw new ArgumentException("Material contour has a gap or invalid endpoint.");
}
}
private static (Vector Start, Vector End) Endpoints(Entity entity) => entity switch
{
Line line => (line.StartPoint, line.EndPoint),
Arc arc => (arc.StartPoint(), arc.EndPoint()),
_ => throw new ArgumentException("Unsupported material entity."),
};
private static void ValidatePolygon(Polygon polygon, CancellationToken cancellationToken)
{
var vertices = polygon.Vertices;
var area = Area(vertices);
if (vertices.Count < 4 || vertices.Any(point => !IsFinite(point))
|| !double.IsFinite(area) || area <= Tolerance.Epsilon)
throw new ArgumentException("Material contour is degenerate or nonfinite.");
var count = vertices.Count - 1;
for (var i = 0; i < count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var previous = vertices[(i + count - 1) % count];
var current = vertices[i];
var next = vertices[i + 1];
if (current.X == next.X && current.Y == next.Y)
throw new ArgumentException("Material polygon has a zero-length edge.");
if (Cross(previous, current, next) == 0
&& (previous.X - current.X) * (next.X - current.X)
+ (previous.Y - current.Y) * (next.Y - current.Y) > 0)
throw new ArgumentException("Material polygon has a retraced edge.");
for (var j = i + 2; j < count; j++)
{
if (i == 0 && j == count - 1)
continue;
if (SegmentsTouch(vertices[i], vertices[i + 1], vertices[j], vertices[j + 1]))
throw new ArgumentException("Material contour self-intersects or touches itself.");
}
}
// Ear clipping can stop early on unusable geometry. Do not certify that as clear.
var local = TransformPolygon(polygon, 0, vertices[0] * -1);
var triangulatedArea = Collision.Triangulate(local).Sum(triangle => Area(triangle.Vertices));
if (!double.IsFinite(triangulatedArea)
|| System.Math.Abs(triangulatedArea - area) > System.Math.Max(Tolerance.Epsilon, area * 1e-9))
throw new ArgumentException("Material contour could not be completely triangulated.");
}
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
{
for (var i = 0; i + 1 < a.Vertices.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
for (var j = 0; j + 1 < b.Vertices.Count; j++)
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
return true;
}
return false;
}
private static bool SegmentsTouch(Vector a, Vector b, Vector c, Vector d)
{
var ac = Cross(a, b, c);
var ad = Cross(a, b, d);
var ca = Cross(c, d, a);
var cb = Cross(c, d, b);
return ac == 0 && OnSegment(a, b, c) || ad == 0 && OnSegment(a, b, d)
|| ca == 0 && OnSegment(c, d, a) || cb == 0 && OnSegment(c, d, b)
|| (ac < 0 && ad > 0 || ac > 0 && ad < 0)
&& (ca < 0 && cb > 0 || ca > 0 && cb < 0);
}
private static bool OnSegment(Vector a, Vector b, Vector point) =>
point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X)
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
// Boundary contact is rejected before this winding-number test is used for topology.
private static bool Inside(Polygon polygon, Vector point)
{
var winding = 0;
for (var i = 0; i + 1 < polygon.Vertices.Count; i++)
{
var a = polygon.Vertices[i];
var b = polygon.Vertices[i + 1];
if (a.Y <= point.Y && b.Y > point.Y && Cross(a, b, point) > 0)
winding++;
else if (a.Y > point.Y && b.Y <= point.Y && Cross(a, b, point) < 0)
winding--;
}
return winding != 0;
}
private static double Cross(Vector a, Vector b, Vector point) =>
(b.X - a.X) * (point.Y - a.Y) - (b.Y - a.Y) * (point.X - a.X);
}
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>
/// Reuses each clean drawing program's converted entities and prepared (validated, chorded,
/// triangulation-checked) material across overlap requests. Preparation dominates the cost of
/// drawings with many holes, and it depends only on the drawing, not on where parts sit, so a
/// recheck after moving parts only repeats the cheap pose transforms and pair clipping.
/// Entries are keyed by <see cref="Program"/> reference and released with it. A changed code
/// count or program rotation is detected, but that is not a geometry hash: call
/// <see cref="Clear"/> before any in-place edit of a clean program or its hole subprograms.
/// Capture on one thread at a time; prepared entries may be shared by concurrent analyses.
/// </summary>
public sealed class OverlapMaterialCache
{
private readonly ConditionalWeakTable<Program, OverlapSource> sources = new();
public void Clear() => sources.Clear();
internal OverlapSource Get(Program program, Func<Program, OverlapSource> create)
{
if (sources.TryGetValue(program, out var source) && source.Matches(program))
return source;
source = create(program);
sources.AddOrUpdate(program, source);
return source;
}
}
/// <summary>Owned converted entities for one clean program, plus its lazily prepared material.</summary>
internal sealed class OverlapSource
{
private readonly object gate = new();
private readonly int codeCount;
private readonly long rotation;
private PreparedMaterial prepared;
internal OverlapSource(Program program, List<Entity> entities, string error)
{
codeCount = program.Codes.Count;
rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
Entities = entities;
Error = error;
}
/// <summary>Never mutated; preparation clones before chaining.</summary>
internal List<Entity> Entities { get; }
internal string Error { get; }
internal bool Matches(Program program) =>
program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
/// <summary>
/// Prepares once and shares the result. A geometry failure is cached like a success;
/// cancellation is not, so a superseded request cannot poison the next one.
/// </summary>
internal PreparedMaterial Prepare(CancellationToken cancellationToken)
{
var current = Volatile.Read(ref prepared);
if (current != null)
return current;
lock (gate)
{
if (prepared != null)
return prepared;
PreparedMaterial result;
try
{
result = new PreparedMaterial(OverlapMaterial.Read(Entities, cancellationToken), null);
}
catch (Exception exception) when (PlateOverlapAnalyzer.IsGeometryFailure(exception))
{
result = new PreparedMaterial(null, exception.Message);
}
Volatile.Write(ref prepared, result);
return result;
}
}
}
/// <summary>Validated local-frame material, only ever read (transformed into new polygons).</summary>
internal sealed record PreparedMaterial(OverlapMaterial Material, string Error);
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
public static class OverlapPairPresentation
{
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
{
var units = UnitsHelper.GetShortString(capturedUnits);
return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
}
// Significant figures adapt to scale: a positive sliver must never read as zero.
public static string Number(double value, IFormatProvider provider = null) =>
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
public static double MarkerHalfSize(int deviceDpi) => 6.0 * deviceDpi / 96;
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
/// <summary>
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
/// use that same space; view zoom never scales the DPI-adjusted hit radius.
/// Coincident or nearby markers return every matching pair in stable ID order.
/// </summary>
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi)
{
var radius = HitRadius(deviceDpi);
return pairs.Where(pair =>
{
var center = worldToScreen(pair.Centroid);
var dx = pointer.X - center.X;
var dy = pointer.Y - center.Y;
return dx * dx + dy * dy <= radius * radius;
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
}
}
@@ -0,0 +1,111 @@
using System.Collections.Generic;
namespace OpenNest.Diagnostics;
public enum OverlapDisplayMode { Off, Areas, Centroids, Both }
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
public sealed class OverlapReportState
{
private OverlapGeometryStamp stamp;
private int uncheckedPartCount;
public long Generation { get; private set; }
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
public PlateOverlapReport Report { get; private set; }
public bool IsRunning => Status == OverlapCheckStatus.Checking;
public string Message => Status switch
{
OverlapCheckStatus.Checking => "Checking overlaps…",
OverlapCheckStatus.Current => Report.Pairs.Count == 0
? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
+ $"{uncheckedPartCount} parts could not be checked",
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
OverlapCheckStatus.Canceled => "Overlap check canceled",
OverlapCheckStatus.Stale => "Overlap check out of date — run Check Overlaps again",
_ => "Overlaps: not checked"
};
/// <summary>
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
/// the user's display choice, including Off.
/// </summary>
public long Begin(Plate plate, bool automatic = false)
{
Clear(OverlapCheckStatus.Checking);
stamp = OverlapGeometryStamp.Capture(plate);
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
DisplayMode = OverlapDisplayMode.Areas;
return Generation;
}
public bool TryPublish(long generation, Plate plate, PlateOverlapReport report)
{
if (!CanComplete(generation, plate))
return false;
Report = report;
uncheckedPartCount = CountUncheckedParts(report.Issues);
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
return true;
}
public bool TryFail(long generation, Plate plate)
{
if (!CanComplete(generation, plate))
return false;
Clear(OverlapCheckStatus.Failed);
return true;
}
private bool CanComplete(long generation, Plate plate) =>
generation == Generation && IsRunning && EnsureFresh(plate);
public bool EnsureFresh(Plate plate)
{
if (stamp == null)
return false;
if (stamp.Matches(plate))
return true;
Invalidate();
return false;
}
public void Invalidate()
{
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
Clear(OverlapCheckStatus.Stale);
}
public void Cancel()
{
if (IsRunning)
Clear(OverlapCheckStatus.Canceled);
}
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
private void Clear(OverlapCheckStatus status)
{
Generation++;
Report = null;
uncheckedPartCount = 0;
stamp = null;
Status = status;
}
public static int CountUncheckedParts(IEnumerable<PlateOverlapIssue> issues)
{
var ids = new HashSet<int>();
foreach (var issue in issues)
{
ids.Add(issue.PartAId);
if (issue.PartBId.HasValue)
ids.Add(issue.PartBId.Value);
}
return ids.Count;
}
}
@@ -0,0 +1,292 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>
/// Read-only, hole-aware material overlap diagnostics, separate from the engine's boolean checks.
/// Uses clean drawing outlines, not placed lead-in/tab toolpaths or spacing offsets.
/// </summary>
public static class PlateOverlapAnalyzer
{
public const double ChordTolerance = 0.001;
/// <summary>
/// Captures poses and converts each distinct clean source program to owned entities once.
/// Inputs must not change during capture. Later analysis never reads live domain objects.
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
CancellationToken cancellationToken = default) =>
Capture(parts, new OverlapMaterialCache(), cancellationToken);
/// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
/// and prepared drawing material from <paramref name="cache"/> across requests. Clear the
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(parts);
ArgumentNullException.ThrowIfNull(cache);
cancellationToken.ThrowIfCancellationRequested();
var captured = new List<CapturedOverlapPart>();
var issues = new List<PlateOverlapIssue>();
for (var id = 0; id < parts.Count; id++)
{
cancellationToken.ThrowIfCancellationRequested();
var part = parts[id];
if (part?.BaseDrawing?.IsCutOff == true)
continue;
try
{
if (part?.BaseDrawing?.Program == null)
throw new ArgumentException("Part has no clean drawing program.");
var program = part.BaseDrawing.Program;
var rotation = part.Rotation - program.Rotation;
var location = part.Location;
if (!double.IsFinite(rotation) || !OverlapMaterial.IsFinite(location))
throw new ArgumentException("Part pose must be finite.");
var source = cache.Get(program, CaptureSource);
if (source.Error != null)
throw new ArgumentException(source.Error);
captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name,
source, rotation, location));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
}
}
cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapSnapshot(captured, issues);
}
private static OverlapSource CaptureSource(Program program)
{
try
{
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
// Conversion creates fresh geometry, including expanded shared hole calls;
// no cloning/rotation of a live program or subprogram is necessary.
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
&& entity.Layer != SpecialLayers.Leadin
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
return new OverlapSource(program, null, exception.Message);
}
}
/// <summary>Convenience synchronous capture and analysis of a group of parts.</summary>
public static PlateOverlapReport Analyze(IReadOnlyList<Part> parts,
CancellationToken cancellationToken = default) =>
Analyze(Capture(parts, cancellationToken), cancellationToken);
/// <summary>
/// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
/// </summary>
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
CancellationToken cancellationToken = default) =>
Analyze(snapshot, null, cancellationToken);
/// <summary>
/// Incremental recheck: identical to a full analysis of <paramref name="snapshot"/>, but a
/// pair whose two parts are unchanged since <paramref name="previous"/> (same captured source
/// and bit-identical pose, in the same relative order) reuses that report's result instead
/// of being clipped again. After moving one part only its own neighbors are recomputed.
/// Reuse needs sources shared through one <see cref="OverlapMaterialCache"/>; otherwise every
/// pair is recomputed. Null <paramref name="previous"/> performs a full analysis.
/// </summary>
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, PlateOverlapReport previous,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
cancellationToken.ThrowIfCancellationRequested();
var reuse = PairReuse.Create(previous, snapshot);
var issues = snapshot.Issues.ToList();
var pairs = new List<PlateOverlapPair>();
var prepared = new List<PreparedPart>();
foreach (var part in snapshot.Parts)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
// Prepared material is shared by every part and request using this source.
var source = part.Source.Prepare(cancellationToken);
if (source.Error != null)
throw new ArgumentException(source.Error);
var material = source.Material.Transform(part.Rotation, part.Location);
prepared.Add(new PreparedPart(part, material));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
issues.Add(new PlateOverlapIssue(part.Id, null, exception.Message));
}
}
var sorted = prepared.OrderBy(part => part.Material.Outer.BoundingBox.Left)
.ThenBy(part => part.Input.Id).ToArray();
for (var i = 0; i < sorted.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var first = sorted[i];
var bounds = first.Material.Outer.BoundingBox;
for (var j = i + 1; j < sorted.Length; j++)
{
cancellationToken.ThrowIfCancellationRequested();
var second = sorted[j];
var otherBounds = second.Material.Outer.BoundingBox;
if (otherBounds.Left >= bounds.Right)
break;
if (otherBounds.Bottom >= bounds.Top || bounds.Bottom >= otherBounds.Top)
continue;
var a = first.Input.Id < second.Input.Id ? first : second;
var b = first.Input.Id < second.Input.Id ? second : first;
if (reuse != null && reuse.TryReuse(a.Input, b.Input, pairs, issues))
continue;
try
{
// Keep pair clipping arithmetic near the parts where possible, then
// restore output to world space. Triangulation itself also uses stable
// local-origin winding so tiny holes in a huge part remain correct.
var origin = a.Material.Outer.Vertices[0];
var localA = a.Material.Transform(0, origin * -1);
var localB = b.Material.Transform(0, origin * -1);
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
if (!result.Overlaps)
continue;
// Evaluate every hole-subtracted fragment before restoring world space.
// A failed moment must make this pair incomplete, never an origin marker.
var moments = new List<PolygonAreaMoments>();
var regions = new List<PlateOverlapRegion>();
foreach (var region in result.OverlapRegions)
{
cancellationToken.ThrowIfCancellationRequested();
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
throw new ArithmeticException("Overlap fragment area moments are invalid.");
moments.Add(fragment);
regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
fragment.Area));
}
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
throw new ArithmeticException("Combined overlap area moments are invalid.");
var centroid = combined.Centroid + origin;
if (!OverlapMaterial.IsFinite(centroid))
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
a.Input.Name, b.Input.Name, regions, centroid));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
issues.Add(new PlateOverlapIssue(a.Input.Id, b.Input.Id, exception.Message));
}
}
}
cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
}
/// <summary>
/// Maps unchanged parts to their previous input positions and looks up previous pair results.
/// A part is unchanged when its captured source object and exact pose bits match; identical
/// duplicates are matched in input order, which is safe because their inputs are bit-identical.
/// </summary>
private sealed class PairReuse
{
private readonly Dictionary<int, int> previousIds;
private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new();
private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new();
private PairReuse(Dictionary<int, int> previousIds, PlateOverlapReport previous)
{
this.previousIds = previousIds;
foreach (var pair in previous.Pairs)
pairs[(pair.PartAId, pair.PartBId)] = pair;
foreach (var issue in previous.Issues)
if (issue.PartBId.HasValue)
issues[(issue.PartAId, issue.PartBId.Value)] = issue;
}
public static PairReuse Create(PlateOverlapReport previous, PlateOverlapSnapshot snapshot)
{
if (previous?.Snapshot == null)
return null;
var available = new Dictionary<PoseKey, Queue<int>>();
foreach (var part in previous.Snapshot.Parts)
{
var key = PoseKey.Of(part);
if (!available.TryGetValue(key, out var ids))
available.Add(key, ids = new Queue<int>());
ids.Enqueue(part.Id);
}
var previousIds = new Dictionary<int, int>();
foreach (var part in snapshot.Parts)
if (available.TryGetValue(PoseKey.Of(part), out var ids) && ids.Count > 0)
previousIds.Add(part.Id, ids.Dequeue());
return previousIds.Count < 2 ? null : new PairReuse(previousIds, previous);
}
/// <summary>
/// Every bounding-box candidate pair among prepared parts was evaluated by the previous
/// analysis, and unchanged parts have identical bounds, so absence there means clear.
/// The previous pair must have had the same operand order: clipping is order-sensitive.
/// </summary>
public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b,
List<PlateOverlapPair> pairOutput, List<PlateOverlapIssue> issueOutput)
{
if (!previousIds.TryGetValue(a.Id, out var oldA) || !previousIds.TryGetValue(b.Id, out var oldB)
|| oldA >= oldB)
return false;
if (pairs.TryGetValue((oldA, oldB), out var pair))
pairOutput.Add(pair.Renumber(a.Id, b.Id, a.Name, b.Name));
else if (issues.TryGetValue((oldA, oldB), out var issue))
issueOutput.Add(issue with { PartAId = a.Id, PartBId = b.Id });
return true;
}
}
private readonly record struct PoseKey(OverlapSource Source, long Rotation, long X, long Y)
{
public static PoseKey Of(CapturedOverlapPart part) => new(part.Source,
BitConverter.DoubleToInt64Bits(part.Rotation),
BitConverter.DoubleToInt64Bits(part.Location.X),
BitConverter.DoubleToInt64Bits(part.Location.Y));
}
internal static bool IsGeometryFailure(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
private static void ValidateProgram(Program program, HashSet<Program> visiting)
{
if (program == null || !visiting.Add(program) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive, or excessively nested subprogram.");
foreach (var code in program.Codes)
{
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (code is Motion motion && !OverlapMaterial.IsFinite(motion.EndPoint)
|| code is ArcMove arc && !OverlapMaterial.IsFinite(arc.CenterPoint))
throw new ArgumentException("Program coordinates must be finite.");
if (code is SubProgramCall call)
{
if (!OverlapMaterial.IsFinite(call.Offset) || !double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram pose must be finite.");
ValidateProgram(call.Program, visiting);
}
}
visiting.Remove(program);
}
private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material);
}
@@ -0,0 +1,116 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true.</summary>
public sealed class PlateOverlapReport
{
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
PlateOverlapSnapshot snapshot)
{
Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly();
Snapshot = snapshot;
}
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
internal PlateOverlapSnapshot Snapshot { get; }
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
public bool IsComplete => Issues.Count == 0;
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
}
/// <summary>Shared material for two input positions, ordered by zero-based input index.</summary>
public sealed class PlateOverlapPair
{
private readonly Box bounds;
private PlateOverlapPair(PlateOverlapPair source, int partAId, int partBId, string partAName,
string partBName)
{
PartAId = partAId;
PartBId = partBId;
PartAName = partAName;
PartBName = partBName;
Regions = source.Regions;
Area = source.Area;
Centroid = source.Centroid;
bounds = source.bounds;
}
/// <summary>The same immutable geometry under new input positions and captured names.</summary>
internal PlateOverlapPair Renumber(int partAId, int partBId, string partAName, string partBName) =>
new(this, partAId, partBId, partAName, partBName);
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
List<PlateOverlapRegion> regions, Vector centroid)
{
PartAId = partAId;
PartBId = partBId;
PartAName = partAName;
PartBName = partBName;
Regions = regions.AsReadOnly();
Area = regions.Sum(region => region.Area);
Centroid = centroid;
var points = regions.SelectMany(region => region.Vertices).ToArray();
var left = points.Min(point => point.X);
var bottom = points.Min(point => point.Y);
bounds = new Box(left, bottom, points.Max(point => point.X) - left,
points.Max(point => point.Y) - bottom);
}
public int PartAId { get; }
public int PartBId { get; }
public string PartAName { get; }
public string PartBName { get; }
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
public double Area { get; }
/// <summary>
/// Finite world-coordinate area centroid of all shared material, after hole subtraction.
/// This can lie outside disconnected or concave shared material. Returned by value.
/// </summary>
public Vector Centroid { get; }
/// <summary>A fresh world-coordinate bounds copy.</summary>
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
/// <summary>A positive-area, hole-subtracted convex polygon in world coordinates.</summary>
public sealed class PlateOverlapRegion
{
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
{
Vertices = System.Array.AsReadOnly(vertices.ToArray());
Area = area;
}
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
public IReadOnlyList<Vector> Vertices { get; }
public double Area { get; }
}
/// <summary>An input or pair that could not be checked. IDs are zero-based input positions.</summary>
public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message);
/// <summary>
/// Owned clean geometry and poses. Capture while inputs are stable, then analyze on a worker.
/// No live Part, Drawing, Program, or subprogram is retained.
/// </summary>
public sealed class PlateOverlapSnapshot
{
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
{
Parts = parts.AsReadOnly();
Issues = issues.AsReadOnly();
}
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
}
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
double Rotation, Vector Location);
@@ -0,0 +1,290 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>
/// Read-only pre-post diagnostics. The caller must keep the nest stable for the entire call.
/// Placed programs are already rotated; only their placement translation is applied here.
/// This intentionally does not invoke a post or promise machine collision avoidance.
/// </summary>
public static class PostVerificationAnalyzer
{
public static PostVerificationReport AnalyzeForPost(Nest nest, IPostProcessor postProcessor,
CancellationToken cancellationToken = default)
{
var report = Analyze(nest, cancellationToken);
if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true })
return report;
var findings = report.Findings.ToList();
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
$"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " +
"and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review."));
return new PostVerificationReport(findings);
}
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(nest);
cancellationToken.ThrowIfCancellationRequested();
var findings = new List<PostVerificationFinding>();
if (nest.Plates == null)
{
findings.Add(new(PostVerificationKind.Incomplete, 1, null, null, "Nest has no plate collection."));
return new PostVerificationReport(findings);
}
for (var plateIndex = 0; plateIndex < nest.Plates.Count; plateIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
var plate = nest.Plates[plateIndex];
var plateNumber = plateIndex + 1;
if (plate?.Parts == null)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
"Plate has no part collection."));
continue;
}
var materialParts = new List<Part>();
var indices = new List<int>();
var obstacles = new List<Obstacle>();
Vector? position = Vector.Zero;
for (var index = 0; index < plate.Parts.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var part = plate.Parts[index];
var partNumber = index + 1;
var cutoff = part?.BaseDrawing?.IsCutOff == true;
var expectsCuts = cutoff;
// Preflight even clean programs before calling the overlap converter: malformed
// recursive graphs must never reach Program.Clone or unguarded conversion.
if (!cutoff)
{
try
{
var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken);
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|| expectsCuts)
{
materialParts.Add(part);
indices.Add(partNumber);
}
}
catch (Exception exception) when (IsInvalid(exception))
{
Incomplete("Overlap check: " + exception.Message);
}
}
try
{
if (part == null || !double.IsFinite(part.Rotation))
throw new ArgumentException("Missing part or invalid rotation.");
var moves = Read(part.Program, part.Location, position, cancellationToken);
if (expectsCuts && !moves.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
Incomplete("Placed program has no cutting contour motions for this drawing.");
AnalyzeMoves(moves, cutoff, obstacles, findings, plateNumber, partNumber, cancellationToken);
position = moves[^1].End;
}
catch (Exception exception) when (IsInvalid(exception))
{
Incomplete("Lead-in/rapid check: " + exception.Message);
// Subsequent internal moves can still be checked, but the incoming segment
// cannot be reconstructed after an invalid program.
position = null;
}
void Incomplete(string message) => findings.Add(new(PostVerificationKind.Incomplete,
plateNumber, partNumber, null, message));
}
cancellationToken.ThrowIfCancellationRequested();
var overlap = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
foreach (var issue in overlap.Issues)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
}
cancellationToken.ThrowIfCancellationRequested();
return new PostVerificationReport(findings);
}
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
private static List<Move> Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
{
var moves = new List<Move>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return moves;
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
private static void AnalyzeMoves(List<Move> moves, bool cutoff, List<Obstacle> obstacles,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
var hasLead = false;
var contourNumber = 0;
foreach (var move in moves)
{
token.ThrowIfCancellationRequested();
if (move.Rapid)
{
Finish();
hasLead = false;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
foreach (var obstacle in obstacles)
{
token.ThrowIfCancellationRequested();
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}."));
}
}
else if (move.Layer == LayerType.Leadin)
{
Finish();
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
}
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
{
if (move.Layer == LayerType.Leadout && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."));
Finish();
hasLead = false;
}
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
{
if (contour.Count == 0)
{
contourNumber++;
if (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var previous = unfinished.FindIndex(chain =>
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
{
contour.AddRange(unfinished[previous]);
unfinished.RemoveAt(previous);
}
}
contour.Add(move.Curve);
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
Finish();
}
}
Finish();
if (unfinished.Count > 1)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
void Finish()
{
if (contour.Count == 0)
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
else if (!cutoff)
unfinished.Add(contour.ToArray());
contour.Clear();
}
}
private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer,
PostVerificationGeometry.Curve Curve);
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
}
@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>Local native line/arc queries, without changing the engine's geometry semantics.</summary>
internal static class PostVerificationGeometry
{
internal const double Epsilon = 1e-8;
private const double TwoPi = 2 * System.Math.PI;
internal static void Validate(Vector point)
{
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|| System.Math.Abs(point.X) > 1e12 || System.Math.Abs(point.Y) > 1e12)
throw new ArgumentException("Nonfinite or numerically unsupported program coordinates.");
}
internal static bool Closed(IReadOnlyList<Curve> curves) => curves.Count > 0
&& curves[0].Start.DistanceTo(curves[^1].End) <= Epsilon;
internal static bool Crosses(Vector start, Vector end, IReadOnlyList<Curve> curves,
CancellationToken token)
{
var delta = end - start;
var length = start.DistanceTo(end);
if (length <= Epsilon)
return false;
var direction = delta * (1 / length);
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
if (curve.ContactAfterStart(start, direction, length))
return true;
}
// If there are no contacts except possibly departure, all open-segment points
// have the same inside/outside status. A midpoint catches travel entirely inside
// and departure into the interior, without flagging start-only outward contact.
var midpoint = start + delta * 0.5;
var inside = false;
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
if (curve.CrossesRay(midpoint))
inside = !inside;
}
return inside;
}
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
private static double Normalize(double angle)
{
angle %= TwoPi;
return angle < 0 ? angle + TwoPi : angle;
}
internal sealed class Curve
{
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
{
Start = start;
End = end;
Center = center;
Radius = radius;
Sweep = sweep;
}
internal Vector Start { get; }
internal Vector End { get; }
private Vector? Center { get; }
private double Radius { get; }
private double Sweep { get; }
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
{
if (center is not { } c)
return new Curve(start, end, null, 0, 0);
Validate(c);
var radius = start.DistanceTo(c);
if (radius <= Epsilon || !double.IsFinite(radius)
|| System.Math.Abs(radius - end.DistanceTo(c)) > Epsilon * System.Math.Max(1, radius))
throw new ArgumentException("Arc has zero or inconsistent radius.");
var a = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
var b = System.Math.Atan2(end.Y - c.Y, end.X - c.X);
var sweep = start.DistanceTo(end) <= Epsilon ? TwoPi
: Normalize(clockwise ? a - b : b - a);
return new Curve(start, end, c, radius, clockwise ? -sweep : sweep);
}
private double StartAngle => System.Math.Atan2(Start.Y - Center.Value.Y, Start.X - Center.Value.X);
private double Travel(double angle) => Normalize(Sweep < 0 ? StartAngle - angle : angle - StartAngle);
private bool OnArc(Vector point) => Travel(System.Math.Atan2(point.Y - Center.Value.Y,
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
{
if (Center is { } center)
{
// Intersect the actual circle, not an inscribed chord polygon: tangencies
// and short arcs must not vanish between tessellation vertices.
var relative = center - origin;
var projection = Dot(relative, direction);
var perpendicular = Cross(relative, direction);
var square = Radius * Radius - perpendicular * perpendicular;
if (square < -Epsilon * System.Math.Max(1, Radius * 2))
return false;
var offset = System.Math.Sqrt(System.Math.Max(0, square));
return Hit(projection - offset) || Hit(projection + offset);
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
}
var edge = End - Start;
var relativeStart = Start - origin;
var denominator = Cross(direction, edge);
if (System.Math.Abs(denominator) <= 1e-12 * System.Math.Max(1, Length))
{
if (System.Math.Abs(Cross(relativeStart, direction)) > Epsilon)
return false;
var a = Dot(relativeStart, direction);
var b = Dot(End - origin, direction);
var low = System.Math.Max(0, System.Math.Min(a, b));
var high = System.Math.Min(length, System.Math.Max(a, b));
return high > Epsilon && low <= high + Epsilon;
}
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon);
}
internal bool CrossesRay(Vector point)
{
if (Center is not { } center)
return (Start.Y > point.Y) != (End.Y > point.Y)
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
// half-open endpoint rule as a polygon ray test, solving X on the native
// circle. This handles full circles, reversed arcs and shared vertices.
var breaks = new List<double> { 0, System.Math.Abs(Sweep) };
foreach (var angle in new[] { System.Math.PI / 2, 3 * System.Math.PI / 2 })
{
var travel = Travel(angle);
if (travel > 0 && travel < System.Math.Abs(Sweep))
breaks.Add(travel);
}
breaks.Sort();
var inside = false;
for (var i = 1; i < breaks.Count; i++)
{
var a = StartAngle + System.Math.Sign(Sweep) * breaks[i - 1];
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
if ((ya > point.Y) == (yb > point.Y))
continue;
var dy = point.Y - center.Y;
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
var x = center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
if (x > point.X)
inside = !inside;
}
return inside;
}
}
}
@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace OpenNest.Diagnostics;
public enum PostVerificationKind
{
Overlap,
MissingLeadIn,
RapidCrossing,
Incomplete
}
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
int? PartNumber, int? OtherPartNumber, string Message);
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
public sealed class PostVerificationReport
{
internal PostVerificationReport(IEnumerable<PostVerificationFinding> findings)
{
Findings = Array.AsReadOnly(findings.ToArray());
}
public IReadOnlyList<PostVerificationFinding> Findings { get; }
public bool HasWarnings => Findings.Count != 0;
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
public string ToDisplayText()
{
var text = new StringBuilder();
text.AppendLine("Pre-post verification");
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
Summary(PostVerificationKind.Overlap, "Overlap");
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
foreach (var finding in Findings)
{
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
if (finding.PartNumber is { } part)
text.Append($", part {part}");
if (finding.OtherPartNumber is { } other)
text.Append($", other part {other}");
text.AppendLine($": {finding.Kind}: {finding.Message}");
}
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " +
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " +
"machine program and machine setup. Actual contour gaps are not proof of adequate retention.");
return text.ToString();
void Summary(PostVerificationKind kind, string label)
{
var count = Findings.Count(finding => finding.Kind == kind);
text.AppendLine($"{label}: {count} warning(s)" +
(incomplete ? "; verification incomplete — do not treat as clear." : "."));
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
namespace OpenNest;
/// <summary>
/// Captures drawing program text before an edit and rebuilds only the parts whose
/// drawing program changed. Drawing keys use reference identity because names are editable.
/// </summary>
public sealed class DrawingProgramSnapshot
{
private readonly Dictionary<Drawing, string> programs;
private readonly Func<Program, string> fingerprint;
private DrawingProgramSnapshot(
Dictionary<Drawing, string> programs,
Func<Program, string> fingerprint)
{
this.programs = programs;
this.fingerprint = fingerprint;
}
/// <summary>
/// Capture before handing drawings to an editor, including its load operation:
/// programs can be edited in place. The callback must include both the main
/// program text and its hole sub-programs, and must not mutate the program.
/// </summary>
public static DrawingProgramSnapshot Capture(
IEnumerable<Drawing> drawings,
Func<Program, string> fingerprint)
{
ArgumentNullException.ThrowIfNull(drawings);
ArgumentNullException.ThrowIfNull(fingerprint);
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
foreach (var drawing in drawings)
{
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
programs.Add(drawing, fingerprint(drawing.Program));
}
return new DrawingProgramSnapshot(programs, fingerprint);
}
/// <summary>
/// Complete the captured edit by rebuilding changed drawings' parts across all plates.
/// Part.Update preserves the placement and clears obsolete lead-ins, tabs and locks.
/// Unchanged and uncaptured drawings' parts retain their program instances and state.
/// Returns the rebuilt parts so a UI can invalidate just their graphics.
/// </summary>
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
{
ArgumentNullException.ThrowIfNull(plates);
var changed = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
foreach (var entry in programs)
{
if (!entry.Key.IsCutOff
&& !string.Equals(entry.Value, fingerprint(entry.Key.Program), StringComparison.Ordinal))
changed.Add(entry.Key);
}
var updated = new List<Part>();
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (!changed.Contains(part.BaseDrawing))
continue;
part.Update();
updated.Add(part);
}
}
return updated;
}
}
+125
View File
@@ -0,0 +1,125 @@
using System;
namespace OpenNest.Geometry
{
/// <summary>
/// Tuning for <see cref="DrawingAligner"/>. Every default is a design choice
/// under measurement, not a calibrated safe envelope: until calibration
/// establishes one, every alignment pair still requires operator confirmation.
/// Values are expressed in the declared units of the drawings being aligned.
/// </summary>
public sealed class AlignmentOptions
{
/// <summary>Chord-error tolerance used when flattening arcs for measurement.</summary>
public double FlattenTolerance { get; set; } = 0.01;
/// <summary>
/// Target arclength between resampled contour samples. Must resolve the
/// intended residual gate: a residual below roughly half this spacing is
/// not meaningful.
/// </summary>
public double SamplingSpacing { get; set; } = 0.5;
/// <summary>Upper bound on samples per contour ring.</summary>
public int MaxSamplesPerRing { get; set; } = 4000;
/// <summary>Upper bound on total samples across all rings of one drawing.</summary>
public int MaxTotalSamples { get; set; } = 20000;
/// <summary>Trim fraction: worst-distance correspondences excluded from each ICP fit.</summary>
public double TrimFraction { get; set; } = 0.10;
/// <summary>Fit iterations per candidate start.</summary>
public int MaxIterations { get; set; } = 40;
/// <summary>Pose change (radians) treated as converged.</summary>
public double RotationEpsilon { get; set; } = 1e-7;
/// <summary>Translation change in units treated as converged.</summary>
public double TranslationEpsilon { get; set; } = 1e-7;
/// <summary>
/// Correspondences farther than this count as unmatched (changed geometry),
/// not as fit error. Defaults to a generous envelope; the review gate
/// reports coverage instead of silently clamping it.
/// </summary>
public double OutlierDistance { get; set; } = 10.0;
/// <summary>Minimum distinct samples required to attempt a fit at all.</summary>
public int MinSamples { get; set; } = 8;
}
/// <summary>Why an alignment is not trustworthy. Absence of all flags is not a
/// calibrated safe envelope; it only means no review reason fired.</summary>
[Flags]
public enum AlignmentReasons
{
None = 0,
/// <summary>The fit stopped on an iteration/work limit or stagnated without converging.</summary>
FailedConvergence = 1,
/// <summary>The revised geometry has too few usable samples, too little outer
/// support, or too little of the target matched to it.</summary>
InsufficientSupport = 2,
/// <summary>Significant changed/unmatched boundary spans in either direction.</summary>
SignificantBoundaryChange = 4,
/// <summary>Two or more genuinely distinct poses fit near-equally (symmetry,
/// repeated features). The reported transform is still valid geometry.</summary>
UnresolvedAlternatives = 8,
/// <summary>Input geometry is invalid: nonfinite coordinates, degenerate or
/// unclosed rings, unsupported topology, zero usable perimeter.</summary>
InvalidGeometry = 16,
/// <summary>A reflected candidate fits as well as the best rigid one, or the
/// input cannot exclude reflection. Reflection is never applied silently.</summary>
ReflectionUncertain = 32,
}
/// <summary>
/// Structured outcome of aligning one revised drawing to one old drawing.
/// The transform maps NEW points into the OLD drawing-local frame:
/// reflect about the declared local axis (only when <see cref="Reflection"/> is
/// true, which the automatic aligner never sets), then rotate by
/// <see cref="Rotation"/> (radians), then translate by <see cref="Translation"/>.
/// Diagnostics are in the drawings' declared units. A diagnostic IoU is
/// bounded to [0, 1] and is not a calibrated probability.
/// </summary>
public sealed class AlignmentResult
{
public bool Converged { get; init; }
public double Rotation { get; init; }
public Vector Translation { get; init; }
public bool Reflection { get; init; }
/// <summary>Review reasons; combined across candidate selection. Empty does
/// not license skipping the operator overlay — no calibrated envelope
/// exists yet.</summary>
public AlignmentReasons Reasons { get; init; }
/// <summary>Trimmed RMS of matched sample-to-segment residuals.</summary>
public double ResidualRms { get; init; }
public double ResidualP50 { get; init; }
public double ResidualP90 { get; init; }
/// <summary>Fraction of NEW samples matching OLD within the outlier bound, and vice versa.</summary>
public double NewToOldCoverage { get; init; }
public double OldToNewCoverage { get; init; }
/// <summary>Number of distinct converged candidate poses clustered as equivalent.</summary>
public int EquivalentCandidateCount { get; init; }
public int Iterations { get; init; }
public int NewSampleCount { get; init; }
public int OldSampleCount { get; init; }
/// <summary>Bounded diagnostic intersection-over-union of the material regions,
/// or null when regions are unavailable or degenerate. Never a gate input.</summary>
public double? DiagnosticIoU { get; init; }
public string FailureMessage { get; init; }
}
}
+22
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
@@ -118,6 +119,27 @@ namespace OpenNest.Geometry
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
}
/// <summary>
/// Sums signed angular change traversing consecutive points around a center.
/// Positive = CCW, negative = CW.
/// </summary>
public static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI)
da -= Angle.TwoPI;
while (da < -System.Math.PI)
da += Angle.TwoPI;
total += da;
}
return total;
}
/// <summary>
/// Computes the maximum radial deviation of interior points from a circle.
/// </summary>
+336
View File
@@ -0,0 +1,336 @@
using System;
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// Signed clearance between two closed polygons, plus the unit direction that
/// increases it by moving the first polygon.
/// </summary>
public struct ClearanceResult
{
/// <summary>
/// &gt; 0: minimum boundary distance. 0: touching. &lt; 0: penetration depth
/// (the translation of <c>a</c> along <see cref="Direction"/> needed to end
/// contact).
/// </summary>
public double Distance;
/// <summary>
/// Unit direction for translating <c>a</c> away from <c>b</c>. For penetration
/// this is the minimum-translation direction. Never zero-length; degenerate
/// (coincident-centroid) penetration resolves to a deterministic axis.
/// </summary>
public Vector Direction;
public ClearanceResult(double distance, Vector direction)
{
Distance = distance;
Direction = direction;
}
}
/// <summary>
/// Omnidirectional clearance between two closed, lines-only polygons.
/// Complements <see cref="SpatialQuery.DirectionalDistance"/> (movement along a
/// fixed ray) with the all-directions minimum distance and separating direction,
/// and <see cref="Collision"/> (boolean overlap) with depth and direction.
/// <para>
/// Reference quality, not hot-loop quality: separation is a brute-force
/// segment-pair minimum with a bounding-box reject, penetration is a
/// separating-axis sweep over both polygons' edge normals. The overlap verdict
/// defers to <see cref="Collision.HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
/// so callers that validate with Collision never see a disagreeing kernel.
/// Rings with holes are handled by the caller: pass every ring pair (a part's
/// material boundary is its outer ring plus its hole rings).
/// </para>
/// </summary>
public static class Clearance
{
public static ClearanceResult Between(Polygon a, Polygon b)
{
var linesA = a.ToLines();
var linesB = b.ToLines();
if (linesA.Count == 0 || linesB.Count == 0)
return new ClearanceResult(0, new Vector(1, 0));
if (Collision.HasOverlap(a, b))
return Penetration(linesA, linesB);
return Separation(linesA, linesB);
}
/// <summary>
/// Non-negative minimum boundary (edge-to-edge) distance between two rings
/// and the direction that translates <paramref name="a"/> away from
/// <paramref name="b"/> at the closest contact. Never tests overlap: a ring
/// contained in another (a part inside a cutout ring) still reports its true
/// gap. For signed material clearance use <see cref="Between"/>.
/// </summary>
public static ClearanceResult BoundaryDistance(Polygon a, Polygon b)
{
var linesA = a.ToLines();
var linesB = b.ToLines();
if (linesA.Count == 0 || linesB.Count == 0)
return new ClearanceResult(0, new Vector(1, 0));
return Separation(linesA, linesB);
}
/// <summary>
/// Minimum boundary distance between two non-overlapping rings and the
/// direction that translates <paramref name="linesA"/> away from
/// <paramref name="linesB"/> at the closest contact.
/// </summary>
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
{
var minDist = double.MaxValue;
var pa = Vector.Zero;
var pb = Vector.Zero;
var boxes = new Box[linesB.Count];
for (var i = 0; i < linesB.Count; i++)
boxes[i] = SegmentBox(linesB[i]);
foreach (var la in linesA)
{
var boxA = SegmentBox(la);
for (var i = 0; i < linesB.Count; i++)
{
if (!BoxesWithin(boxA, boxes[i], minDist))
continue;
var d = SegmentDistance(la, linesB[i], out var qa, out var qb);
if (d < minDist)
{
minDist = d;
pa = qa;
pb = qb;
}
}
}
var dir = pa - pb;
var len = Magnitude(dir);
if (len <= Tolerance.Epsilon)
dir = CentroidAway(linesA, linesB);
else
dir = dir / len;
return new ClearanceResult(minDist, dir);
}
/// <summary>
/// Penetration depth and minimum-translation direction along the separating-
/// axis candidates of both rings. Per candidate axis the true translation
/// depth is used (exit distance to the far side), so containment reports the
/// depth that actually ends contact, not the interval-intersection length.
/// Depth is reported as a negative clearance.
/// </summary>
private static ClearanceResult Penetration(List<Line> linesA, List<Line> linesB)
{
var ca = Centroid(linesA);
var cb = Centroid(linesB);
var bestDepth = double.MaxValue;
var bestDir = new Vector(1, 0);
var bestAxis = -1;
for (var axis = 0; axis < 2; axis++)
{
var lines = axis == 0 ? linesA : linesB;
foreach (var line in lines)
{
var edge = line.pt2 - line.pt1;
var n = new Vector(edge.Y, -edge.X);
var len = Magnitude(n);
if (len <= Tolerance.Epsilon)
continue;
n = n / len;
var (minA, maxA) = Project(linesA, n);
var (minB, maxB) = Project(linesB, n);
if (maxA <= minB || maxB <= minA)
continue; // separating axis found
// Depth pushing a away from b along ±n.
var forward = maxB - minA; // move a in +n until minA >= maxB
var backward = maxA - minB; // move a in -n until maxA <= minB
double depth;
Vector dir;
if (forward <= backward)
{
depth = forward;
dir = n;
}
else
{
depth = backward;
dir = -n;
}
if (depth < bestDepth - Tolerance.Epsilon || bestAxis < 0)
{
bestDepth = depth;
bestDir = dir;
bestAxis = axis;
}
}
}
if (bestAxis < 0)
{
// No candidate axis (degenerate rings): deterministic fallback.
var away = ca - cb;
var len = Magnitude(away);
bestDir = len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
bestDepth = 0;
}
return new ClearanceResult(-bestDepth, bestDir);
}
private static Vector CentroidAway(List<Line> linesA, List<Line> linesB)
{
var away = Centroid(linesA) - Centroid(linesB);
var len = Magnitude(away);
return len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
}
private static Vector Centroid(List<Line> lines)
{
var sum = Vector.Zero;
foreach (var line in lines)
{
sum += line.pt1;
sum += line.pt2;
}
return sum / (2 * lines.Count);
}
private static (double Min, double Max) Project(List<Line> lines, Vector n)
{
var min = double.MaxValue;
var max = double.MinValue;
foreach (var line in lines)
{
var d1 = line.pt1.DotProduct(n);
var d2 = line.pt2.DotProduct(n);
if (d1 < min)
min = d1;
if (d1 > max)
max = d1;
if (d2 < min)
min = d2;
if (d2 > max)
max = d2;
}
return (min, max);
}
/// <summary>
/// Minimum distance between two segments with the closest points.
/// Non-parallel segments use the classic clamped closest-point solve;
/// (near-)parallel segments fall back to the four endpoint-to-segment
/// distances, which is where the minimum always lies.
/// </summary>
private static double SegmentDistance(Line a, Line b, out Vector pa, out Vector pb)
{
var p = a.pt1;
var r = a.pt2 - a.pt1;
var q = b.pt1;
var s = b.pt2 - b.pt1;
var rxr = r.DotProduct(r);
var sxs = s.DotProduct(s);
var rxs = r.DotProduct(s);
const double eps = 1e-12;
var denom = rxr * sxs - rxs * rxs;
if (denom > eps && rxr > eps && sxs > eps)
{
// Minimize |(p + r t) - (q + s u)|^2; setting both partials to
// zero and solving (Cramer) with d0 = p - q:
// t = ((r.s)(d0.s) - (d0.r)(s.s)) / (rr.ss - (r.s)^2)
// u = ((r.r)(d0.s) - (r.s)(d0.r)) / (rr.ss - (r.s)^2)
var d0 = p - q;
var d0r = d0.DotProduct(r);
var d0s = d0.DotProduct(s);
var t = Clamp((rxs * d0s - d0r * sxs) / denom, 0, 1);
var u = Clamp((rxs * t + d0s) / sxs, 0, 1); // nearest u on b for clamped t
t = Clamp((rxs * u - d0r) / rxr, 0, 1); // re-solve t for clamped u
pa = p + r * t;
pb = q + s * u;
return pa.DistanceTo(pb);
}
// Degenerate or parallel: the minimum is attained at an endpoint.
var bestPa = p;
var bestPb = q;
var best = double.MaxValue;
void Consider(Vector pt, Line seg, bool ptOnA)
{
var d = seg.pt2 - seg.pt1;
var len2 = d.DotProduct(d);
var u = len2 <= eps ? 0 : Clamp((pt - seg.pt1).DotProduct(d) / len2, 0, 1);
var on = seg.pt1 + d * u;
var dist = pt.DistanceTo(on);
if (dist < best)
{
best = dist;
bestPa = ptOnA ? pt : on;
bestPb = ptOnA ? on : pt;
}
}
Consider(p, b, true);
Consider(a.pt2, b, true);
Consider(q, a, false);
Consider(b.pt2, a, false);
pa = bestPa;
pb = bestPb;
return best;
}
private static double Clamp(double v, double lo, double hi) =>
v < lo ? lo : (v > hi ? hi : v);
private static double Magnitude(Vector v) => System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
private static Box SegmentBox(Line line)
{
return new Box(
System.Math.Min(line.pt1.X, line.pt2.X),
System.Math.Min(line.pt1.Y, line.pt2.Y),
System.Math.Abs(line.pt2.X - line.pt1.X),
System.Math.Abs(line.pt2.Y - line.pt1.Y)
);
}
private static bool BoxesWithin(Box a, Box b, double distance)
{
return !(
a.Right + distance < b.Left
|| b.Right + distance < a.Left
|| a.Top + distance < b.Bottom
|| b.Top + distance < a.Bottom
);
}
}
}
+385
View File
@@ -0,0 +1,385 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// One world-space point on a cut contour together with the cut direction there.
/// Produced by <see cref="ContourSampler"/> for rendering (cut-direction arrows)
/// and for measurement (contour alignment); neither caller may mutate it.
/// </summary>
public readonly struct ContourSample
{
/// <summary>Point on the contour in world coordinates.</summary>
public Vector Position { get; }
/// <summary>Unit vector pointing in the direction of travel along the contour.</summary>
public Vector Direction { get; }
/// <summary>
/// World-frame tangent angle in radians (atan2 of <see cref="Direction"/>).
/// Screen-space conversion is the renderer's job.
/// </summary>
public double Tangent { get; }
/// <summary>
/// Arclength of this sample from the start of the contour walk it came from:
/// cumulative distance along non-rapid, non-suppressed moves for a program
/// walk, and from the ring's first vertex for a ring walk.
/// </summary>
public double At { get; }
public ContourSample(Vector position, Vector direction, double tangent, double at)
{
Position = position;
Direction = direction;
Tangent = tangent;
At = at;
}
}
/// <summary>
/// Pure contour sampling math shared by the cut-direction arrow renderer and by
/// contour alignment. All positions and tangents are world-space; no screen
/// conversion, no arrowheads, and no view state appear here.
/// <para>
/// Two scheduling policies live here deliberately:
/// <see cref="LineMoves"/> and <see cref="ArcMoves"/> implement the arrow
/// renderer's established display policy (skip moves shorter than half the
/// spacing, place <c>max(1, trunc(len/spacing))</c> arrows strictly inside each
/// move, resetting per move), while <see cref="RingMoves"/> is a contour-wide
/// arclength scheduler for measurement: distance is carried across segment
/// boundaries, no segment is omitted, and the closing vertex of a ring is never
/// duplicated. Alignment must use the ring policy; display zoom must never
/// change an alignment result because alignment spacing comes from the model,
/// not the view.
/// </para>
/// </summary>
public static class ContourSampler
{
/// <summary>
/// Samples one bounded line move using the arrow display policy: no samples
/// when the move is shorter than half the spacing or degenerate; otherwise
/// <c>max(1, (int)(length / spacing))</c> samples strictly between the
/// endpoints at uniform spacing. Appends to <paramref name="output"/>.
/// </summary>
public static void LineMoves(
Vector start,
Vector end,
double spacing,
List<ContourSample> output
)
{
var dx = end.X - start.X;
var dy = end.Y - start.Y;
var length = System.Math.Sqrt(dx * dx + dy * dy);
if (length < spacing * 0.5)
return;
var dirX = dx / length;
var dirY = dy / length;
var tangent = System.Math.Atan2(dirY, dirX);
var count = System.Math.Max(1, (int)(length / spacing));
var step = length / (count + 1);
for (var i = 1; i <= count; i++)
{
var t = step * i;
var pt = new Vector(start.X + dirX * t, start.Y + dirY * t);
output.Add(new ContourSample(pt, new Vector(dirX, dirY), tangent, t));
}
}
/// <summary>
/// Samples one bounded arc move using the arrow display policy. The sweep is
/// taken in the requested rotation direction and always in (0, 2*PI], so a
/// full circle (equal endpoints) yields a full turn. No samples when the arc
/// is shorter than half the spacing or the radius is degenerate. Tangents
/// follow the direction of travel: +90 degrees from the radius for CCW,
/// -90 degrees for CW. Appends to <paramref name="output"/>.
/// </summary>
public static void ArcMoves(
Vector start,
Vector end,
Vector center,
RotationType rotation,
double spacing,
List<ContourSample> output
)
{
var radius = center.DistanceTo(start);
if (radius < Tolerance.Epsilon)
return;
var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
double sweep;
if (rotation == RotationType.CCW)
{
sweep = endAngle - startAngle;
if (sweep <= 0)
sweep += 2 * System.Math.PI;
}
else
{
sweep = startAngle - endAngle;
if (sweep <= 0)
sweep += 2 * System.Math.PI;
}
var arcLength = radius * System.Math.Abs(sweep);
if (arcLength < spacing * 0.5)
return;
var count = System.Math.Max(1, (int)(arcLength / spacing));
var stepAngle = sweep / (count + 1);
for (var i = 1; i <= count; i++)
{
double angle;
if (rotation == RotationType.CCW)
angle = startAngle + stepAngle * i;
else
angle = startAngle - stepAngle * i;
var pt = new Vector(
center.X + radius * System.Math.Cos(angle),
center.Y + radius * System.Math.Sin(angle)
);
double tangent;
if (rotation == RotationType.CCW)
tangent = angle + System.Math.PI / 2;
else
tangent = angle - System.Math.PI / 2;
var dir = new Vector(System.Math.Cos(tangent), System.Math.Sin(tangent));
var at = radius * System.Math.Abs(stepAngle * i);
output.Add(new ContourSample(pt, dir, tangent, at));
}
}
/// <summary>
/// Walks a CNC program in world space with the same traversal policy the cut
/// direction renderer has always used: absolute endpoints are relative to
/// <paramref name="basePos"/>, incremental endpoints and arc centers are
/// relative to the current position, suppressed moves and rapids advance the
/// pen but produce no samples, and each sub-program call executes at
/// <c>basePos + Offset</c> against a shared program (callers own the shared
/// program; this method only reads it). Suppressed sub-program content is
/// filtered inside the sub-program itself.
/// </summary>
/// <returns>The pen position after the program, so callers keep the
/// reference semantics of the renderer's by-ref position.</returns>
public static Vector ProgramMoves(
Program pgm,
Vector basePos,
Vector pos,
double spacing,
List<ContourSample> output
)
{
var at = 0.0;
WalkProgram(pgm, basePos, ref pos, spacing, output, ref at);
return pos;
}
private static void WalkProgram(
Program pgm,
Vector basePos,
ref Vector pos,
double spacing,
List<ContourSample> output,
ref double at
)
{
for (var i = 0; i < pgm.Length; ++i)
{
var code = pgm[i];
if (code.Type == CodeType.SubProgramCall)
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program != null)
{
var holeBase = basePos + subpgm.Offset;
pos = holeBase;
WalkProgram(
subpgm.Program,
holeBase,
ref pos,
spacing,
output,
ref at
);
}
continue;
}
if (code is not Motion motion)
continue;
var endpt =
pgm.Mode == Mode.Incremental
? motion.EndPoint + pos
: motion.EndPoint + basePos;
if (code.Type == CodeType.LinearMove)
{
var line = (LinearMove)code;
if (!line.Suppressed)
{
var before = output.Count;
LineMoves(pos, endpt, spacing, output);
Relocate(output, before, at);
at += Distance(pos, endpt);
}
}
else if (code.Type == CodeType.ArcMove)
{
var arc = (ArcMove)code;
if (!arc.Suppressed)
{
var center =
pgm.Mode == Mode.Incremental
? arc.CenterPoint + pos
: arc.CenterPoint + basePos;
var before = output.Count;
ArcMoves(pos, endpt, center, arc.Rotation, spacing, output);
Relocate(output, before, at);
at += ArcDistance(pos, endpt, center, arc.Rotation);
}
}
pos = endpt;
}
}
/// <summary>
/// Resamples a closed ring at near-uniform arclength for measurement. The
/// distance counter is carried across segment boundaries, no segment is
/// omitted, and the closing vertex is not duplicated: samples sit at
/// arclength <c>i * step</c> for <c>i in [0, count)</c> where
/// <c>step = perimeter / count</c> divides the perimeter exactly, so the
/// sample set is invariant to where the ring's start vertex sits as long as
/// the caller quantizes consistently. A duplicated explicit closing vertex
/// is accepted and ignored.
/// </summary>
/// <exception cref="ArgumentException">
/// The ring has fewer than three distinct vertices, nonfinite coordinates,
/// or a zero perimeter.
/// </exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="spacing"/> is not finite or not positive.</exception>
public static void RingMoves(IList<Vector> ring, double spacing, List<ContourSample> output)
{
if (ring == null)
throw new ArgumentNullException(nameof(ring));
if (!(spacing > 0) || double.IsInfinity(spacing) || double.IsNaN(spacing))
throw new ArgumentOutOfRangeException(nameof(spacing));
var n = ring.Count;
if (n > 1 && ring[0] == ring[n - 1])
n--; // ignore an explicit closing vertex; the ring closes implicitly
if (n < 3)
throw new ArgumentException("Ring needs at least 3 distinct vertices.", nameof(ring));
var perimeter = 0.0;
for (var i = 0; i < n; i++)
{
var a = ring[i];
var b = ring[(i + 1) % n];
if (double.IsNaN(a.X) || double.IsNaN(a.Y) || double.IsNaN(b.X) || double.IsNaN(b.Y))
throw new ArgumentException("Ring contains nonfinite coordinates.", nameof(ring));
perimeter += Distance(a, b);
}
if (!(perimeter > Tolerance.Epsilon))
throw new ArgumentException("Ring has zero perimeter.", nameof(ring));
var count = System.Math.Max(1, (int)System.Math.Round(perimeter / spacing));
var step = perimeter / count;
var seg = 0;
var segStart = 0.0; // cumulative arclength at the start of segment seg
for (var k = 0; k < count; k++)
{
var s = step * k;
// Carry the walk across segment boundaries; short segments advance
// the arclength counter without ever being skipped.
var a = ring[seg];
var b = ring[(seg + 1) % n];
var segLen = Distance(a, b);
while (s > segStart + segLen && seg + 1 < n)
{
segStart += segLen;
seg++;
a = ring[seg];
b = ring[(seg + 1) % n];
segLen = Distance(a, b);
}
var local = segLen > 0 ? (s - segStart) / segLen : 0.0;
var dir = SegmentDirection(a, b);
var pt = new Vector(a.X + (b.X - a.X) * local, a.Y + (b.Y - a.Y) * local);
output.Add(new ContourSample(pt, dir, System.Math.Atan2(dir.Y, dir.X), s));
}
}
private static Vector SegmentDirection(Vector a, Vector b)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
var len = System.Math.Sqrt(dx * dx + dy * dy);
return len > 0 ? new Vector(dx / len, dy / len) : new Vector(1, 0);
}
private static double Distance(Vector a, Vector b)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
return System.Math.Sqrt(dx * dx + dy * dy);
}
private static double ArcDistance(
Vector start,
Vector end,
Vector center,
RotationType rotation
)
{
var radius = center.DistanceTo(start);
if (radius < Tolerance.Epsilon)
return 0.0;
// Same sweep convention as ArcMoves: always in (0, 2*PI], so a full
// circle counts its whole circumference toward the walk's arclength.
var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
var sweep =
rotation == RotationType.CCW ? endAngle - startAngle : startAngle - endAngle;
if (sweep <= 0)
sweep += 2 * System.Math.PI;
return radius * sweep;
}
private static void Relocate(List<ContourSample> output, int from, double baseAt)
{
if (baseAt == 0.0)
return;
for (var i = from; i < output.Count; i++)
{
var s = output[i];
output[i] = new ContourSample(
s.Position,
s.Direction,
s.Tangent,
baseAt + s.At
);
}
}
}
}
@@ -148,14 +148,12 @@ namespace OpenNest.Geometry
/// </summary>
private static double SignedArea(List<Vector> verts)
{
// World-coordinate products can erase the sign of a small polygon's area
// far from the origin, leaving CW outlines/holes untriangulated. Measure
// relative to a vertex, just as the clipping kernel measures its fragments.
var area = 0.0;
for (var i = 0; i < verts.Count; i++)
{
var j = (i + 1) % verts.Count;
area += verts[i].X * verts[j].Y;
area -= verts[j].X * verts[i].Y;
}
for (var i = 1; i + 1 < verts.Count; i++)
area += Cross(verts[0], verts[i], verts[i + 1]);
return area * 0.5;
}
File diff suppressed because it is too large Load Diff
+13 -20
View File
@@ -108,8 +108,18 @@ namespace OpenNest.Geometry
nameof(tolerance),
"Tolerance must be positive."
);
if (semiMajor <= 0 || semiMinor <= 0)
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
if (semiMajor <= 0)
throw new ArgumentOutOfRangeException(
nameof(semiMajor),
semiMajor,
"Semi-major axis length must be positive."
);
if (semiMinor <= 0)
throw new ArgumentOutOfRangeException(
nameof(semiMinor),
semiMinor,
"Semi-minor axis length must be positive."
);
if (endParam <= startParam)
endParam += Angle.TwoPI;
@@ -320,7 +330,7 @@ namespace OpenNest.Geometry
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
var points = new List<Vector> { p0, pMid, p1 };
var isReversed = SumSignedAngles(arcCenter, points) < 0;
var isReversed = ArcFit.SumSignedAngles(arcCenter, points) < 0;
if (startAngle < 0)
startAngle += Angle.TwoPI;
@@ -329,22 +339,5 @@ namespace OpenNest.Geometry
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
}
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI)
da -= Angle.TwoPI;
while (da < -System.Math.PI)
da += Angle.TwoPI;
total += da;
}
return total;
}
}
}
+4 -25
View File
@@ -435,7 +435,7 @@ public class GeometrySimplifier
// Reject arcs that subtend a tiny angle — these are nearly-straight lines
// that happen to fit a huge circle. Applied after extension so that many small
// segments can accumulate enough sweep to qualify.
var sweep = System.Math.Abs(SumSignedAngles(center, points));
var sweep = System.Math.Abs(ArcFit.SumSignedAngles(center, points));
if (sweep < Angle.ToRadians(5))
return null;
@@ -454,7 +454,7 @@ public class GeometrySimplifier
continue;
// Check that the arc doesn't bulge away from the original line segments
var isReversed = SumSignedAngles(center, points) < 0;
var isReversed = ArcFit.SumSignedAngles(center, points) < 0;
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
if (arcDev > Tolerance)
continue;
@@ -673,7 +673,7 @@ public class GeometrySimplifier
var lastPt = points[^1];
var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y;
var sign = SumSignedAngles(center, points) >= 0 ? 1 : -1;
var sign = ArcFit.SumSignedAngles(center, points) >= 0 ? 1 : -1;
return new Vector(-sign * ry, sign * rx);
}
@@ -792,7 +792,7 @@ public class GeometrySimplifier
var endAngle = NormalizeAngle(
System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X)
);
var isReversed = SumSignedAngles(center, points) < 0;
var isReversed = ArcFit.SumSignedAngles(center, points) < 0;
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
arc.Layer = sourceEntity.Layer;
@@ -816,27 +816,6 @@ public class GeometrySimplifier
_ => Vector.Invalid,
};
/// <summary>
/// Sums signed angular change traversing consecutive points around a center.
/// Positive = CCW, negative = CW.
/// </summary>
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI)
da -= Angle.TwoPI;
while (da < -System.Math.PI)
da += Angle.TwoPI;
total += da;
}
return total;
}
/// <summary>
/// Measures the maximum distance from sampled points along the fitted arc
/// back to the original line segments. This catches cases where points lie
+304
View File
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
namespace OpenNest.Geometry;
/// <summary>
/// Maximal empty axis-aligned rectangles: rectangles of free space that cannot grow in any
/// direction. The first result is the largest by area.
/// </summary>
public static class MaximalRectangles
{
/// <summary>
/// Finds maximal axis-aligned rectangles that lie wholly inside a region, such as a cutout
/// already shrunk by the part spacing. Rectangles may touch the region's boundary but never
/// cross it.
/// </summary>
/// <remarks>
/// The grid has a line through every vertex coordinate plus <paramref name="divisions"/>
/// evenly spaced lines per axis. A cell is free only when no edge passes through its
/// interior and its centre is inside the region, so results are exact for regions whose
/// edges are all horizontal or vertical. Slanted and curved edges are followed as a
/// staircase: results stay inside, but can fall short of the true maximum by up to about
/// one cell on each side. Rotate the region to search other rectangle angles.
/// </remarks>
/// <param name="region">Closed, non-crossing paths, as returned by a Clipper Boolean or offset.
/// A point is inside when an odd number of paths enclose it, so holes are subtracted.</param>
/// <param name="minDimension">Rectangles narrower than this in either axis are dropped.</param>
/// <param name="divisions">Even subdivisions of the region's bounds per axis, which bound the
/// staircase loss along slanted edges.</param>
/// <returns>Rectangles not contained in another result, largest area first.</returns>
public static List<Box> InRegion(PathsD region, double minDimension = 0, int divisions = 64)
{
ArgumentNullException.ThrowIfNull(region);
ArgumentOutOfRangeException.ThrowIfLessThan(divisions, 1);
var paths = region.Where(path => path.Count >= 3).ToList();
if (paths.Count == 0)
return new List<Box>();
if (paths.Any(path => path.Any(point => !double.IsFinite(point.x) || !double.IsFinite(point.y))))
throw new ArgumentException("Region coordinates must be finite.", nameof(region));
var bounds = Clipper.GetBounds(new PathsD(paths));
var xs = GridLines(paths.SelectMany(path => path).Select(point => point.x), bounds.left, bounds.right, divisions);
var ys = GridLines(paths.SelectMany(path => path).Select(point => point.y), bounds.top, bounds.bottom, divisions);
if (xs.Count < 2 || ys.Count < 2)
return new List<Box>();
var rows = ys.Count - 1;
var cols = xs.Count - 1;
var crossed = new bool[rows, cols];
foreach (var path in paths)
{
var previous = path[^1];
foreach (var current in path)
{
MarkCrossedCells(previous, current, xs, ys, crossed);
previous = current;
}
}
var empty = new bool[rows, cols];
var crossings = new List<double>();
for (var r = 0; r < rows; r++)
{
// Even-odd scan along the row's centre line. No vertex lies on it, and an edge that
// meets it strictly inside a cell has already marked that cell crossed, so each
// uncrossed cell is on the same side as its centre.
var y = (ys[r] + ys[r + 1]) / 2;
crossings.Clear();
foreach (var path in paths)
{
var previous = path[^1];
foreach (var current in path)
{
if ((previous.y > y) != (current.y > y))
crossings.Add(previous.x + (y - previous.y) * (current.x - previous.x) / (current.y - previous.y));
previous = current;
}
}
crossings.Sort();
var passed = 0;
for (var c = 0; c < cols; c++)
{
var x = (xs[c] + xs[c + 1]) / 2;
while (passed < crossings.Count && crossings[passed] < x)
passed++;
empty[r, c] = !crossed[r, c] && passed % 2 == 1;
}
}
return FromGrid(xs, ys, empty, minDimension);
}
/// <summary>
/// Finds the maximal rectangles of empty cells in a rectilinear grid, using the histogram
/// method: for each row, a height histogram of consecutive empty cells below it, scanned
/// with a stack.
/// </summary>
/// <param name="xs">Ascending column boundaries; column c spans xs[c] to xs[c + 1].</param>
/// <param name="ys">Ascending row boundaries; row r spans ys[r] to ys[r + 1].</param>
/// <param name="empty">Free cells, indexed [row, column].</param>
/// <param name="minDimension">Rectangles narrower than this in either axis are dropped.</param>
/// <returns>Rectangles not contained in another result, largest area first.</returns>
public static List<Box> FromGrid(
IReadOnlyList<double> xs,
IReadOnlyList<double> ys,
bool[,] empty,
double minDimension = 0
)
{
var merged = MergeCells(xs, ys, empty);
var sized = FilterBySize(merged, minDimension);
return RemoveDominated(sized);
}
private static List<double> GridLines(IEnumerable<double> vertices, double min, double max, int divisions)
{
var lines = new SortedSet<double>(vertices);
var exact = lines.ToList();
var step = (max - min) / divisions;
for (var i = 1; i < divisions; i++)
{
// Skip even lines that would only cut a sliver off a vertex line.
var line = min + i * step;
var index = exact.BinarySearch(line);
if (index >= 0)
continue;
index = ~index;
var near = (index > 0 && line - exact[index - 1] < Math.Tolerance.Epsilon)
|| (index < exact.Count && exact[index] - line < Math.Tolerance.Epsilon);
if (!near)
lines.Add(line);
}
return lines.ToList();
}
/// <summary>Marks every cell whose open interior a slanted edge passes through.</summary>
private static void MarkCrossedCells(PointD a, PointD b, List<double> xs, List<double> ys, bool[,] crossed)
{
// Edges along a grid line touch cells without entering them; vertex coordinates
// are grid lines, so every horizontal or vertical edge lies on one.
if (a.x == b.x || a.y == b.y)
return;
var c0 = xs.BinarySearch(System.Math.Min(a.x, b.x));
var c1 = xs.BinarySearch(System.Math.Max(a.x, b.x));
var r0 = ys.BinarySearch(System.Math.Min(a.y, b.y));
var r1 = ys.BinarySearch(System.Math.Max(a.y, b.y));
for (var r = r0; r < r1; r++)
{
for (var c = c0; c < c1; c++)
{
if (!crossed[r, c] && EntersInterior(a, b, xs[c], ys[r], xs[c + 1], ys[r + 1]))
crossed[r, c] = true;
}
}
}
/// <summary>
/// Clips the segment to the closed cell (Liang-Barsky). A segment that enters the open
/// interior has the midpoint of its clipped piece strictly inside; one that only touches
/// a side or corner does not.
/// </summary>
private static bool EntersInterior(PointD a, PointD b, double left, double bottom, double right, double top)
{
var dx = b.x - a.x;
var dy = b.y - a.y;
var t0 = 0.0;
var t1 = 1.0;
if (
!Clip(-dx, a.x - left, ref t0, ref t1)
|| !Clip(dx, right - a.x, ref t0, ref t1)
|| !Clip(-dy, a.y - bottom, ref t0, ref t1)
|| !Clip(dy, top - a.y, ref t0, ref t1)
)
return false;
var t = (t0 + t1) / 2;
var x = a.x + t * dx;
var y = a.y + t * dy;
return x > left && x < right && y > bottom && y < top;
}
private static bool Clip(double p, double q, ref double t0, ref double t1)
{
if (p == 0)
return q >= 0;
var ratio = q / p;
if (p < 0)
{
if (ratio > t1)
return false;
if (ratio > t0)
t0 = ratio;
}
else
{
if (ratio < t0)
return false;
if (ratio < t1)
t1 = ratio;
}
return true;
}
private static List<Box> MergeCells(IReadOnlyList<double> xs, IReadOnlyList<double> ys, bool[,] empty)
{
var rows = empty.GetLength(0);
var cols = empty.GetLength(1);
var height = new int[rows, cols];
for (var c = 0; c < cols; c++)
{
for (var r = 0; r < rows; r++)
height[r, c] = empty[r, c] ? (r > 0 ? height[r - 1, c] + 1 : 1) : 0;
}
var candidates = new List<Box>();
for (var r = 0; r < rows; r++)
{
var stack = new Stack<(int startCol, int h)>();
for (var c = 0; c <= cols; c++)
{
var h = c < cols ? height[r, c] : 0;
var startCol = c;
while (stack.Count > 0 && stack.Peek().h > h)
{
var top = stack.Pop();
startCol = top.startCol;
candidates.Add(
new Box(
xs[top.startCol],
ys[r - top.h + 1],
xs[c] - xs[top.startCol],
ys[r + 1] - ys[r - top.h + 1]
)
);
}
if (h > 0)
stack.Push((startCol, h));
}
}
return candidates;
}
private static List<Box> FilterBySize(List<Box> boxes, double minDimension)
{
if (minDimension <= 0)
return boxes;
var result = new List<Box>();
foreach (var box in boxes)
{
if (box.Width >= minDimension && box.Length >= minDimension)
result.Add(box);
}
return result;
}
private static List<Box> RemoveDominated(List<Box> boxes)
{
boxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
var results = new List<Box>();
foreach (var box in boxes)
{
var dominated = false;
foreach (var larger in results)
{
if (IsContainedIn(box, larger))
{
dominated = true;
break;
}
}
if (!dominated)
results.Add(box);
}
return results;
}
private static bool IsContainedIn(Box inner, Box outer)
{
var eps = Math.Tolerance.Epsilon;
return inner.Left >= outer.Left - eps
&& inner.Right <= outer.Right + eps
&& inner.Bottom >= outer.Bottom - eps
&& inner.Top <= outer.Top + eps;
}
}
@@ -0,0 +1,101 @@
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// Positive area and area centroid of a simple polygon, independent of winding.
/// Moments are evaluated about a nearby origin rather than the world origin.
/// </summary>
public readonly struct PolygonAreaMoments
{
private PolygonAreaMoments(double area, Vector centroid)
{
Area = area;
Centroid = centroid;
}
public double Area { get; }
public Vector Centroid { get; }
/// <summary>
/// Accepts an open vertex list or an exactly repeated closing vertex. Returns false
/// for degenerate or nonfinite moments; does not validate polygon topology.
/// </summary>
public static bool TryCompute(IReadOnlyList<Vector> vertices, out PolygonAreaMoments moments)
{
moments = default;
if (vertices == null || vertices.Count < 3)
return false;
foreach (var point in vertices)
if (!IsFinite(point))
return false;
var origin = vertices[0];
var count = vertices.Count;
if (vertices[count - 1].X == origin.X && vertices[count - 1].Y == origin.Y)
count--;
if (count < 3)
return false;
var twiceArea = 0.0;
var momentX = 0.0;
var momentY = 0.0;
// The closing edges meet the local origin and contribute zero. The signed
// triangle fan also handles concavity without averaging polygon vertices.
for (var i = 1; i + 1 < count; i++)
{
var a = vertices[i] - origin;
var b = vertices[i + 1] - origin;
var cross = a.X * b.Y - a.Y * b.X;
twiceArea += cross;
momentX += (a.X + b.X) * cross;
momentY += (a.Y + b.Y) * cross;
}
var area = System.Math.Abs(twiceArea) * 0.5;
if (!double.IsFinite(area) || area <= 0
|| !double.IsFinite(momentX) || !double.IsFinite(momentY))
return false;
// Dividing signed moments by signed area cancels the winding, while Area
// stays positive so independently wound fragments always add material.
var centroid = origin + new Vector(momentX / twiceArea / 3, momentY / twiceArea / 3);
if (!IsFinite(centroid))
return false;
moments = new PolygonAreaMoments(area, centroid);
return true;
}
/// <summary>
/// Combines nonoverlapping, already hole-subtracted fragments using positive area
/// weights and another local origin. The centroid may lie outside the material.
/// Empty input or any invalid fragment fails the entire result.
/// </summary>
public static bool TryCombine(IEnumerable<PolygonAreaMoments> fragments, out PolygonAreaMoments moments)
{
moments = default;
if (fragments == null)
return false;
var area = 0.0;
var origin = Vector.Zero;
var firstMoment = Vector.Zero;
foreach (var fragment in fragments)
{
if (!double.IsFinite(fragment.Area) || fragment.Area <= 0 || !IsFinite(fragment.Centroid))
return false;
if (area == 0)
origin = fragment.Centroid;
firstMoment += (fragment.Centroid - origin) * fragment.Area;
area += fragment.Area;
}
if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
return false;
var centroid = origin + firstMoment / area;
if (!IsFinite(centroid))
return false;
moments = new PolygonAreaMoments(area, centroid);
return true;
}
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
}
+831
View File
@@ -0,0 +1,831 @@
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
internal enum ContactSide
{
/// <summary>The boundary could not be decomposed into closed loops.</summary>
Unresolved,
/// <summary>The point is not on the boundary: a tolerance near-miss, not a contact.</summary>
Off,
/// <summary>Several boundary runs meet here, or the corner is a cusp or spike.</summary>
Ambiguous,
/// <summary>The material sector is known.</summary>
Sector,
}
/// <summary>
/// Closed boundary loops of one entity list, prepared so a directional slide can tell
/// which side of each boundary point is material. Immutable after
/// <see cref="Prepare"/>, so one instance may be shared by concurrent queries.
/// </summary>
/// <remarks>
/// Loops are recovered from contiguous runs whose end points chain back to their start
/// (the order produced by <see cref="ShapeBuilder"/> and the offset helpers). Nesting
/// depth decides holes: material is inside even-depth loops and outside odd-depth ones.
/// When the list cannot be decomposed that way, every contact query is unresolved.
/// </remarks>
public sealed class SlideContactGeometry
{
// Contact points are computed from unsnapped ray parameters, so a genuine contact is
// on both boundaries to floating-point accuracy. This also bounds the overlap sliver a
// tangential classification can admit, so keep it far below spacing tolerances.
internal const double IncidenceTolerance = 1e-7;
private readonly List<Entity> entities;
private readonly int[] loopOf;
private readonly int[] previous;
private readonly int[] following;
private readonly bool[] materialLeft;
private SlideContactGeometry(
List<Entity> entities,
int[] loopOf,
int[] previous,
int[] following,
bool[] materialLeft
)
{
this.entities = entities;
this.loopOf = loopOf;
this.previous = previous;
this.following = following;
this.materialLeft = materialLeft;
}
/// <summary>True when every entity belongs to a closed loop with a known material side.</summary>
public bool IsResolved => materialLeft != null;
public static SlideContactGeometry Prepare(List<Entity> entities)
{
var count = entities.Count;
var loopOf = new int[count];
var previous = new int[count];
var following = new int[count];
var loops = new List<(int First, int Last)>();
var i = 0;
while (i < count)
{
var first = i;
if (entities[i] is Circle)
{
i++;
}
else
{
if (!TryEndpoints(entities[i], out var start, out _))
return Unresolved(entities);
var closed = false;
while (i < count && TryEndpoints(entities[i], out _, out var end))
{
// A lone closed arc is a loop; a lone line cannot be, even when it
// has zero length and so ends where it starts.
if (Near(end, start) && (i > first || entities[i] is Arc))
{
closed = true;
i++;
break;
}
if (
i + 1 >= count
|| !TryEndpoints(entities[i + 1], out var nextStart, out _)
|| !Near(nextStart, end)
)
break;
i++;
}
if (!closed)
return Unresolved(entities);
}
var loop = loops.Count;
loops.Add((first, i - 1));
for (var k = first; k < i; k++)
{
loopOf[k] = loop;
previous[k] = k == first ? i - 1 : k - 1;
following[k] = k == i - 1 ? first : k + 1;
}
}
var materialLeft = new bool[loops.Count];
for (var loop = 0; loop < loops.Count; loop++)
{
var area = SignedArea(entities, loops[loop].First, loops[loop].Last);
if (System.Math.Abs(area) <= Tolerance.Epsilon)
return Unresolved(entities);
var depth = 0;
if (loops.Count > 1)
{
var sample = SamplePoint(entities[loops[loop].First]);
for (var other = 0; other < loops.Count; other++)
{
if (other == loop)
continue;
if (Contains(entities, loops[other].First, loops[other].Last, sample))
depth++;
}
}
materialLeft[loop] = (area > 0) == (depth % 2 == 0);
}
return new SlideContactGeometry(entities, loopOf, previous, following, materialLeft);
}
private static SlideContactGeometry Unresolved(List<Entity> entities) =>
new SlideContactGeometry(entities, null, null, null, null);
/// <summary>
/// Material directions at a boundary point: an angular sector starting at
/// <paramref name="start"/> and sweeping CCW by <paramref name="width"/>.
/// Concavity is recorded separately at each sector ray: only the supporting
/// curve, not an unrelated curve at that corner, can block a tangential slide.
/// Entities wholly inside the incidence tolerance are treated as part of the corner.
/// </summary>
internal ContactSide GetMaterialSector(
Vector point,
out double start,
out double width,
out bool startConcave,
out bool endConcave
)
{
start = width = 0;
startConcave = endConcave = false;
if (materialLeft == null)
return ContactSide.Unresolved;
var best = -1;
var bestDistance = double.MaxValue;
for (var i = 0; i < entities.Count; i++)
{
var distance = DistanceTo(entities[i], point);
if (distance < bestDistance)
{
bestDistance = distance;
best = i;
}
}
if (best < 0 || bestDistance > IncidenceTolerance)
return ContactSide.Off;
// Walk to the entities that enter and leave the tolerance disc.
var loopLength = LoopLength(best);
var incoming = best;
var steps = 0;
var smoothLoop = loopLength == 1 && (entities[best] is Circle
|| entities[best] is Arc fullArc && fullArc.IsFullCircle());
while (!smoothLoop && StartsNear(incoming, point))
{
incoming = previous[incoming];
if (++steps >= loopLength)
return ContactSide.Ambiguous;
}
var outgoing = best;
steps = 0;
while (!smoothLoop && EndsNear(outgoing, point))
{
outgoing = following[outgoing];
if (++steps >= loopLength)
return ContactSide.Ambiguous;
}
// Anything else touching this point (another loop, a spike, a self-crossing)
// makes the local material side ambiguous.
for (var i = 0; i < entities.Count; i++)
{
if (InRun(i, incoming, outgoing))
continue;
if (DistanceTo(entities[i], point) <= IncidenceTolerance)
return ContactSide.Ambiguous;
}
var interior = incoming == best && outgoing == best && !EndsNear(best, point);
var inTangent = interior ? TangentAt(entities[best], point) : EndTangent(entities[incoming]);
var outTangent = interior
? inTangent
: StartTangent(entities[outgoing]);
// A circle has no endpoints, so its point is always interior.
if (smoothLoop)
inTangent = outTangent = TangentAt(entities[best], point);
if (IsZero(inTangent) || IsZero(outTangent))
return ContactSide.Ambiguous;
var outAngle = System.Math.Atan2(outTangent.Y, outTangent.X);
var inAngle = System.Math.Atan2(-inTangent.Y, -inTangent.X);
var left = materialLeft[loopOf[best]];
start = left ? outAngle : inAngle;
width = Angle.NormalizeRad((left ? inAngle : outAngle) - start);
startConcave = IsConcave(entities[left ? outgoing : incoming], left);
endConcave = IsConcave(entities[left ? incoming : outgoing], left);
return
width > SlideContact.AngleTolerance
&& width < Angle.TwoPI - 2 * SlideContact.SplitOverlap
? ContactSide.Sector
: ContactSide.Ambiguous;
}
private int LoopLength(int index)
{
var length = 1;
for (var i = following[index]; i != index; i = following[i])
length++;
return length;
}
private bool StartsNear(int index, Vector point) =>
TryEndpoints(entities[index], out var start, out _)
&& start.DistanceTo(point) <= IncidenceTolerance;
private bool EndsNear(int index, Vector point) =>
TryEndpoints(entities[index], out _, out var end)
&& end.DistanceTo(point) <= IncidenceTolerance;
private bool InRun(int index, int first, int last)
{
for (var i = first; ; i = following[i])
{
if (i == index)
return true;
if (i == last)
return false;
}
}
private static bool IsZero(Vector v) => v.X == 0 && v.Y == 0;
private static bool IsConcave(Entity entity, bool materialLeft)
{
// A CCW curve has its center on its left; that center is on the free side
// (a concave boundary) exactly when material is on the right.
return entity switch
{
Arc arc => materialLeft == arc.IsReversed,
Circle circle => materialLeft == (circle.Rotation == RotationType.CW),
_ => false,
};
}
private static Vector StartTangent(Entity entity) =>
entity switch
{
Line line => Direction(line.pt1, line.pt2),
Arc arc => ArcTangent(arc.StartAngle, arc.IsReversed),
_ => new Vector(),
};
private static Vector EndTangent(Entity entity) =>
entity switch
{
Line line => Direction(line.pt1, line.pt2),
Arc arc => ArcTangent(arc.EndAngle, arc.IsReversed),
_ => new Vector(),
};
private static Vector TangentAt(Entity entity, Vector point) =>
entity switch
{
Line line => Direction(line.pt1, line.pt2),
Arc arc => ArcTangent(arc.Center.AngleTo(point), arc.IsReversed),
Circle circle => ArcTangent(
circle.Center.AngleTo(point),
circle.Rotation == RotationType.CW
),
_ => new Vector(),
};
private static Vector ArcTangent(double angle, bool clockwise)
{
var sign = clockwise ? -1.0 : 1.0;
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
}
private static Vector Direction(Vector from, Vector to)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
var length = System.Math.Sqrt(dx * dx + dy * dy);
return length > 0 ? new Vector(dx / length, dy / length) : new Vector();
}
private static double DistanceTo(Entity entity, Vector point)
{
switch (entity)
{
case Line line:
return point.DistanceTo(line.ClosestPointTo(point));
case Arc arc:
{
var angle = arc.Center.AngleTo(point);
if (Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
return System.Math.Abs(arc.Center.DistanceTo(point) - arc.Radius);
return System.Math.Min(
point.DistanceTo(arc.StartPoint()),
point.DistanceTo(arc.EndPoint())
);
}
case Circle circle:
return System.Math.Abs(circle.Center.DistanceTo(point) - circle.Radius);
default:
return double.MaxValue;
}
}
private static bool TryEndpoints(Entity entity, out Vector start, out Vector end)
{
switch (entity)
{
case Line line:
start = line.pt1;
end = line.pt2;
return true;
case Arc arc:
start = arc.StartPoint();
end = arc.EndPoint();
return true;
default:
start = end = new Vector();
return false;
}
}
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
private static double SignedArea(List<Entity> entities, int first, int last)
{
var area = 0.0;
for (var i = first; i <= last; i++)
{
switch (entities[i])
{
case Circle circle:
var sign = circle.Rotation == RotationType.CW ? -1 : 1;
area += sign * System.Math.PI * circle.Radius * circle.Radius;
break;
case Line line:
area += Cross(line.pt1, line.pt2) / 2;
break;
case Arc arc:
var sweep = arc.IsReversed ? -arc.SweepAngle() : arc.SweepAngle();
var r = arc.Radius;
area += Cross(arc.StartPoint(), arc.EndPoint()) / 2;
area += r * r / 2 * (sweep - System.Math.Sin(sweep));
break;
}
}
return area;
}
private static double Cross(Vector a, Vector b) => a.X * b.Y - b.X * a.Y;
private static Vector SamplePoint(Entity entity) =>
entity switch
{
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
Arc arc => arc.StartPoint(),
Line line => line.pt1,
_ => new Vector(),
};
// Exact horizontal-ray parity. Split arcs at Y extrema so every piece is
// monotone; the same half-open endpoint rule as lines avoids seam double counts.
// A coarse inscribed polygon can misclassify thin rings as solid material.
private static bool Contains(List<Entity> entities, int first, int last, Vector point)
{
var inside = false;
for (var i = first; i <= last; i++)
{
if (entities[i] is Circle circle)
return circle.Center.DistanceTo(point) < circle.Radius;
if (entities[i] is Line line)
{
var a = line.pt1;
var b = line.pt2;
if ((a.Y > point.Y) != (b.Y > point.Y)
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
inside = !inside;
}
else if (entities[i] is Arc arc)
{
var sweep = arc.SweepAngle();
var sign = arc.IsReversed ? -1.0 : 1.0;
var cuts = new List<double> { 0, sweep };
foreach (var extreme in new[] { Angle.HalfPI, 3 * Angle.HalfPI })
{
var t = Angle.NormalizeRad(sign * (extreme - arc.StartAngle));
if (t > 0 && t < sweep)
cuts.Add(t);
}
cuts.Sort();
for (var k = 1; k < cuts.Count; k++)
{
var a = arc.StartAngle + sign * cuts[k - 1];
var b = arc.StartAngle + sign * cuts[k];
var y1 = arc.Center.Y + arc.Radius * System.Math.Sin(a);
var y2 = arc.Center.Y + arc.Radius * System.Math.Sin(b);
if ((y1 > point.Y) == (y2 > point.Y))
continue;
var dy = point.Y - arc.Center.Y;
var dx = System.Math.Sqrt(System.Math.Max(0, arc.Radius * arc.Radius - dy * dy));
var x = arc.Center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
if (point.X < x)
inside = !inside;
}
}
}
return inside;
}
}
/// <summary>
/// Contact classifier for one moving/stationary pair of boundaries. Geometry is prepared
/// on first use, so a slide whose nearest contact is never classified pays nothing; call
/// <see cref="Prepare"/> before sharing one instance across threads. Each boundary is
/// given in its own frame; the origins place those frames in the world coordinates used
/// by slide events.
/// </summary>
public sealed class SlideContactClassifier
{
private readonly System.Func<List<Entity>> movingSource;
private readonly System.Func<List<Entity>> stationarySource;
private SlideContactGeometry moving;
private SlideContactGeometry stationary;
public SlideContactClassifier(List<Entity> movingEntities, List<Entity> stationaryEntities)
: this(movingEntities, Vector.Zero, stationaryEntities, Vector.Zero) { }
public SlideContactClassifier(
List<Entity> movingEntities,
Vector movingOrigin,
List<Entity> stationaryEntities,
Vector stationaryOrigin
)
: this(() => movingEntities, movingOrigin, () => stationaryEntities, stationaryOrigin)
{ }
public SlideContactClassifier(
SlideContactGeometry moving,
Vector movingOrigin,
SlideContactGeometry stationary,
Vector stationaryOrigin
)
{
this.moving = moving;
this.stationary = stationary;
MovingOrigin = movingOrigin;
StationaryOrigin = stationaryOrigin;
}
private SlideContactClassifier(
System.Func<List<Entity>> movingSource,
Vector movingOrigin,
System.Func<List<Entity>> stationarySource,
Vector stationaryOrigin
)
{
this.movingSource = movingSource;
this.stationarySource = stationarySource;
MovingOrigin = movingOrigin;
StationaryOrigin = stationaryOrigin;
}
public Vector MovingOrigin { get; }
public Vector StationaryOrigin { get; }
public static SlideContactClassifier FromLines(
List<Line> movingLines,
Vector movingOrigin,
List<Line> stationaryLines,
Vector stationaryOrigin
) =>
new SlideContactClassifier(
() => new List<Entity>(movingLines),
movingOrigin,
() => new List<Entity>(stationaryLines),
stationaryOrigin
);
public static SlideContactClassifier FromEdges(
(Vector start, Vector end)[] movingEdges,
Vector movingOrigin,
(Vector start, Vector end)[] stationaryEdges,
Vector stationaryOrigin
)
{
// The kernel sorts edge arrays in place, so snapshot the chain order now.
var moving = ((Vector start, Vector end)[])movingEdges.Clone();
var stationary = ((Vector start, Vector end)[])stationaryEdges.Clone();
return new SlideContactClassifier(
() => ToLines(moving),
movingOrigin,
() => ToLines(stationary),
stationaryOrigin
);
}
private static List<Entity> ToLines((Vector start, Vector end)[] edges)
{
var lines = new List<Entity>(edges.Length);
foreach (var (start, end) in edges)
lines.Add(new Line(start, end));
// Public edge arrays are sorted in place by previous queries. Recover their
// chains on private line objects; never reverse or reorder caller geometry.
var ordered = new List<Entity>(lines.Count);
foreach (var shape in ShapeBuilder.GetShapes(lines))
ordered.AddRange(shape.Entities);
return ordered;
}
public SlideContactClassifier Prepare()
{
moving ??= SlideContactGeometry.Prepare(movingSource?.Invoke() ?? new List<Entity>());
stationary ??= SlideContactGeometry.Prepare(
stationarySource?.Invoke() ?? new List<Entity>()
);
return this;
}
/// <summary>The same prepared boundaries placed at other origins.</summary>
public SlideContactClassifier At(Vector movingOrigin, Vector stationaryOrigin)
{
Prepare();
return new SlideContactClassifier(moving, movingOrigin, stationary, stationaryOrigin);
}
/// <summary>
/// True when moving along (dirX, dirY) from this world-space contact would push
/// material into material, or the contact cannot be classified.
/// </summary>
public bool Blocks(Vector movingPoint, Vector stationaryPoint, double dirX, double dirY)
{
Prepare();
return SlideContact.Blocks(
moving,
movingPoint - MovingOrigin,
stationary,
stationaryPoint - StationaryOrigin,
dirX,
dirY
);
}
}
/// <summary>Receives candidate contact events from a directional slide query.</summary>
public interface ISlideEventSink
{
/// <summary>True once further events cannot change this sink's result.</summary>
bool IsDone { get; }
/// <param name="distance">Travel to the contact, snapped to zero within Tolerance.Epsilon.</param>
/// <param name="movingPoint">Contact on the moving boundary, at its start position.</param>
/// <param name="stationaryPoint">Contact on the stationary boundary.</param>
void Add(double distance, Vector movingPoint, Vector stationaryPoint);
}
/// <summary>
/// Enumerates every candidate contact of one slide. Must yield the same events each
/// time it is enumerated.
/// </summary>
public interface ISlideEventSource
{
void Enumerate<TSink>(ref TSink sink)
where TSink : struct, ISlideEventSink;
}
/// <summary>Keeps the nearest event; stops at a contact that is already touching.</summary>
public struct NearestSlideEvent : ISlideEventSink
{
public bool Found;
public double Distance;
public Vector MovingPoint;
public Vector StationaryPoint;
public bool IsDone => Found && Distance <= 0;
public void Add(double distance, Vector movingPoint, Vector stationaryPoint)
{
if (Found && distance >= Distance)
return;
Found = true;
Distance = distance;
MovingPoint = movingPoint;
StationaryPoint = stationaryPoint;
}
}
internal struct SlideEventList : ISlideEventSink
{
public List<(double Distance, Vector MovingPoint, Vector StationaryPoint)> Events;
public bool IsDone => false;
public void Add(double distance, Vector movingPoint, Vector stationaryPoint) =>
Events.Add((distance, movingPoint, stationaryPoint));
}
public static class SlideResolver
{
/// <summary>
/// Travel to the first contact that blocks the slide, or double.MaxValue. When the
/// nearest contact blocks (every contact, for unresolved boundaries), the result is
/// exactly the nearest event distance and the events are enumerated once.
/// </summary>
public static double FirstBlocking<TSource>(
ref TSource source,
SlideContactClassifier contacts,
double dirX,
double dirY
)
where TSource : struct, ISlideEventSource
{
var nearest = new NearestSlideEvent();
source.Enumerate(ref nearest);
if (!nearest.Found)
return double.MaxValue;
if (contacts.Blocks(nearest.MovingPoint, nearest.StationaryPoint, dirX, dirY))
return nearest.Distance;
var all = new SlideEventList
{
Events = new List<(double, Vector, Vector)>(),
};
source.Enumerate(ref all);
all.Events.Sort((a, b) => a.Distance.CompareTo(b.Distance));
foreach (var (distance, movingPoint, stationaryPoint) in all.Events)
{
if (contacts.Blocks(movingPoint, stationaryPoint, dirX, dirY))
return distance;
}
return double.MaxValue;
}
}
/// <summary>
/// Decides whether a first-contact event found by a directional slide stops the slide.
/// </summary>
/// <remarks>
/// Parts that already touch may slide along each other or apart. Only a direction that
/// would create positive-area overlap blocks: with S the stationary material sector and
/// M the moving one at the contact point, that is the open Minkowski cone S ⊕ −M.
/// A direction on that cone's boundary is a tangential slide; it blocks only when an
/// incident curve is concave, because the second-order bend then closes the gap.
/// Unresolved or ambiguous topology blocks, which is the previous behavior for every
/// contact.
/// </remarks>
public static class SlideContact
{
internal const double AngleTolerance = 1e-7;
// Reflex sectors are split into two overlapping convex halves; the overlap keeps
// the split ray in the interior of the union.
internal const double SplitOverlap = 1e-3;
/// <summary>
/// True when moving along (dirX, dirY) from this contact would push material into
/// material, or when the contact cannot be classified. False for a near-miss whose
/// point is not on both boundaries.
/// </summary>
/// <param name="movingPoint">Contact point in the moving entities' own frame.</param>
/// <param name="stationaryPoint">The same contact in the stationary frame.</param>
public static bool Blocks(
SlideContactGeometry moving,
Vector movingPoint,
SlideContactGeometry stationary,
Vector stationaryPoint,
double dirX,
double dirY
)
{
if (moving == null || stationary == null)
return true;
var stationarySide = stationary.GetMaterialSector(
stationaryPoint,
out var stationaryStart,
out var stationaryWidth,
out var stationaryStartConcave,
out var stationaryEndConcave
);
var movingSide = moving.GetMaterialSector(
movingPoint,
out var movingStart,
out var movingWidth,
out var movingStartConcave,
out var movingEndConcave
);
if (stationarySide == ContactSide.Unresolved || movingSide == ContactSide.Unresolved)
return true;
// Ray tolerances report hits slightly beyond an entity's end; such a point is
// not on the other boundary, so the parts pass without touching there.
if (stationarySide == ContactSide.Off || movingSide == ContactSide.Off)
return false;
if (stationarySide == ContactSide.Ambiguous || movingSide == ContactSide.Ambiguous)
return true;
var direction = System.Math.Atan2(dirY, dirX);
var stationaryPieces = Split(stationaryStart, stationaryWidth);
var movingPieces = Split(movingStart + System.Math.PI, movingWidth);
var onBoundary = false;
foreach (var s in stationaryPieces)
{
foreach (var m in movingPieces)
{
if (!TryHull(s, m, out var hullStart, out var hullWidth))
return true;
var offset = Angle.NormalizeRad(direction - hullStart);
if (offset > AngleTolerance && offset < hullWidth - AngleTolerance)
return true;
if (
offset <= AngleTolerance
|| offset >= Angle.TwoPI - AngleTolerance
|| System.Math.Abs(offset - hullWidth) <= AngleTolerance
)
onBoundary = true;
}
}
return onBoundary && (
stationaryStartConcave && SameRay(direction, stationaryStart)
|| stationaryEndConcave && SameRay(direction, stationaryStart + stationaryWidth)
|| movingStartConcave && SameRay(direction, movingStart + System.Math.PI)
|| movingEndConcave && SameRay(direction, movingStart + movingWidth + System.Math.PI));
}
private static bool SameRay(double a, double b)
{
var offset = Angle.NormalizeRad(a - b);
return offset <= AngleTolerance || offset >= Angle.TwoPI - AngleTolerance;
}
private static (double Start, double Width)[] Split(double start, double width)
{
if (width <= System.Math.PI + AngleTolerance)
return new[] { (start, width) };
var half = width / 2;
return new[]
{
(start, half + SplitOverlap),
(start + half - SplitOverlap, half + SplitOverlap),
};
}
/// <summary>
/// Convex cone generated by two convex sectors. False when it is the whole plane.
/// </summary>
private static bool TryHull(
(double Start, double Width) a,
(double Start, double Width) b,
out double start,
out double width
)
{
var fromA = System.Math.Max(a.Width, Angle.NormalizeRad(b.Start - a.Start) + b.Width);
var fromB = System.Math.Max(b.Width, Angle.NormalizeRad(a.Start - b.Start) + a.Width);
if (fromA <= fromB)
{
start = a.Start;
width = fromA;
}
else
{
start = b.Start;
width = fromB;
}
return width <= System.Math.PI + AngleTolerance;
}
}
}
@@ -0,0 +1,54 @@
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>Shared curve primitives for raw distance queries and slide contact events.</summary>
internal static class SlideCurvePrimitives
{
internal static bool ContainsContactAngle(Arc arc, double radius, double x, double y)
{
// A zero-radius curve is a point: its angular range has no geometric meaning.
if (arc == null || radius == 0)
return true;
var angle = Angle.NormalizeRad(System.Math.Atan2(y, x));
return Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed);
}
/// <summary>Returns both ray-circle parameters, before forward filtering or epsilon snapping.</summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
)]
internal static bool SolveRayCircle(
double vx,
double vy,
double cx,
double cy,
double r,
double dirX,
double dirY,
out double t1,
out double t2
)
{
var ox = vx - cx;
var oy = vy - cy;
var a = dirX * dirX + dirY * dirY;
var b = 2.0 * (ox * dirX + oy * dirY);
var c = ox * ox + oy * oy - r * r;
var discriminant = b * b - 4.0 * a * c;
if (discriminant < 0)
{
t1 = t2 = double.MaxValue;
return false;
}
var sqrtD = System.Math.Sqrt(discriminant);
var inv2a = 1.0 / (2.0 * a);
t1 = (-b - sqrtD) * inv2a;
t2 = (-b + sqrtD) * inv2a;
return true;
}
}
}
+784
View File
@@ -0,0 +1,784 @@
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// Candidate contact events of directional slides. Each emitter reports every forward
/// hit its distance kernel considers, with the distance snapped exactly as that kernel
/// snaps it, so the nearest event equals the kernel's historical minimum.
/// </summary>
internal static class SlideEvents
{
private const double Eps = Tolerance.Epsilon;
private static double Snap(double t) => t > Eps ? t : 0;
/// <summary>
/// Ray from a vertex against one entity. When <paramref name="vertexMoves"/> is true
/// the vertex belongs to the moving boundary and the ray follows the push direction;
/// otherwise it is a stationary vertex and the ray runs opposite to the push.
/// </summary>
public static void Ray<TSink>(
ref TSink sink,
double vx,
double vy,
Entity entity,
double entityDx,
double entityDy,
double rayX,
double rayY,
bool vertexMoves
)
where TSink : struct, ISlideEventSink
{
switch (entity)
{
case Line line:
RayLine(
ref sink,
vx,
vy,
line.pt1.X + entityDx,
line.pt1.Y + entityDy,
line.pt2.X + entityDx,
line.pt2.Y + entityDy,
rayX,
rayY,
vertexMoves
);
break;
case Arc arc:
{
var cx = arc.Center.X + entityDx;
var cy = arc.Center.Y + entityDy;
if (!SolveRayCircle(vx, vy, cx, cy, arc.Radius, rayX, rayY, out var t1, out var t2))
return;
for (var k = 0; k < 2; k++)
{
var t = k == 0 ? t1 : t2;
if (t <= -Eps)
continue;
var hitAngle = Angle.NormalizeRad(
System.Math.Atan2(vy + t * rayY - cy, vx + t * rayX - cx)
);
if (!Angle.IsBetweenRad(hitAngle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
continue;
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
if (sink.IsDone)
return;
}
break;
}
case Circle circle:
{
if (
!SolveRayCircle(
vx,
vy,
circle.Center.X + entityDx,
circle.Center.Y + entityDy,
circle.Radius,
rayX,
rayY,
out var t1,
out var t2
)
)
return;
for (var k = 0; k < 2; k++)
{
var t = k == 0 ? t1 : t2;
if (t < -Eps)
continue;
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
if (sink.IsDone)
return;
}
break;
}
}
}
/// <summary>Same hit rule as <see cref="SpatialQuery.RayEdgeDistance(double, double, double, double, double, double, double, double)"/>.</summary>
public static void RayLine<TSink>(
ref TSink sink,
double vx,
double vy,
double p1x,
double p1y,
double p2x,
double p2y,
double rayX,
double rayY,
bool vertexMoves
)
where TSink : struct, ISlideEventSink
{
var ex = p2x - p1x;
var ey = p2y - p1y;
var det = ex * rayY - ey * rayX;
if (System.Math.Abs(det) < Eps)
return;
var dvx = p1x - vx;
var dvy = p1y - vy;
var t = (ex * dvy - ey * dvx) / det;
if (t < -Eps)
return;
var s = (rayX * dvy - rayY * dvx) / det;
if (s < -Eps || s > 1.0 + Eps)
return;
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
}
/// <summary>
/// Axis-aligned ray against a segment, with the same hit rule as the
/// <see cref="PushDirection"/> kernel.
/// </summary>
public static void AxisRayLine<TSink>(
ref TSink sink,
double vx,
double vy,
double p1x,
double p1y,
double p2x,
double p2y,
PushDirection rayDirection,
bool vertexMoves
)
where TSink : struct, ISlideEventSink
{
double dist,
hx,
hy;
switch (rayDirection)
{
case PushDirection.Left:
case PushDirection.Right:
{
var dy = p2y - p1y;
if (System.Math.Abs(dy) < Eps)
return;
var t = (vy - p1y) / dy;
if (t < -Eps || t > 1.0 + Eps)
return;
hx = p1x + t * (p2x - p1x);
hy = vy;
dist = rayDirection == PushDirection.Left ? vx - hx : hx - vx;
break;
}
case PushDirection.Down:
case PushDirection.Up:
{
var dx = p2x - p1x;
if (System.Math.Abs(dx) < Eps)
return;
var t = (vx - p1x) / dx;
if (t < -Eps || t > 1.0 + Eps)
return;
hx = vx;
hy = p1y + t * (p2y - p1y);
dist = rayDirection == PushDirection.Down ? vy - hy : hy - vy;
break;
}
default:
return;
}
if (dist < -Eps)
return;
var vertex = new Vector(vx, vy);
var hit = new Vector(hx, hy);
if (vertexMoves)
sink.Add(Snap(dist), vertex, hit);
else
sink.Add(Snap(dist), hit, vertex);
}
/// <summary>
/// Closest-approach points of arcs against lines, which vertex sampling can miss.
/// </summary>
public static void ArcToLine<TSink>(
ref TSink sink,
List<Entity> arcEntities,
double arcDx,
double arcDy,
List<Entity> lineEntities,
double lineDx,
double lineDy,
double rayX,
double rayY,
bool arcMoves
)
where TSink : struct, ISlideEventSink
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (!TryGetCurve(arcEntities[i], out var localCx, out var localCy, out var r))
continue;
var arc = arcEntities[i] as Arc;
var cx = localCx + arcDx;
var cy = localCy + arcDy;
for (var j = 0; j < lineEntities.Count; j++)
{
if (lineEntities[j] is not Line line)
continue;
var p1x = line.pt1.X + lineDx;
var p1y = line.pt1.Y + lineDy;
var p2x = line.pt2.X + lineDx;
var p2y = line.pt2.Y + lineDy;
var ex = p2x - p1x;
var ey = p2y - p1y;
var det = ex * rayY - ey * rayX;
if (System.Math.Abs(det) < Eps)
continue;
// The directional distance from an arc point at angle θ to the
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
for (var k = 0; k < 2; k++)
{
var theta = k == 0 ? theta1 : theta2;
if (arc != null && !Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
continue;
var qx = cx + r * System.Math.Cos(theta);
var qy = cy + r * System.Math.Sin(theta);
RayLine(ref sink, qx, qy, p1x, p1y, p2x, p2y, rayX, rayY, arcMoves);
if (sink.IsDone)
return;
}
}
}
}
/// <summary>
/// External and internal tangencies of two curves along a unit direction. Radii must
/// be nonnegative; a null arc is a full circle.
/// </summary>
public static void CurveTangency<TSink>(
ref TSink sink,
double movingCx,
double movingCy,
double movingRadius,
Arc movingArc,
double stationaryCx,
double stationaryCy,
double stationaryRadius,
Arc stationaryArc,
double dirX,
double dirY
)
where TSink : struct, ISlideEventSink
{
for (var kind = 0; kind < 2; kind++)
{
var internalContact = kind == 1;
var radius = internalContact
? System.Math.Abs(movingRadius - stationaryRadius)
: movingRadius + stationaryRadius;
// Equal-radius internal contact has coincident centers, not a unique
// tangent point. Endpoints detect any overlap of those angular spans.
if (radius == 0)
continue;
if (
!SolveRayCircle(
movingCx,
movingCy,
stationaryCx,
stationaryCy,
radius,
dirX,
dirY,
out var t1,
out var t2
)
)
continue;
// The nearer center-circle root can be outside an arc while the farther
// root is its first contact. Check the actual tangent point at BOTH roots.
for (var root = 0; root < 2; root++)
{
var t = root == 0 ? t1 : t2;
if (t < -Eps)
continue;
var toX = stationaryCx - (movingCx + t * dirX);
var toY = stationaryCy - (movingCy + t * dirY);
var movingSign = internalContact && movingRadius < stationaryRadius ? -1 : 1;
var stationarySign = internalContact ? movingSign : -1;
if (
!ContainsContactAngle(
movingArc,
movingRadius,
movingSign * toX,
movingSign * toY
)
|| !ContainsContactAngle(
stationaryArc,
stationaryRadius,
stationarySign * toX,
stationarySign * toY
)
)
continue;
var length = System.Math.Sqrt(toX * toX + toY * toY);
var ux = length > 0 ? toX / length : 0;
var uy = length > 0 ? toY / length : 0;
var movingPoint = new Vector(
movingCx + movingSign * movingRadius * ux,
movingCy + movingSign * movingRadius * uy
);
var stationaryPoint = new Vector(
stationaryCx + stationarySign * stationaryRadius * ux,
stationaryCy + stationarySign * stationaryRadius * uy
);
sink.Add(Snap(t), movingPoint, stationaryPoint);
if (sink.IsDone)
return;
}
}
}
public static bool TryGetCurve(Entity entity, out double cx, out double cy, out double r)
{
switch (entity)
{
case Circle circle:
cx = circle.Center.X;
cy = circle.Center.Y;
r = circle.Radius;
return true;
case Arc arc:
cx = arc.Center.X;
cy = arc.Center.Y;
r = arc.Radius;
return true;
default:
cx = cy = r = 0;
return false;
}
}
private static void Emit<TSink>(
ref TSink sink,
double vx,
double vy,
double t,
double rayX,
double rayY,
bool vertexMoves
)
where TSink : struct, ISlideEventSink
{
var vertex = new Vector(vx, vy);
var hit = new Vector(vx + t * rayX, vy + t * rayY);
if (vertexMoves)
sink.Add(Snap(t), vertex, hit);
else
sink.Add(Snap(t), hit, vertex);
}
private static bool ContainsContactAngle(Arc arc, double radius, double x, double y)
{
return SlideCurvePrimitives.ContainsContactAngle(arc, radius, x, y);
}
internal static bool SolveRayCircle(
double vx,
double vy,
double cx,
double cy,
double r,
double dirX,
double dirY,
out double t1,
out double t2
)
{
return SlideCurvePrimitives.SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out t1, out t2);
}
}
/// <summary>
/// Slide events between native Line/Arc/Circle boundaries. The moving entities and
/// vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
/// </summary>
public struct EntitySlideEvents : ISlideEventSource
{
private readonly List<Entity> moving;
private readonly Vector[] movingVertices;
private readonly double movingDx;
private readonly double movingDy;
private readonly List<Entity> stationary;
private readonly Vector[] stationaryVertices;
private readonly double dirX;
private readonly double dirY;
private readonly bool arcToLine;
public EntitySlideEvents(
List<Entity> moving,
Vector[] movingVertices,
double movingDx,
double movingDy,
List<Entity> stationary,
Vector[] stationaryVertices,
double dirX,
double dirY,
bool arcToLine
)
{
this.moving = moving;
this.movingVertices = movingVertices;
this.movingDx = movingDx;
this.movingDy = movingDy;
this.stationary = stationary;
this.stationaryVertices = stationaryVertices;
this.dirX = dirX;
this.dirY = dirY;
this.arcToLine = arcToLine;
}
public void Enumerate<TSink>(ref TSink sink)
where TSink : struct, ISlideEventSink
{
// Phase 1: moving vertices along the push against stationary entities.
for (var v = 0; v < movingVertices.Length; v++)
{
var vx = movingVertices[v].X + movingDx;
var vy = movingVertices[v].Y + movingDy;
for (var j = 0; j < stationary.Count; j++)
{
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
if (sink.IsDone)
return;
}
}
// Phase 2: stationary vertices against the push onto moving entities.
for (var v = 0; v < stationaryVertices.Length; v++)
{
var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y;
for (var j = 0; j < moving.Count; j++)
{
SlideEvents.Ray(
ref sink,
vx,
vy,
moving[j],
movingDx,
movingDy,
-dirX,
-dirY,
false
);
if (sink.IsDone)
return;
}
}
// Phase 3: arc-to-line closest points, which vertex sampling can miss.
if (arcToLine)
{
SlideEvents.ArcToLine(
ref sink,
moving,
movingDx,
movingDy,
stationary,
0,
0,
dirX,
dirY,
true
);
if (sink.IsDone)
return;
SlideEvents.ArcToLine(
ref sink,
stationary,
0,
0,
moving,
movingDx,
movingDy,
-dirX,
-dirY,
false
);
if (sink.IsDone)
return;
}
// Phase 4: native curve tangency, including a convex corner inside a concave arc.
for (var i = 0; i < moving.Count; i++)
{
if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr))
continue;
for (var j = 0; j < stationary.Count; j++)
{
if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr))
continue;
SlideEvents.CurveTangency(
ref sink,
mcx + movingDx,
mcy + movingDy,
mr,
moving[i] as Arc,
scx,
scy,
sr,
stationary[j] as Arc,
dirX,
dirY
);
if (sink.IsDone)
return;
}
}
}
}
/// <summary>
/// Slide events between line boundaries along an arbitrary unit direction. The moving
/// lines and vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
/// </summary>
public struct LineSlideEvents : ISlideEventSource
{
private readonly List<Line> moving;
private readonly Vector[] movingVertices;
private readonly double movingDx;
private readonly double movingDy;
private readonly List<Line> stationary;
private readonly Vector[] stationaryVertices;
private readonly double dirX;
private readonly double dirY;
public LineSlideEvents(
List<Line> moving,
Vector[] movingVertices,
double movingDx,
double movingDy,
List<Line> stationary,
Vector[] stationaryVertices,
double dirX,
double dirY
)
{
this.moving = moving;
this.movingVertices = movingVertices;
this.movingDx = movingDx;
this.movingDy = movingDy;
this.stationary = stationary;
this.stationaryVertices = stationaryVertices;
this.dirX = dirX;
this.dirY = dirY;
}
public void Enumerate<TSink>(ref TSink sink)
where TSink : struct, ISlideEventSink
{
for (var v = 0; v < movingVertices.Length; v++)
{
var vx = movingVertices[v].X + movingDx;
var vy = movingVertices[v].Y + movingDy;
for (var j = 0; j < stationary.Count; j++)
{
var e = stationary[j];
SlideEvents.RayLine(
ref sink,
vx,
vy,
e.pt1.X,
e.pt1.Y,
e.pt2.X,
e.pt2.Y,
dirX,
dirY,
true
);
if (sink.IsDone)
return;
}
}
for (var v = 0; v < stationaryVertices.Length; v++)
{
var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y;
for (var j = 0; j < moving.Count; j++)
{
var e = moving[j];
SlideEvents.RayLine(
ref sink,
vx,
vy,
e.pt1.X + movingDx,
e.pt1.Y + movingDy,
e.pt2.X + movingDx,
e.pt2.Y + movingDy,
-dirX,
-dirY,
false
);
if (sink.IsDone)
return;
}
}
}
}
/// <summary>
/// Axis-aligned slide events between edge arrays sorted for pruning, as used by the
/// <see cref="PushDirection"/> kernel. Offsets translate each side into world space.
/// </summary>
public struct AxisSlideEvents : ISlideEventSource
{
private readonly (Vector start, Vector end)[] movingEdges;
private readonly Vector movingOffset;
private readonly Vector[] movingVertices;
private readonly (Vector start, Vector end)[] stationaryEdges;
private readonly Vector stationaryOffset;
private readonly Vector[] stationaryVertices;
private readonly PushDirection direction;
/// <param name="movingVertices">World-space moving vertices.</param>
/// <param name="stationaryVertices">World-space stationary vertices.</param>
public AxisSlideEvents(
(Vector start, Vector end)[] movingEdges,
Vector movingOffset,
Vector[] movingVertices,
(Vector start, Vector end)[] stationaryEdges,
Vector stationaryOffset,
Vector[] stationaryVertices,
PushDirection direction
)
{
this.movingEdges = movingEdges;
this.movingOffset = movingOffset;
this.movingVertices = movingVertices;
this.stationaryEdges = stationaryEdges;
this.stationaryOffset = stationaryOffset;
this.stationaryVertices = stationaryVertices;
this.direction = direction;
}
public void Enumerate<TSink>(ref TSink sink)
where TSink : struct, ISlideEventSink
{
for (var v = 0; v < movingVertices.Length; v++)
{
OneWay(ref sink, movingVertices[v], stationaryEdges, stationaryOffset, direction, true);
if (sink.IsDone)
return;
}
var opposite = SpatialQuery.OppositeDirection(direction);
for (var v = 0; v < stationaryVertices.Length; v++)
{
OneWay(ref sink, stationaryVertices[v], movingEdges, movingOffset, opposite, false);
if (sink.IsDone)
return;
}
}
private static void OneWay<TSink>(
ref TSink sink,
Vector vertex,
(Vector start, Vector end)[] edges,
Vector edgeOffset,
PushDirection rayDirection,
bool vertexMoves
)
where TSink : struct, ISlideEventSink
{
var vx = vertex.X;
var vy = vertex.Y;
var horizontal = SpatialQuery.IsHorizontalDirection(rayDirection);
// Edges are sorted by their perpendicular min-coordinate.
for (var i = 0; i < edges.Length; i++)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
double perpValue,
edgeMin,
edgeMax;
if (horizontal)
{
perpValue = vy;
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
}
else
{
perpValue = vx;
edgeMin = e1.X < e2.X ? e1.X : e2.X;
edgeMax = e1.X > e2.X ? e1.X : e2.X;
}
if (perpValue < edgeMin - Tolerance.Epsilon)
break;
if (perpValue > edgeMax + Tolerance.Epsilon)
continue;
SlideEvents.AxisRayLine(
ref sink,
vx,
vy,
e1.X,
e1.Y,
e2.X,
e2.Y,
rayDirection,
vertexMoves
);
if (sink.IsDone)
return;
}
}
}
}
+170 -377
View File
@@ -147,25 +147,7 @@ namespace OpenNest.Geometry
out double t2
)
{
var ox = vx - cx;
var oy = vy - cy;
var a = dirX * dirX + dirY * dirY;
var b = 2.0 * (ox * dirX + oy * dirY);
var c = ox * ox + oy * oy - r * r;
var discriminant = b * b - 4.0 * a * c;
if (discriminant < 0)
{
t1 = t2 = double.MaxValue;
return false;
}
var sqrtD = System.Math.Sqrt(discriminant);
var inv2a = 1.0 / (2.0 * a);
t1 = (-b - sqrtD) * inv2a;
t2 = (-b + sqrtD) * inv2a;
return true;
return SlideCurvePrimitives.SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out t1, out t2);
}
/// <summary>
@@ -312,16 +294,13 @@ namespace OpenNest.Geometry
private static bool ContainsContactAngle(Arc arc, double radius, double x, double y)
{
// A zero-radius curve is a point: its angular range has no geometric meaning.
if (arc == null || radius == 0)
return true;
var angle = Angle.NormalizeRad(System.Math.Atan2(y, x));
return Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed);
return SlideCurvePrimitives.ContainsContactAngle(arc, radius, x, y);
}
/// <summary>
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
/// Computes the translation distance along a push direction before any edge of
/// movingLines first blocks against an edge of stationaryLines. A contact that
/// the push slides along or leaves does not block (see <see cref="SlideContact"/>).
/// Returns double.MaxValue if no collision path exists.
/// </summary>
public static double DirectionalDistance(
@@ -334,7 +313,7 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Computes the minimum directional distance with the moving lines translated
/// Computes the directional distance with the moving lines translated
/// by (movingDx, movingDy) without creating new Line objects.
/// </summary>
public static double DirectionalDistance(
@@ -345,37 +324,57 @@ namespace OpenNest.Geometry
PushDirection direction
)
{
var minDist = double.MaxValue;
var movingOffset = new Vector(movingDx, movingDy);
return DirectionalDistance(
movingLines,
movingDx,
movingDy,
stationaryLines,
direction,
SlideContactClassifier.FromLines(
movingLines,
new Vector(movingDx, movingDy),
stationaryLines,
Vector.Zero
)
);
}
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = CollectVertices(movingLines, movingOffset);
/// <summary>
/// <see cref="DirectionalDistance(List{Line}, double, double, List{Line}, PushDirection)"/>
/// with caller-supplied contact topology, for inputs that are not complete closed
/// boundaries (for example direction-filtered edges).
/// </summary>
public static double DirectionalDistance(
List<Line> movingLines,
double movingDx,
double movingDy,
List<Line> stationaryLines,
PushDirection direction,
SlideContactClassifier contacts
)
{
var movingOffset = new Vector(movingDx, movingDy);
var movingVertices = CollectVertices(movingLines, movingOffset).ToArray();
var stationaryEdges = ToEdgeArray(stationaryLines);
SortEdgesForPruning(stationaryEdges, direction);
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist)
minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero).ToArray();
var movingEdges = ToEdgeArray(movingLines);
SortEdgesForPruning(movingEdges, opposite);
SortEdgesForPruning(movingEdges, OppositeDirection(direction));
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist)
minDist = d;
}
return minDist;
var source = new AxisSlideEvents(
movingEdges,
movingOffset,
movingVertices,
stationaryEdges,
Vector.Zero,
stationaryVertices,
direction
);
var unit = DirectionToOffset(direction, 1.0);
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
}
/// <summary>
@@ -396,8 +395,8 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Computes the minimum directional distance using raw edge arrays and location offsets
/// to avoid all intermediate object allocations.
/// Computes the blocking directional distance using raw edge arrays and location
/// offsets. Sorts both edge arrays in place for pruning.
/// </summary>
public static double DirectionalDistance(
(Vector start, Vector end)[] movingEdges,
@@ -407,36 +406,58 @@ namespace OpenNest.Geometry
PushDirection direction
)
{
var minDist = double.MaxValue;
SortEdgesForPruning(stationaryEdges, direction);
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = CollectVertices(movingEdges, movingOffset);
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
if (d < minDist)
minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
SortEdgesForPruning(movingEdges, opposite);
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist)
minDist = d;
}
return minDist;
return DirectionalDistance(
movingEdges,
movingOffset,
stationaryEdges,
stationaryOffset,
direction,
SlideContactClassifier.FromEdges(
movingEdges,
movingOffset,
stationaryEdges,
stationaryOffset
)
);
}
/// <summary>
/// Edge-array overload with caller-supplied contact topology. The classifier's
/// origins must match <paramref name="movingOffset"/> and
/// <paramref name="stationaryOffset"/> in the frame of its boundaries.
/// </summary>
public static double DirectionalDistance(
(Vector start, Vector end)[] movingEdges,
Vector movingOffset,
(Vector start, Vector end)[] stationaryEdges,
Vector stationaryOffset,
PushDirection direction,
SlideContactClassifier contacts
)
{
SortEdgesForPruning(stationaryEdges, direction);
var movingVertices = CollectVertices(movingEdges, movingOffset).ToArray();
SortEdgesForPruning(movingEdges, OppositeDirection(direction));
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset).ToArray();
var source = new AxisSlideEvents(
movingEdges,
movingOffset,
movingVertices,
stationaryEdges,
stationaryOffset,
stationaryVertices,
direction
);
var unit = DirectionToOffset(direction, 1.0);
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
}
/// <summary>
/// Nearest raw hit from one vertex along a push direction against edges sorted for
/// pruning. This is a first-touch primitive; it does not classify sliding contacts.
/// </summary>
public static double OneWayDistance(
Vector vertex,
(Vector start, Vector end)[] edges,
@@ -628,8 +649,8 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction
/// before any edge of movingLines contacts any edge of stationaryLines.
/// Computes the translation distance along an arbitrary unit direction before any
/// edge of movingLines first blocks against an edge of stationaryLines.
/// </summary>
public static double DirectionalDistance(
List<Line> movingLines,
@@ -637,58 +658,41 @@ namespace OpenNest.Geometry
Vector direction
)
{
var minDist = double.MaxValue;
var dirX = direction.X;
var dirY = direction.Y;
return DirectionalDistance(
movingLines,
stationaryLines,
direction,
SlideContactClassifier.FromLines(
movingLines,
Vector.Zero,
stationaryLines,
Vector.Zero
)
);
}
var movingVertices = CollectVertices(movingLines, Vector.Zero);
foreach (var mv in movingVertices)
{
for (var i = 0; i < stationaryLines.Count; 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
);
if (d < minDist)
minDist = d;
}
}
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
foreach (var sv in stationaryVertices)
{
for (var i = 0; i < movingLines.Count; 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
);
if (d < minDist)
minDist = d;
}
}
return minDist;
/// <summary>
/// <see cref="DirectionalDistance(List{Line}, List{Line}, Vector)"/> with
/// caller-supplied contact topology.
/// </summary>
public static double DirectionalDistance(
List<Line> movingLines,
List<Line> stationaryLines,
Vector direction,
SlideContactClassifier contacts
)
{
var source = new LineSlideEvents(
movingLines,
CollectVertices(movingLines, Vector.Zero).ToArray(),
0,
0,
stationaryLines,
CollectVertices(stationaryLines, Vector.Zero).ToArray(),
direction.X,
direction.Y
);
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
}
/// <summary>
@@ -710,10 +714,10 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
/// stationaryEntities. Works with native Line, Arc, and Circle entities
/// without tessellation.
/// Computes the translation distance along an arbitrary unit direction before any
/// vertex/edge of movingEntities first blocks against stationaryEntities. Works with
/// native Line, Arc, and Circle entities without tessellation. A contact that the
/// push slides along or leaves does not block (see <see cref="SlideContact"/>).
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities,
@@ -721,228 +725,42 @@ namespace OpenNest.Geometry
Vector direction
)
{
var minDist = double.MaxValue;
var dirX = direction.X;
var dirY = direction.Y;
var movingVertices = ExtractEntityVertices(movingEntities);
for (var v = 0; v < movingVertices.Length; v++)
{
var vx = movingVertices[v].X;
var vy = movingVertices[v].Y;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0)
return 0;
}
}
}
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
for (var v = 0; v < stationaryVertices.Length; v++)
{
var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0)
return 0;
}
}
}
// Phase 3: Arc-to-line closest-point check.
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
// closest point on a small corner arc to a straight edge may lie between
// those samples. Use ClosestPointTo to find it and fire a ray from there.
minDist = ArcToLineClosestDistance(
return DirectionalDistance(
movingEntities,
stationaryEntities,
dirX,
dirY,
minDist
direction,
new SlideContactClassifier(movingEntities, stationaryEntities)
);
if (minDist <= 0)
return 0;
minDist = ArcToLineClosestDistance(
stationaryEntities,
}
/// <summary>
/// <see cref="DirectionalDistance(List{Entity}, List{Entity}, Vector)"/> with
/// caller-supplied contact topology.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities,
List<Entity> stationaryEntities,
Vector direction,
SlideContactClassifier contacts
)
{
// Phases: vertex rays both ways, arc-to-line closest points (vertex sampling
// misses interior arc contact), then native curve tangency.
var source = new EntitySlideEvents(
movingEntities,
oppX,
oppY,
minDist
ExtractEntityVertices(movingEntities),
0,
0,
stationaryEntities,
ExtractEntityVertices(stationaryEntities),
direction.X,
direction.Y,
arcToLine: true
);
if (minDist <= 0)
return 0;
// Phase 4: Native curve tangency, including a convex corner inside a concave arc.
for (var i = 0; i < movingEntities.Count; i++)
{
var me = movingEntities[i];
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
continue;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var se = stationaryEntities[j];
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
continue;
var d = CurveTangencyDistance(
mcx, mcy, mr, me as Arc,
scx, scy, sr, se as Arc, dirX, dirY);
if (d >= minDist)
continue;
minDist = d;
if (d <= 0)
return 0;
}
}
return minDist;
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
}
private static double ArcToLineClosestDistance(
List<Entity> arcEntities,
List<Entity> lineEntities,
double dirX,
double dirY,
double minDist
)
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (arcEntities[i] is not Arc arc)
continue;
var cx = arc.Center.X;
var cy = arc.Center.Y;
var r = arc.Radius;
for (var j = 0; j < lineEntities.Count; j++)
{
if (lineEntities[j] is not Line line)
continue;
var p1x = line.pt1.X;
var p1y = line.pt1.Y;
var ex = line.pt2.X - p1x;
var ey = line.pt2.Y - p1y;
var det = ex * dirY - ey * dirX;
if (System.Math.Abs(det) < Tolerance.Epsilon)
continue;
// The directional distance from an arc point at angle θ to the
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
for (var k = 0; k < 2; k++)
{
var theta = k == 0 ? theta1 : theta2;
if (
!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)
)
continue;
var qx = cx + r * System.Math.Cos(theta);
var qy = cy + r * System.Math.Sin(theta);
var d = RayEdgeDistance(
qx,
qy,
p1x,
p1y,
line.pt2.X,
line.pt2.Y,
dirX,
dirY
);
if (d < minDist)
{
minDist = d;
if (d <= 0)
return 0;
}
}
}
}
return minDist;
}
private static double RayEntityDistance(
double vx,
double vy,
Entity entity,
double dirX,
double dirY
)
{
if (entity is Line line)
{
return RayEdgeDistance(
vx,
vy,
line.pt1.X,
line.pt1.Y,
line.pt2.X,
line.pt2.Y,
dirX,
dirY
);
}
if (entity is Arc arc)
{
return RayArcDistance(
vx,
vy,
arc.Center.X,
arc.Center.Y,
arc.Radius,
arc.StartAngle,
arc.EndAngle,
arc.IsReversed,
dirX,
dirY
);
}
if (entity is Circle circle)
{
return RayCircleDistance(
vx,
vy,
circle.Center.X,
circle.Center.Y,
circle.Radius,
dirX,
dirY
);
}
return double.MaxValue;
}
private static Vector[] ExtractEntityVertices(List<Entity> entities)
public static Vector[] ExtractEntityVertices(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
@@ -1041,31 +859,6 @@ namespace OpenNest.Geometry
);
}
private static bool TryGetCurveParams(
Entity entity,
out double cx,
out double cy,
out double r
)
{
if (entity is Circle circle)
{
cx = circle.Center.X;
cy = circle.Center.Y;
r = circle.Radius;
return true;
}
if (entity is Arc arc)
{
cx = arc.Center.X;
cy = arc.Center.Y;
r = arc.Radius;
return true;
}
cx = cy = r = 0;
return false;
}
private static double BoxProjectionMin(Box box, double dx, double dy)
{
var x = dx >= 0 ? box.Left : box.Right;
+3 -20
View File
@@ -80,7 +80,7 @@ namespace OpenNest.Geometry
}
var finalPoints = points.GetRange(start, endIdx - start + 1);
var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints));
var sweep = System.Math.Abs(ArcFit.SumSignedAngles(center, finalPoints));
if (sweep < Angle.ToRadians(5))
return null;
@@ -151,27 +151,10 @@ namespace OpenNest.Geometry
double radius
) => ArcFit.MaxRadialDeviation(points, cx, cy, radius);
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI)
da -= Angle.TwoPI;
while (da < -System.Math.PI)
da += Angle.TwoPI;
total += da;
}
return total;
}
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
{
var lastPt = points[^1];
var totalAngle = SumSignedAngles(center, points);
var totalAngle = ArcFit.SumSignedAngles(center, points);
var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y;
@@ -186,7 +169,7 @@ namespace OpenNest.Geometry
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
var isReversed = SumSignedAngles(center, points) < 0;
var isReversed = ArcFit.SumSignedAngles(center, points) < 0;
if (startAngle < 0)
startAngle += Angle.TwoPI;
+17
View File
@@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace OpenNest
{
/// <summary>
/// A post-processor whose <see cref="IPostProcessor.Post(Nest, string)"/>
/// can write more than one file (for example one program per sheet).
/// </summary>
public interface IMultiFilePostProcessor : IPostProcessor
{
/// <summary>
/// The files <see cref="IPostProcessor.Post(Nest, string)"/> will write
/// for this nest and chosen path, in order, with the current settings.
/// </summary>
IReadOnlyList<string> GetOutputFiles(Nest nest, string outputFile);
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace OpenNest;
/// <summary>
/// Optional post contract for nest-level rapid verification. Opt in only when the
/// post preserves Plate.Parts order and each placed Program's contour order and
/// pierce positions. This does not certify retract height, parking or controller macros.
/// Unknown/reordering posts still get nest diagnostics, but require acknowledgment
/// that their final rapid sequence has not been verified.
/// </summary>
public interface IPostVerificationSupport
{
bool PreservesPlacedProgramOrder { get; }
}
+4 -4
View File
@@ -1,4 +1,4 @@
namespace OpenNest.Math
namespace OpenNest.Math
{
public static class Angle
{
@@ -106,14 +106,14 @@
if (reversed)
Generic.Swap(ref a1, ref a2);
var diff = Angle.NormalizeRad(a2 - a1);
var diff = Angle.NormalizeDeg(a2 - a1);
// full circle
if (a2.IsEqualTo(a1))
return true;
a1 = Angle.NormalizeRad(angle - a1);
a2 = Angle.NormalizeRad(a2 - angle);
a1 = Angle.NormalizeDeg(angle - a1);
a2 = Angle.NormalizeDeg(a2 - angle);
return diff >= a1 - Tolerance.Epsilon || diff >= a2 - Tolerance.Epsilon;
}
+3 -1
View File
@@ -8,7 +8,9 @@ namespace OpenNest.Math
public static class Fraction
{
public static readonly Regex FractionRegex = new Regex(
@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)"
@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)",
RegexOptions.None,
TimeSpan.FromMilliseconds(250)
);
public static bool IsValid(string s)
+5 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using OpenNest.Collections;
using OpenNest.Geometry;
@@ -38,6 +38,10 @@ namespace OpenNest
public string AssistGas { get; set; } = "";
public NestStatus Status { get; set; } = NestStatus.Quote;
public string MadeBy { get; set; } = "";
public double Thickness { get; set; }
public Material Material { get; set; }
+13
View File
@@ -0,0 +1,13 @@
namespace OpenNest
{
/// <summary>
/// Shop-floor workflow state of a saved nest, persisted as a string in
/// <c>nest.json</c> so unknown future values can fall back safely.
/// </summary>
public enum NestStatus
{
Quote,
ToBeCut,
HasBeenCut,
}
}
+53 -2
View File
@@ -111,6 +111,30 @@ namespace OpenNest
UpdateBounds();
}
/// <summary>
/// Installs an owned, already-rotated saved cutting program without rotating it again.
/// The current pose remains the clean-drawing pose used by RemoveLeadIns.
/// </summary>
public bool RestoreLeadInProgram(Program program, bool locked)
{
if (program == null || !program.Codes.Any(code => code is Motion
|| code is SubProgramCall call && call.Program != null
&& call.Program.Codes.Any(subCode => subCode is Motion)))
return false;
// Compute before changing state, so a malformed program cannot half-install.
var bounds = program.BoundingBox();
bounds.Offset(Location);
preLeadInRotation = Rotation;
Program = program;
ownsProgram = true;
HasManualLeadIns = true;
LeadInsLocked = locked;
CuttingParameters = null;
BoundingBox = bounds;
return true;
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
@@ -145,7 +169,7 @@ namespace OpenNest
EnsureOwnedProgram();
Program.Rotate(angle);
location = Location.Rotate(angle);
preLeadInRotation = Program.Rotation;
TrackRotation(angle);
UpdateBounds();
}
@@ -159,10 +183,22 @@ namespace OpenNest
EnsureOwnedProgram();
Program.Rotate(angle);
location = Location.Rotate(angle, origin);
preLeadInRotation = Program.Rotation;
TrackRotation(angle);
UpdateBounds();
}
/// <summary>
/// Records the part's rotation after it turned by <paramref name="angle"/>. A lead-in
/// program is rebuilt by the cutting strategy and starts over at zero program
/// rotation, so for those parts the rotation is accumulated rather than read back.
/// </summary>
private void TrackRotation(double angle)
{
preLeadInRotation = HasManualLeadIns
? Angle.NormalizeRad(preLeadInRotation + angle)
: Program.Rotation;
}
/// <summary>
/// Offsets the part.
/// </summary>
@@ -323,6 +359,7 @@ namespace OpenNest
new Box(BoundingBox.X, BoundingBox.Y, BoundingBox.Length, BoundingBox.Width)
);
part.ownsProgram = true;
part.CopyLeadInStateFrom(this);
return part;
}
@@ -347,10 +384,24 @@ namespace OpenNest
BoundingBox.Width
)
);
part.CopyLeadInStateFrom(this);
return part;
}
/// <summary>
/// Copies the lead-in state that goes with a copied program. Without it a copy of a
/// lead-in part reads its rotation from the rebuilt program (zero), and lead-in
/// assignment does not know to strip the copied lead-ins before adding new ones.
/// </summary>
private void CopyLeadInStateFrom(Part source)
{
HasManualLeadIns = source.HasManualLeadIns;
LeadInsLocked = source.LeadInsLocked;
CuttingParameters = source.CuttingParameters;
preLeadInRotation = source.preLeadInRotation;
}
private void EnsureOwnedProgram()
{
if (!ownsProgram)
+19 -14
View File
@@ -31,20 +31,9 @@ namespace OpenNest
double chordTolerance = 0.001
)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(
entities.Where(e => SpecialLayers.IsMaterial(e.Layer))
return GetDirectionalPartLines(
part, chordTolerance, useVector: false, facingDirection, default
);
var lines = new List<Line>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines;
}
/// <summary>
@@ -162,6 +151,19 @@ namespace OpenNest
Vector facingDirection,
double chordTolerance = 0.001
)
{
return GetDirectionalPartLines(
part, chordTolerance, useVector: true, default, facingDirection
);
}
private static List<Line> GetDirectionalPartLines(
Part part,
double chordTolerance,
bool useVector,
PushDirection cardinalDirection,
Vector vectorDirection
)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(
@@ -173,7 +175,10 @@ namespace OpenNest
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
// Keep the overload-specific arithmetic and invalid-direction behavior.
lines.AddRange(useVector
? GetDirectionalLines(polygon, vectorDirection)
: GetDirectionalLines(polygon, cardinalDirection));
}
return lines;
+44 -7
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Collections;
@@ -94,18 +94,40 @@ namespace OpenNest
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
/// regenerated and added back if it produces any geometry.
/// regenerated and put back at the same place in the cut sequence
/// (<see cref="Parts"/> order). New cut-offs are added at the end.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings)
{
// Remove existing cut-off parts
// Remember each cut-off's place in the cut sequence, so a part drag
// or cut-off move doesn't send it to the end of the sequence.
var sequence = new Dictionary<CutOff, int>();
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i);
if (!Parts[i].BaseDrawing.IsCutOff)
continue;
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
if (cutoff != null)
sequence[cutoff] = i;
Parts.RemoveAt(i);
}
RegenerateCutOffs(settings, sequence);
}
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts, placing
/// each cut-off at its index in <paramref name="sequence"/> (its place in
/// <see cref="Parts"/> order). Cut-offs missing from it are added at the end.
/// Callers must remove existing cut-off parts first.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence)
{
var cache = BuildPerimeterCache(this);
var placed = new List<(int Index, Part Part)>();
// Regenerate and materialize each cut-off
foreach (var cutoff in CutOffs)
@@ -116,8 +138,17 @@ namespace OpenNest
continue;
var part = new Part(cutoff.Drawing);
Parts.Add(part);
if (sequence != null && sequence.TryGetValue(cutoff, out var index))
placed.Add((index, part));
else
Parts.Add(part);
}
// Lowest index first: each insert then lands on its saved index, because
// every part sequenced before it is already in place.
foreach (var (index, part) in placed.OrderBy(p => p.Index))
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
}
/// <summary>
@@ -143,7 +174,13 @@ namespace OpenNest
if (entities.Count > 0)
{
var profile = new Geometry.ShapeProfile(entities);
// Leads are cutting paths in scrap, not boundary edges. Chaining them
// into the outline can make a closed part appear open and replace its
// recesses with a convex hull. Keep the full geometry for the existing
// conservative fallback when the material contour really is open.
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
e.Layer != SpecialLayers.Leadout).ToList();
var profile = new Geometry.ShapeProfile(outline);
if (profile.Perimeter.IsClosed())
{
-12
View File
@@ -1,12 +0,0 @@
using System.Collections.Generic;
namespace OpenNest
{
public class PlateOptimizerResult
{
public List<Part> Parts { get; set; } = new();
public PlateOption ChosenSize { get; set; }
public double NetCost { get; set; }
public double Utilization { get; set; }
}
}
@@ -0,0 +1,56 @@
using System;
namespace OpenNest.PostSettings
{
/// <summary>
/// Places a post-processor config property in a section of the desktop
/// settings editor. A config type opts into the sectioned editor by marking
/// at least one property; unmarked configs keep the generic PropertyGrid.
/// Label and help text come from <c>DisplayName</c> and <c>Description</c>.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class PostSettingAttribute : Attribute
{
public PostSettingAttribute(string section, int order = 0)
{
Section = section;
Order = order;
}
public string Section { get; }
public int Order { get; }
/// <summary>Lower bound for numeric fields; NaN uses the kind's default.</summary>
public double Minimum { get; set; } = double.NaN;
/// <summary>Upper bound for numeric fields; NaN uses the kind's default.</summary>
public double Maximum { get; set; } = double.NaN;
/// <summary>Decimal places for decimal fields; negative uses the default.</summary>
public int DecimalPlaces { get; set; } = -1;
/// <summary>Column header for the key of a name/value table.</summary>
public string KeyHeader { get; set; }
/// <summary>Column header for the value of a name/value table.</summary>
public string ValueHeader { get; set; }
}
/// <summary>Declares a settings section's order and description.</summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class PostSettingsSectionAttribute : Attribute
{
public PostSettingsSectionAttribute(string name, int order)
{
Name = name;
Order = order;
}
public string Name { get; }
public int Order { get; }
public string Description { get; set; }
}
}
@@ -0,0 +1,231 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
namespace OpenNest.PostSettings
{
public enum PostSettingKind
{
Text,
Integer,
Decimal,
Boolean,
Choice,
StringMap,
}
public sealed class PostSettingsField
{
internal PostSettingsField(
PropertyInfo property,
PostSettingKind kind,
PostSettingAttribute setting
)
{
Property = property;
Kind = kind;
Label =
property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName
?? property.Name;
Description = property.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "";
var (min, max) = DefaultRange(kind);
Minimum = setting != null && !double.IsNaN(setting.Minimum) ? setting.Minimum : min;
Maximum = setting != null && !double.IsNaN(setting.Maximum) ? setting.Maximum : max;
DecimalPlaces =
kind == PostSettingKind.Integer ? 0
: setting != null && setting.DecimalPlaces >= 0 ? setting.DecimalPlaces
: 3;
KeyHeader = setting?.KeyHeader ?? "Name";
ValueHeader = setting?.ValueHeader ?? "Value";
}
public PropertyInfo Property { get; }
public string Name => Property.Name;
public PostSettingKind Kind { get; }
public string Label { get; }
public string Description { get; }
public double Minimum { get; }
public double Maximum { get; }
public int DecimalPlaces { get; }
public string KeyHeader { get; }
public string ValueHeader { get; }
public string[] ChoiceNames =>
Kind == PostSettingKind.Choice
? Enum.GetNames(Property.PropertyType)
: Array.Empty<string>();
public object GetValue(object config) => Property.GetValue(config);
public void SetValue(object config, object value) => Property.SetValue(config, value);
private static (double, double) DefaultRange(PostSettingKind kind) =>
kind == PostSettingKind.Integer ? (int.MinValue, int.MaxValue) : (-1e9, 1e9);
}
public sealed class PostSettingsSection
{
internal PostSettingsSection(
string name,
string description,
IReadOnlyList<PostSettingsField> fields
)
{
Name = name;
Description = description ?? "";
Fields = fields;
}
public string Name { get; }
public string Description { get; }
public IReadOnlyList<PostSettingsField> Fields { get; }
}
/// <summary>
/// Builds the sectioned settings layout for a post-processor config from its
/// <see cref="PostSettingAttribute"/> metadata, without any UI dependency.
/// </summary>
public static class PostSettingsLayout
{
public const string OtherSection = "Other";
/// <summary>
/// Returns the ordered sections, or null when the config should use the
/// generic PropertyGrid: it has no <see cref="PostSettingAttribute"/>,
/// or one of its editable properties has a type the editor cannot show.
/// Editable properties without the attribute go to the
/// <see cref="OtherSection"/> so none become uneditable.
/// </summary>
public static IReadOnlyList<PostSettingsSection> TryBuild(Type configType)
{
if (configType == null)
return null;
var properties = configType
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p =>
p.CanRead
&& p.GetSetMethod() != null
&& p.GetIndexParameters().Length == 0
&& p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false
)
.OrderBy(p => p.MetadataToken)
.ToList();
if (!properties.Any(p => p.GetCustomAttribute<PostSettingAttribute>() != null))
return null;
var entries = new List<(string Section, int Order, int Index, PostSettingsField Field)>();
for (var i = 0; i < properties.Count; i++)
{
var property = properties[i];
var kind = KindOf(property.PropertyType);
if (kind == null)
return null;
var setting = property.GetCustomAttribute<PostSettingAttribute>();
var section = string.IsNullOrWhiteSpace(setting?.Section)
? OtherSection
: setting.Section;
entries.Add(
(
section,
setting?.Order ?? int.MaxValue,
i,
new PostSettingsField(property, kind.Value, setting)
)
);
}
var declared = configType
.GetCustomAttributes<PostSettingsSectionAttribute>()
.GroupBy(a => a.Name, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
return entries
.GroupBy(e => e.Section, StringComparer.Ordinal)
.Select(g => new
{
Name = g.Key,
Declared = declared.TryGetValue(g.Key, out var d) ? d : null,
FirstIndex = g.Min(e => e.Index),
Fields = g.OrderBy(e => e.Order).ThenBy(e => e.Index).Select(e => e.Field).ToList(),
})
.OrderBy(s => s.Name == OtherSection && s.Declared == null ? 2
: s.Declared != null ? 0
: 1)
.ThenBy(s => s.Declared?.Order ?? 0)
.ThenBy(s => s.FirstIndex)
.Select(s => new PostSettingsSection(s.Name, s.Declared?.Description, s.Fields))
.ToList();
}
/// <summary>
/// Builds a name/value map from edited table rows using the supplied key
/// comparer. Keys and values are trimmed; fully blank rows are skipped.
/// Throws <see cref="FormatException"/> naming the 1-based row for a
/// missing or duplicate name.
/// </summary>
public static Dictionary<string, string> BuildMap(
IEnumerable<KeyValuePair<string, string>> rows,
IEqualityComparer<string> comparer
)
{
var map = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
var row = 0;
foreach (var entry in rows ?? Enumerable.Empty<KeyValuePair<string, string>>())
{
row++;
var key = entry.Key?.Trim() ?? "";
var value = entry.Value?.Trim() ?? "";
if (key.Length == 0 && value.Length == 0)
continue;
if (key.Length == 0)
throw new FormatException($"Row {row}: a name is required.");
if (map.ContainsKey(key))
throw new FormatException($"Row {row}: \"{key}\" is listed more than once.");
map.Add(key, value);
}
return map;
}
private static PostSettingKind? KindOf(Type type)
{
if (type == typeof(string))
return PostSettingKind.Text;
if (type == typeof(int))
return PostSettingKind.Integer;
if (type == typeof(double))
return PostSettingKind.Decimal;
if (type == typeof(bool))
return PostSettingKind.Boolean;
if (type.IsEnum)
return PostSettingKind.Choice;
if (
type == typeof(Dictionary<string, string>)
|| type == typeof(IDictionary<string, string>)
)
return PostSettingKind.StringMap;
return null;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// Last-used Auto Nest engine, stored separately from nest/plate defaults.
/// Loading does not resolve the name: the host must finish plug-in discovery first.
/// </summary>
public sealed class EngineSelectionSettings
{
public const string DefaultEngineName = "Default";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public string EngineName { get; set; } = DefaultEngineName;
/// <summary>%APPDATA%\OpenNest\engine-selection.json.</summary>
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest", "engine-selection.json");
/// <summary>Missing, unreadable or corrupt settings safely use Default without writing.</summary>
public static EngineSelectionSettings Load(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return new();
try
{
var settings = JsonSerializer.Deserialize<EngineSelectionSettings>(
File.ReadAllText(path), JsonOptions) ?? new();
settings.EngineName = NormalizeName(settings.EngineName);
return settings;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return new();
}
}
/// <summary>
/// Resolves against the host's selectable engines AFTER plug-in loading. A missing engine
/// returns Default plus a status-bar warning, without replacing the saved preference.
/// Names use registry casing so desktop combo-box selection remains exact. When the saved
/// name is not selectable, <paramref name="renamed"/> (the registry's legacy-name lookup)
/// may map it to the engine that replaced it; the result must itself be selectable.
/// </summary>
public string Resolve(
IEnumerable<string> availableEngineNames,
out string? statusMessage,
Func<string, string?>? renamed = null)
{
ArgumentNullException.ThrowIfNull(availableEngineNames);
var available = availableEngineNames.ToList();
var requestedName = NormalizeName(EngineName);
var registeredName = Find(available, requestedName)
?? (renamed?.Invoke(requestedName) is { } replacement ? Find(available, replacement) : null);
if (registeredName is not null)
{
statusMessage = null;
return registeredName;
}
statusMessage = $"Saved Auto Nest engine '{requestedName}' is unavailable. Using Default.";
return DefaultEngineName;
}
private static string? Find(IEnumerable<string> names, string name) =>
names.FirstOrDefault(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Writes camelCase JSON, creating the parent directory and retrying IO collisions as
/// LocalJsonProvider does. The host handles a persistent write failure.
/// </summary>
public void Save(string path)
{
var json = JsonSerializer.Serialize(
new EngineSelectionSettings { EngineName = NormalizeName(EngineName) }, JsonOptions);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
for (var attempt = 0; attempt < 3; attempt++)
{
try
{
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < 2)
{
Thread.Sleep(100);
}
}
}
private static string NormalizeName(string? name) =>
string.IsNullOrWhiteSpace(name) ? DefaultEngineName : name.Trim();
}
+31
View File
@@ -0,0 +1,31 @@
namespace OpenNest.Data;
/// <summary>
/// Storage backend for saved nests. <see cref="RemoteNestRepository"/> talks to the
/// central nest server; File mode in the desktop app keeps using SaveFileDialog and
/// does not go through this interface.
/// </summary>
public interface INestRepository
{
/// <summary>All stored nests, newest saved first.</summary>
Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default);
Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary>
Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Stores a new nest; returns the server-assigned record.</summary>
Task<NestRecord> UploadAsync(
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
/// <summary>Replaces the archive and metadata of an existing id, keeping the id.</summary>
Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
/// <summary>Replaces metadata only (status, made-by, comments...), leaving the archive.</summary>
Task<NestRecord> UpdateMetadataAsync(
Guid id, NestRecord record, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
+270
View File
@@ -0,0 +1,270 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.Geometry;
namespace OpenNest.Data;
/// <summary>Outcome of <see cref="NestDefaults.Load(string, out NestDefaultsStatus)"/>.</summary>
public enum NestDefaultsStatus
{
/// <summary>Defaults were read from the file (invalid fields still fall back individually).</summary>
Ok,
/// <summary>No file exists at the path; built-in fallback values were used.</summary>
Missing,
/// <summary>The file exists but could not be read or parsed; fallback values were used.</summary>
Invalid,
}
/// <summary>
/// Plate/nest defaults persisted to a single JSON file
/// (by default %APPDATA%\OpenNest\defaults.json), replacing the
/// .nstdot nest-template mechanism. Loading never throws: a missing,
/// corrupt, or partially valid file degrades field-by-field to
/// <see cref="Fallback"/> values so creating a new nest is never blocked.
/// </summary>
public sealed class NestDefaults
{
public const int CurrentVersion = 1;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public Units Units { get; set; } = Units.Inches;
public Size Size { get; set; } = new(100, 100);
public int Quadrant { get; set; } = 1;
public double PartSpacing { get; set; } = 1;
public Spacing EdgeSpacing { get; set; } = new(1, 1, 1, 1);
/// <summary>
/// The built-in defaults used when no file exists and for every field
/// that is missing or invalid. Matches the historical
/// MainForm.CreateDefaultNest values (units default to Inches; callers
/// may override from their own settings).
/// </summary>
public static NestDefaults Fallback => new();
/// <summary>%APPDATA%\OpenNest\defaults.json.</summary>
public static string DefaultPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest",
"defaults.json"
);
/// <summary>
/// Loads defaults from <paramref name="path"/>, falling back field by
/// field for a missing file, invalid JSON, or invalid values.
/// </summary>
public static NestDefaults Load(string path) => Load(path, out _);
/// <summary>
/// Loads defaults and reports whether the file was missing, loaded, or
/// present but unreadable/invalid, so callers can warn about a corrupt
/// file while still returning usable values.
/// </summary>
public static NestDefaults Load(string path, out NestDefaultsStatus status)
{
var defaults = Fallback;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = NestDefaultsStatus.Missing;
return defaults;
}
NestDefaultsDto? dto;
try
{
var json = File.ReadAllText(path);
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
}
catch (JsonException)
{
status = NestDefaultsStatus.Invalid;
return defaults;
}
catch (IOException)
{
status = NestDefaultsStatus.Invalid;
return defaults;
}
if (dto is null)
{
status = NestDefaultsStatus.Invalid;
return defaults;
}
status = NestDefaultsStatus.Ok;
if (
dto.Units is not null
&& Enum.TryParse<Units>(dto.Units, ignoreCase: true, out var units)
)
defaults.Units = units;
if (
dto.Size?.Width is { } width
&& dto.Size.Length is { } length
&& IsValidSize(width, length)
)
defaults.Size = new Size(width, length);
if (dto.Quadrant is { } quadrant && quadrant is >= 1 and <= 4)
defaults.Quadrant = quadrant;
if (dto.PartSpacing is { } partSpacing && IsValidSpacing(partSpacing))
defaults.PartSpacing = partSpacing;
if (
dto.EdgeSpacing?.Left is { } left
&& dto.EdgeSpacing.Bottom is { } bottom
&& dto.EdgeSpacing.Right is { } right
&& dto.EdgeSpacing.Top is { } top
&& IsValidSpacing(left)
&& IsValidSpacing(bottom)
&& IsValidSpacing(right)
&& IsValidSpacing(top)
)
defaults.EdgeSpacing = new Spacing(left, bottom, right, top);
return defaults;
}
/// <summary>
/// Captures the current units and plate defaults from a nest.
/// </summary>
public static NestDefaults FromNest(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
var plate = nest.PlateDefaults;
return new NestDefaults
{
Units = nest.Units,
Size = plate.Size,
Quadrant = plate.Quadrant,
PartSpacing = plate.PartSpacing,
EdgeSpacing = plate.EdgeSpacing,
};
}
/// <summary>
/// Captures defaults from an existing plate (a copy of its size,
/// quadrant, and spacing), e.g. the active plate in the desktop app.
/// </summary>
public static NestDefaults FromPlate(Units units, Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
return new NestDefaults
{
Units = units,
Size = plate.Size,
Quadrant = plate.Quadrant,
PartSpacing = plate.PartSpacing,
EdgeSpacing = plate.EdgeSpacing,
};
}
public void ApplyTo(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
nest.Units = Units;
var plate = nest.PlateDefaults;
plate.Size = Size;
plate.Quadrant = Quadrant;
plate.PartSpacing = PartSpacing;
plate.EdgeSpacing = EdgeSpacing;
}
/// <summary>
/// Writes the file (creating the parent directory), retrying briefly on
/// IO collisions the same way <see cref="LocalJsonProvider"/> does.
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var dto = new NestDefaultsDto
{
Version = CurrentVersion,
Units = Units.ToString().ToLowerInvariant(),
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
Quadrant = Quadrant,
PartSpacing = PartSpacing,
EdgeSpacing = new SpacingDto
{
Left = EdgeSpacing.Left,
Bottom = EdgeSpacing.Bottom,
Right = EdgeSpacing.Right,
Top = EdgeSpacing.Top,
},
};
var json = JsonSerializer.Serialize(dto, JsonOptions);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
}
private static bool IsValidSize(double width, double length) =>
!double.IsNaN(width)
&& !double.IsNaN(length)
&& !double.IsInfinity(width)
&& !double.IsInfinity(length)
&& width > 0
&& length > 0;
private static bool IsValidSpacing(double value) =>
!double.IsNaN(value) && !double.IsInfinity(value) && value >= 0;
/// <summary>
/// Wire format. Every field is nullable so a partial file merges over
/// the fallback field by field; unknown fields (including a future
/// higher <c>version</c>) are ignored rather than rejected.
/// </summary>
private sealed record NestDefaultsDto
{
public int? Version { get; init; } = CurrentVersion;
public string? Units { get; init; }
public SizeDto? Size { get; init; }
public int? Quadrant { get; init; }
public double? PartSpacing { get; init; }
public SpacingDto? EdgeSpacing { get; init; }
}
private sealed record SizeDto
{
public double? Width { get; init; }
public double? Length { get; init; }
}
private sealed record SpacingDto
{
public double? Left { get; init; }
public double? Bottom { get; init; }
public double? Right { get; init; }
public double? Top { get; init; }
}
}
+42
View File
@@ -0,0 +1,42 @@
using OpenNest;
namespace OpenNest.Data;
/// <summary>
/// Server-side metadata for one saved nest. The .nest archive itself is stored
/// alongside; this record is what lists, filters and status tracking read.
/// Counts are captured by the client at save time.
/// </summary>
public sealed class NestRecord
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
public DateTime DateCreated { get; set; }
public DateTime DateModified { get; set; }
public string Material { get; set; } = "";
public double Thickness { get; set; }
/// <summary>quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers).</summary>
public NestStatus Status { get; set; } = NestStatus.Quote;
public int PlateCount { get; set; }
public int PartCount { get; set; }
public string Comments { get; set; } = "";
public string MadeBy { get; set; } = "";
/// <summary>Size of the stored .nest archive in bytes.</summary>
public long FileSize { get; set; }
/// <summary>When the server last stored the nest contents or metadata.</summary>
public DateTime SavedAt { get; set; }
}
+45
View File
@@ -0,0 +1,45 @@
using System.Linq;
namespace OpenNest.Data;
/// <summary>
/// Builds the shareable <see cref="NestRecord"/> metadata from a live <see cref="Nest"/>.
/// Counts are computed here (not carried on <see cref="Nest"/> itself) so every caller
/// that saves to the nest server gets the same definition of "plate" and "part" counts.
/// </summary>
public static class NestRecordFactory
{
/// <summary>
/// Builds a record for uploading <paramref name="nest"/>. <paramref name="id"/> is the
/// client-tracked id: <see cref="System.Guid.Empty"/> for a first save (the server
/// assigns one), or the previously returned id to update an existing record.
/// <paramref name="fileSize"/> is the size of the serialized .nest archive being
/// uploaded alongside this record; the server also recomputes it independently.
/// </summary>
public static NestRecord FromNest(Nest nest, System.Guid id, long fileSize)
{
ArgumentNullException.ThrowIfNull(nest);
return new NestRecord
{
Id = id,
Name = nest.Name ?? "",
Customer = nest.Customer ?? "",
DateCreated = nest.DateCreated,
DateModified = nest.DateLastModified,
Material = nest.Material?.Name ?? "",
Thickness = nest.Thickness,
Status = nest.Status,
PlateCount = nest.Plates.Count,
PartCount = nest.Plates.Sum(CountNonCutoffParts),
// Nest has no separate "comments" field; Notes is the closest analog
// shown in the nest info dialog, so it round-trips as Comments here.
Comments = nest.Notes ?? "",
MadeBy = nest.MadeBy ?? "",
FileSize = fileSize,
};
}
private static int CountNonCutoffParts(Plate plate) =>
plate.Parts.Count(part => !part.BaseDrawing.IsCutOff);
}
+46
View File
@@ -0,0 +1,46 @@
namespace OpenNest.Data;
/// <summary>
/// Tracks a document's database identity, bound to a particular server address.
/// A failed upload leaves that identity untouched. Switching servers creates a new
/// record rather than accidentally updating an unrelated record with the same id.
/// </summary>
public sealed class NestSaveSession
{
public Guid RemoteId { get; private set; }
public string ServerUrl { get; private set; } = "";
public void Bind(Guid id, string serverUrl)
{
if (id == Guid.Empty)
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
RemoteId = id;
ServerUrl = Normalize(serverUrl);
}
public async Task<NestRecord> SaveAsync(
INestRepository repository, string serverUrl, Nest nest, byte[] archive,
bool saveCopy = false, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(nest);
ArgumentNullException.ThrowIfNull(archive);
var normalizedUrl = Normalize(serverUrl);
if (normalizedUrl.Length == 0)
throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
var record = NestRecordFactory.FromNest(nest, id, archive.LongLength);
var saved = id == Guid.Empty
? await repository.UploadAsync(archive, record, cancellationToken)
: await repository.UpdateFileAsync(id, archive, record, cancellationToken);
if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id))
throw new InvalidDataException("Server returned an invalid nest id.");
Bind(saved.Id, normalizedUrl);
return saved;
}
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
}
+83
View File
@@ -0,0 +1,83 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>Where the desktop app persists nests: local .nest files or the shared nest server.</summary>
public enum NestStorageMode
{
File,
Database,
}
/// <summary>
/// Storage-mode toggle and nest-server address, stored at %APPDATA%\OpenNest\storage.json.
/// Loading never throws; a missing or corrupt file means File mode so existing installs
/// keep the original behavior until the operator opts in.
/// </summary>
public sealed class NestStorageSettings
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public NestStorageMode Mode { get; set; } = NestStorageMode.File;
/// <summary>Base URL of the nest server, e.g. http://barge.lan:8090. No trailing slash required.</summary>
public string ServerUrl { get; set; } = "";
/// <summary>True when Database mode is active and a server URL is configured.</summary>
public bool IsDatabaseMode =>
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest", "storage.json");
public static NestStorageSettings Load(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return new();
try
{
var settings = JsonSerializer.Deserialize<NestStorageSettings>(
File.ReadAllText(path), JsonOptions) ?? new();
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
return settings;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return new();
}
}
/// <summary>Writes camelCase JSON, creating the parent directory and retrying IO collisions.</summary>
public void Save(string path)
{
var json = JsonSerializer.Serialize(
new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) },
JsonOptions);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
for (var attempt = 0; attempt < 3; attempt++)
{
try
{
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < 2)
{
Thread.Sleep(100);
}
}
}
private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/');
}
+159
View File
@@ -0,0 +1,159 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Data;
/// <summary>
/// Talks to the central OpenNest nest server over HTTP. Uploads are multipart:
/// a "metadata" JSON part and a "file" part holding the .nest archive.
/// Connection and HTTP failures surface as HttpRequestException/IOException to the caller.
/// </summary>
public sealed class RemoteNestRepository : INestRepository, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
public RemoteNestRepository(string baseUrl)
: this(new HttpClient(), baseUrl)
{
_ownsClient = true;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
public RemoteNestRepository(HttpClient httpClient, string baseUrl)
{
_httpClient = httpClient;
_ownsClient = false;
var url = (baseUrl ?? "").Trim().TrimEnd('/');
if (!Uri.TryCreate(url, UriKind.Absolute, out var baseUri)
|| (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps))
{
throw new ArgumentException($"Invalid nest server URL: '{baseUrl}'", nameof(baseUrl));
}
// Keep the trailing slash so relative combines replace no path segment.
_baseUri = new Uri(baseUri.AbsoluteUri.TrimEnd('/') + "/");
}
private Uri Url(string relative) => new(_baseUri, relative);
public async Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
await EnsureSuccess(response, "list nests", cancellationToken);
var items = await response.Content
.ReadFromJsonAsync<List<NestRecord>>(JsonOptions, cancellationToken);
return items ?? new List<NestRecord>();
}
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
}
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
public async Task<NestRecord> UploadAsync(
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
using var content = BuildMultipart(nestFile, record);
using var response = await _httpClient.PostAsync(Url("api/nests"), content, cancellationToken);
await EnsureSuccess(response, $"upload nest '{record.Name}'", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
using var content = BuildMultipart(nestFile, record);
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
await EnsureSuccess(response, $"update nest {id}", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task<NestRecord> UpdateMetadataAsync(
Guid id, NestRecord record, CancellationToken cancellationToken = default)
{
using var content = new StringContent(
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken);
await EnsureSuccess(response, $"delete nest {id}", cancellationToken);
}
public void Dispose()
{
if (_ownsClient)
_httpClient.Dispose();
}
private static MultipartFormDataContent BuildMultipart(byte[] nestFile, NestRecord record)
{
var content = new MultipartFormDataContent();
var metadata = new StringContent(
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
content.Add(metadata, "metadata");
var file = new ByteArrayContent(nestFile);
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
content.Add(file, "file", record.Name.Length > 0 ? $"{record.Name}.nest" : "nest.nest");
return content;
}
private static async Task<NestRecord> ReadRecord(
HttpResponseMessage response, CancellationToken cancellationToken)
{
var record = await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
return record ?? throw new IOException("Nest server returned an empty record.");
}
private static async Task EnsureSuccess(
HttpResponseMessage response, string action, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
return;
var detail = "";
try
{
detail = await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
// The status code is the useful part; the body is best-effort.
}
if (detail.Length > 200)
detail = detail[..200] + "…";
throw new IOException(
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
}
}
@@ -0,0 +1,147 @@
using System.Drawing;
using OpenNest.Engine.CirclePacking;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.CirclePacking;
public class ItemCloneTests
{
[Fact]
public void Clone_FromItemReference_ReturnsItem()
{
var item = CreateItem();
var clone = item.Clone();
Assert.IsType<Item>(clone);
Assert.NotSame(item, clone);
}
[Fact]
public void Clone_FromCircleReference_ReturnsItem()
{
var item = CreateItem();
var circle = (Circle)item;
var clone = circle.Clone();
Assert.IsType<Item>(clone);
Assert.NotSame(item, clone);
}
[Fact]
public void Clone_FromEntityReference_ReturnsItem()
{
var item = CreateItem();
var entity = (Entity)item;
var clone = entity.Clone();
Assert.IsType<Item>(clone);
Assert.NotSame(item, clone);
}
[Fact]
public void Clone_PreservesPackingIdAndCreatesNewGeometryId()
{
var item = CreateItem();
var clone = Assert.IsType<Item>(item.Clone());
Assert.Equal(item.PackingId, clone.PackingId);
Assert.NotEqual(Guid.Empty, ((Entity)clone).Id);
Assert.NotEqual(((Entity)item).Id, ((Entity)clone).Id);
}
[Fact]
public void Clone_PreservesRotation()
{
var item = CreateItem();
var clone = Assert.IsType<Item>(item.Clone());
Assert.Equal(item.Rotation, clone.Rotation);
}
[Fact]
public void Clone_PreservesEntityMetadata()
{
var item = CreateItem();
var clone = Assert.IsType<Item>(item.Clone());
Assert.Equal(item.Color, clone.Color);
Assert.Same(item.Layer, clone.Layer);
Assert.Equal(item.LineTypeName, clone.LineTypeName);
Assert.Equal(item.IsVisible, clone.IsVisible);
Assert.Equal(item.Tag, clone.Tag);
}
[Fact]
public void Clone_HasIndependentGeometryAndBounds()
{
var item = CreateItem();
var sourceCenter = item.Center;
var sourceRadius = item.Radius;
var sourceBounds = (item.Left, item.Bottom, item.Right, item.Top);
var clone = Assert.IsType<Item>(item.Clone());
Assert.Equal(sourceCenter, clone.Center);
Assert.Equal(sourceRadius, clone.Radius);
Assert.Equal(sourceBounds, (clone.Left, clone.Bottom, clone.Right, clone.Top));
Assert.NotSame(item.BoundingBox, clone.BoundingBox);
var cloneCenter = clone.Center;
cloneCenter.X += 30;
cloneCenter.Y -= 10;
clone.Center = cloneCenter;
Assert.Equal(sourceCenter, item.Center);
Assert.Equal(sourceBounds, (item.Left, item.Bottom, item.Right, item.Top));
Assert.Equal(cloneCenter, clone.Center);
Assert.Equal(sourceRadius, clone.Radius);
Assert.NotEqual(sourceBounds, (clone.Left, clone.Bottom, clone.Right, clone.Top));
Assert.Equal(
(cloneCenter.X - sourceRadius, cloneCenter.Y - sourceRadius,
cloneCenter.X + sourceRadius, cloneCenter.Y + sourceRadius),
(clone.Left, clone.Bottom, clone.Right, clone.Top));
clone.Radius += 2;
Assert.Equal(sourceRadius, item.Radius);
Assert.Equal(sourceBounds, (item.Left, item.Bottom, item.Right, item.Top));
Assert.Equal(
(cloneCenter.X - clone.Radius, cloneCenter.Y - clone.Radius,
cloneCenter.X + clone.Radius, cloneCenter.Y + clone.Radius),
(clone.Left, clone.Bottom, clone.Right, clone.Top));
}
[Fact]
public void Clone_Twice_CreatesDistinctGeometryIds()
{
var item = CreateItem();
var first = (Entity)Assert.IsType<Item>(item.Clone());
var second = (Entity)Assert.IsType<Item>(item.Clone());
Assert.NotEqual(Guid.Empty, first.Id);
Assert.NotEqual(Guid.Empty, second.Id);
Assert.NotEqual(((Entity)item).Id, first.Id);
Assert.NotEqual(((Entity)item).Id, second.Id);
Assert.NotEqual(first.Id, second.Id);
}
private static Item CreateItem() => new Item
{
PackingId = 42,
Radius = 5,
Center = new Vector(10, 20),
Rotation = RotationType.CCW,
Color = Color.CornflowerBlue,
Layer = new Layer("packing"),
LineTypeName = "Dashed",
IsVisible = false,
Tag = "packing-item",
};
}
+347
View File
@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
/// <summary>
/// PlateView spacing expander: grows part-to-part spacing with the work area
/// and non-selected parts as hard boundaries.
/// </summary>
public class ExpanderTests
{
private static Program Rectangle(double width = 4, double length = 4)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(width, 0);
program.LineTo(width, length);
program.LineTo(0, length);
program.LineTo(0, 0);
return program;
}
private static Part AddSquare(Plate plate, double x, double y, double size = 4)
{
var part = new Part(new Drawing($"sq{plate.Parts.Count}", Rectangle(size, size)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
{
var plate = new Plate(new Size(widthY, lengthX));
plate.EdgeSpacing = new Spacing(edge, edge);
return plate;
}
/// <summary>Independent clearance oracle: naive vertex/segment min distance over raw part lines.</summary>
private static double BruteClearance(Part a, Part b)
{
var linesA = PartGeometry.GetPartLines(a);
var linesB = PartGeometry.GetPartLines(b);
double min = double.MaxValue;
foreach (var la in linesA)
foreach (var lb in linesB)
{
min = System.Math.Min(min, PointSegment(a, la.StartPoint, lb));
min = System.Math.Min(min, PointSegment(a, la.EndPoint, lb));
min = System.Math.Min(min, PointSegment(b, lb.StartPoint, la));
min = System.Math.Min(min, PointSegment(b, lb.EndPoint, la));
}
return min;
}
private static double PointSegment(Part owner, Vector pt, Line seg)
{
var d = seg.EndPoint - seg.StartPoint;
var len2 = d.DotProduct(d);
var t = len2 <= 1e-12 ? 0 : System.Math.Clamp((pt - seg.StartPoint).DotProduct(d) / len2, 0, 1);
return pt.DistanceTo(seg.StartPoint + d * t);
}
private static void AssertNoOverlaps(Plate plate)
{
for (var i = 0; i < plate.Parts.Count; i++)
for (var j = i + 1; j < plate.Parts.Count; j++)
Assert.False(
plate.Parts[i].Intersects(plate.Parts[j], out _),
$"{plate.Parts[i].BaseDrawing.Name} overlaps {plate.Parts[j].BaseDrawing.Name}"
);
}
[Fact]
public void Expand_TwoSquares_GrowUntilEdgeFloor_AndAnchorStaysPut()
{
var plate = MakePlate(24, 24);
var a = AddSquare(plate, 6, 10);
var b = AddSquare(plate, 14, 10);
var result = Expander.Expand(new List<Part> { a, b }, plate);
// Max gap: B flush against the right edge floor (23.5): 23.5 - 14 - 4 + gap base...
// A stays (anchor); B slides to x=19.5 -> gap 9.5.
Assert.Equal(6, a.Location.X, 6);
Assert.Equal(10, a.Location.Y, 6);
Assert.Equal(9.5, b.Location.X - (a.Location.X + 4), 1);
Assert.True(result.AchievedSpacing >= 9.4, $"achieved {result.AchievedSpacing}");
Assert.True(result.AchievedSpacing <= 9.6, $"achieved {result.AchievedSpacing}");
AssertNoOverlaps(plate);
Assert.True(b.BoundingBox.Right <= 23.5 + 1e-6);
}
[Fact]
public void Expand_SandwichedBetweenWalls_ConvergesOnlyToInitialGaps_AndKeepsWalls()
{
var plate = MakePlate(26, 10, edge: 0.0);
var wallL = AddSquare(plate, 0, 3);
var wallR = AddSquare(plate, 18, 3);
var a = AddSquare(plate, 6, 3);
var b = AddSquare(plate, 12, 3);
var result = Expander.Expand(new List<Part> { a, b }, plate);
// Every gap starts at exactly 2.0; straight separation moves cannot open
// the row (opening one gap costs another), so the run stays at ~2.0.
Assert.Equal(0, wallL.Location.X, 6);
Assert.Equal(18, wallR.Location.X, 6);
Assert.True(
result.AchievedSpacing >= 1.9 && result.AchievedSpacing <= 2.05,
$"achieved {result.AchievedSpacing}"
);
AssertNoOverlaps(plate);
}
[Fact]
public void Expand_OverlappingPair_SeparatesAndClearsOverlap()
{
var plate = MakePlate(30, 12);
var a = AddSquare(plate, 5, 4);
var b = AddSquare(plate, 7, 4); // 2.0 overlap in X
var result = Expander.Expand(new List<Part> { a, b }, plate, new Expander.Options
{
InitialStep = 0.5,
MaxSpacing = 3,
});
Assert.False(a.Intersects(b, out _));
var gap = b.Location.X - (a.Location.X + 4);
Assert.True(gap >= 2.99, $"gap {gap}");
Assert.True(result.AchievedSpacing >= 2.9);
AssertNoOverlaps(plate);
}
[Fact]
public void Separate_PinnedPart_ReportsViolationsWithoutOverlap()
{
var plate = MakePlate(20, 20, edge: 0.0);
var pinned = AddSquare(plate, 8, 8);
// Walls 0.2 clear on all four sides.
var left = AddSquare(plate, 3.8, 8);
var right = AddSquare(plate, 12.2, 8);
var bottom = AddSquare(plate, 8, 3.8);
var top = AddSquare(plate, 8, 12.2);
var (converged, positions, violations) = Expander.Separate(
new List<Part> { pinned },
plate,
spacing: 1.0
);
Assert.False(converged);
Assert.NotEmpty(violations);
// The pinned part may slide into the walls but never through them.
AssertNoOverlaps(plate);
Assert.Equal(3.8, left.Location.X, 6);
Assert.Equal(12.2, right.Location.X, 6);
Assert.Equal(3.8, bottom.Location.Y, 6);
Assert.Equal(12.2, top.Location.Y, 6);
}
[Fact]
public void Expand_CancelledBeforeRun_LeavesEverythingInPlace()
{
var plate = MakePlate(24, 24);
var a = AddSquare(plate, 6, 10);
var b = AddSquare(plate, 14, 10);
using var cts = new CancellationTokenSource();
cts.Cancel();
var result = Expander.Expand(
new List<Part> { a, b },
plate,
token: cts.Token
);
Assert.True(result.Cancelled);
Assert.Equal(6, a.Location.X, 6);
Assert.Equal(14, b.Location.X, 6);
}
[Fact]
public void Expand_ThreeInRow_FirstSelectedNeverMoves_AndOracleConfirmsSpacing()
{
var plate = MakePlate(60, 14);
var a = AddSquare(plate, 5, 5);
var b = AddSquare(plate, 10, 5);
var c = AddSquare(plate, 15, 5);
var result = Expander.Expand(
new List<Part> { a, b, c },
plate,
new Expander.Options { MaxSpacing = 8 }
);
Assert.Equal(5, a.Location.X, 6); // anchor: never the later index of any pair
Assert.True(result.AchievedSpacing >= 7.9);
// Independent oracle: every pair clears the reported spacing.
var parts = new List<Part> { a, b, c };
for (var i = 0; i < parts.Count; i++)
for (var j = i + 1; j < parts.Count; j++)
{
var clearance = BruteClearance(parts[i], parts[j]);
Assert.True(
clearance >= result.AchievedSpacing - 0.01,
$"{parts[i].BaseDrawing.Name}/{parts[j].BaseDrawing.Name}: oracle {clearance} < reported {result.AchievedSpacing}"
);
}
AssertNoOverlaps(plate);
}
private static Program RectangleWithHole(
double width,
double length,
double hx,
double hy,
double hw,
double hh
)
{
var program = Rectangle(width, length);
program.MoveTo(hx, hy);
program.LineTo(hx + hw, hy);
program.LineTo(hx + hw, hy + hh);
program.LineTo(hx, hy + hh);
program.LineTo(hx, hy);
return program;
}
/// <summary>
/// Hole-subtracting overlap check matching NestValidator semantics (a part in
/// a cutout is legal). Part.Intersects is perimeter-only, so it cannot
/// certify part-in-cutout layouts.
/// </summary>
private static bool MateriallyOverlaps(Part a, Part b)
{
var (outerA, holesA) = Rings(a);
var (outerB, holesB) = Rings(b);
return Collision.HasOverlap(outerA, outerB, holesA, holesB);
}
private static (Polygon Outer, List<Polygon> Holes) Rings(Part part)
{
var entities = OpenNest.Converters.ConvertProgram
.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
var profile = new ShapeProfile(entities);
var outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
outer.Offset(part.Location);
var holes = new List<Polygon>();
foreach (var cutout in profile.Cutouts)
{
var hole = cutout.ToPolygonWithTolerance(0.001);
hole.Offset(part.Location);
holes.Add(hole);
}
return (outer, holes.Count == 0 ? null : holes);
}
[Fact]
public void Expand_PartInsideCutout_KeepsLegalAndClearsHoleWalls()
{
var plate = MakePlate(40, 24);
// Wall part with a 10x10 cutout; a small selected part sits inside it.
var wall = new Part(
new Drawing(
"wall",
RectangleWithHole(20, 20, 5, 5, 10, 10)
),
new Vector(0, 0)
);
plate.Parts.Add(wall);
var inside = new Part(new Drawing("inside", Rectangle(2, 2)), new Vector(9, 9));
var other = new Part(new Drawing("other", Rectangle(2, 2)), new Vector(30, 9));
plate.Parts.Add(inside);
plate.Parts.Add(other);
var result = Expander.Expand(
new List<Part> { inside, other },
plate,
new Expander.Options { MaxSpacing = 2 }
);
// Part-in-cutout is legal, never a material overlap.
Assert.False(MateriallyOverlaps(inside, wall));
Assert.False(MateriallyOverlaps(other, wall));
Assert.False(MateriallyOverlaps(inside, other));
Assert.True(result.AchievedSpacing >= 1.9);
// The part that started in the cutout must clear the hole walls too.
var holeLeft = 5;
var holeRight = 15;
var gapLeft = inside.Location.X - holeLeft;
var gapRight = holeRight - (inside.Location.X + 2);
var gapBottom = inside.Location.Y - holeLeft;
var gapTop = holeRight - (inside.Location.Y + 2);
var minGap = System.Math.Min(
System.Math.Min(gapLeft, gapRight),
System.Math.Min(gapBottom, gapTop)
);
Assert.True(minGap >= 1.9, $"closest hole-wall gap {minGap}");
}
[Fact]
public void Expand_DegenerateInputs_Throw()
{
var plate = MakePlate(10, 10);
var a = AddSquare(plate, 1, 1);
var stranger = new Part(new Drawing("stranger", Rectangle()), new Vector(50, 50));
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part>(), plate));
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a }, plate));
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a, stranger }, plate));
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null));
}
[Fact]
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
{
// Selection must not be pushed to open gaps between parts it excludes.
var plate = MakePlate(40, 12);
var w1 = AddSquare(plate, 2, 4);
var w2 = AddSquare(plate, 6.5, 4); // 0.5 apart from w1, both unselected
var a = AddSquare(plate, 14, 4);
var b = AddSquare(plate, 20, 4);
Expander.Expand(new List<Part> { a, b }, plate);
Assert.Equal(2, w1.Location.X, 6);
Assert.Equal(6.5, w2.Location.X, 6);
Assert.True(a.Intersects(w1, out _) == false);
Assert.True(b.Intersects(w2, out _) == false);
}
}
+40 -38
View File
@@ -1,8 +1,8 @@
using System.Globalization;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using Xunit;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Tests.Jobs;
@@ -16,7 +16,9 @@ namespace OpenNest.Engine.Tests.Jobs;
/// </summary>
/// <remarks>
/// Captured on Linux/.NET 8 at commit 42bbde7 (post ShrinkFiller axis fix), verified
/// identical across 30 repeat runs per strategy. The Strip strategy is only pinned on the
/// identical across 30 repeat runs per strategy. The Default and remnant mixed-job layouts were
/// re-captured when leftover packing moved to the maximal-rectangles packer (every pose passes
/// NestLayoutCheck). The Strip strategy is only pinned on the
/// rectangle-variety job: on dense mixed-shape jobs the iterative shrink path intermittently
/// proposes overlapping candidates (pre-existing scheduling nondeterminism, not a regression),
/// so its mixed-geometry layout is deliberately not pinned here.
@@ -169,12 +171,12 @@ public class GoldenLayoutTests
AssertGolden(
result,
[
P("arc", 0, 15, 1, 0),
P("arc", 1, 15, 7, 0),
P("arc", 2, 15, 13, 0),
P("lshape", 0, 15, 19, 0),
P("lshape", 1, 15, 24, 0),
P("lshape", 2, 21.5, 1, 0),
P("arc", 0, 20, 1, 1.5707963267948966),
P("arc", 1, 20, 7.5, 1.5707963267948966),
P("arc", 2, 20, 14, 1.5707963267948966),
P("lshape", 0, 19, 20.5, 1.5707963267948966),
P("lshape", 1, 24, 20.5, 1.5707963267948966),
P("lshape", 2, 25, 1, 1.5707963267948966),
P("rect-a", 0, 5, 21, 1.5707963267948966),
P("rect-a", 1, 1, 1, 0),
P("rect-a", 2, 1, 6, 0),
@@ -183,12 +185,12 @@ public class GoldenLayoutTests
P("rect-a", 5, 10, 21, 1.5707963267948966),
P("rect-a", 6, 8, 1, 0),
P("rect-a", 7, 8, 6, 0),
P("rect-b", 0, 21.5, 6, 0),
P("rect-b", 1, 21.5, 10, 0),
P("rect-b", 2, 21.5, 14, 0),
P("rect-b", 3, 22, 19, 0),
P("rect-b", 4, 22, 23, 0),
P("rect-b", 5, 26.5, 6, 0),
P("rect-b", 0, 24, 8, 1.5707963267948966),
P("rect-b", 1, 24, 13, 1.5707963267948966),
P("rect-b", 2, 28, 8, 1.5707963267948966),
P("rect-b", 3, 28, 13, 1.5707963267948966),
P("rect-b", 4, 28, 18, 1.5707963267948966),
P("rect-b", 5, 28, 23, 1.5707963267948966),
]
);
}
@@ -206,12 +208,12 @@ public class GoldenLayoutTests
AssertGolden(
result,
[
P("arc", 0, 15, 1, 0),
P("arc", 1, 15, 7, 0),
P("arc", 2, 15, 13, 0),
P("lshape", 0, 15, 19, 0),
P("lshape", 1, 15, 24, 0),
P("lshape", 2, 21.5, 1, 0),
P("arc", 0, 20, 1, 1.5707963267948966),
P("arc", 1, 20, 7.5, 1.5707963267948966),
P("arc", 2, 20, 14, 1.5707963267948966),
P("lshape", 0, 19, 20.5, 1.5707963267948966),
P("lshape", 1, 24, 20.5, 1.5707963267948966),
P("lshape", 2, 25, 1, 1.5707963267948966),
P("rect-a", 0, 5, 21, 1.5707963267948966),
P("rect-a", 1, 1, 1, 0),
P("rect-a", 2, 1, 6, 0),
@@ -220,12 +222,12 @@ public class GoldenLayoutTests
P("rect-a", 5, 10, 21, 1.5707963267948966),
P("rect-a", 6, 8, 1, 0),
P("rect-a", 7, 8, 6, 0),
P("rect-b", 0, 21.5, 6, 0),
P("rect-b", 1, 21.5, 10, 0),
P("rect-b", 2, 21.5, 14, 0),
P("rect-b", 3, 22, 19, 0),
P("rect-b", 4, 22, 23, 0),
P("rect-b", 5, 26.5, 6, 0),
P("rect-b", 0, 24, 8, 1.5707963267948966),
P("rect-b", 1, 24, 13, 1.5707963267948966),
P("rect-b", 2, 28, 8, 1.5707963267948966),
P("rect-b", 3, 28, 13, 1.5707963267948966),
P("rect-b", 4, 28, 18, 1.5707963267948966),
P("rect-b", 5, 28, 23, 1.5707963267948966),
]
);
}
@@ -241,12 +243,12 @@ public class GoldenLayoutTests
AssertGolden(
result,
[
P("arc", 0, 1, 23, 0),
P("arc", 1, 7.5, 23, 0),
P("arc", 2, 8, 8, 0),
P("lshape", 0, 1, 8, 0),
P("lshape", 1, 1, 13, 0),
P("lshape", 2, 1, 18, 0),
P("arc", 0, 1, 8, 0),
P("arc", 1, 7.5, 8, 0),
P("arc", 2, 14, 8, 0),
P("lshape", 0, 20.5, 8, 0),
P("lshape", 1, 27.5, 8, 0),
P("lshape", 2, 34.5, 8, 0),
P("rect-a", 0, 1, 1, 0),
P("rect-a", 1, 8, 1, 0),
P("rect-a", 2, 15, 1, 0),
@@ -255,12 +257,12 @@ public class GoldenLayoutTests
P("rect-a", 5, 36, 1, 1.5707963267948966),
P("rect-a", 6, 41, 1, 1.5707963267948966),
P("rect-a", 7, 46, 1, 1.5707963267948966),
P("rect-b", 0, 8, 14, 0),
P("rect-b", 1, 8, 18, 0),
P("rect-b", 2, 13, 14, 0),
P("rect-b", 3, 13, 18, 0),
P("rect-b", 4, 14, 23, 0),
P("rect-b", 5, 14.5, 8, 0),
P("rect-b", 0, 41.5, 8, 0),
P("rect-b", 1, 41.5, 12, 0),
P("rect-b", 2, 20.5, 13, 0),
P("rect-b", 3, 25.5, 13, 0),
P("rect-b", 4, 30.5, 13, 0),
P("rect-b", 5, 35.5, 13, 0),
]
);
}
@@ -6,32 +6,78 @@ namespace OpenNest.Engine.Tests.Jobs;
public class NestLayoutCheckTests
{
[Theory]
[InlineData(null, false)]
[InlineData(1, true)]
[InlineData(2, false)]
[InlineData(3, false)]
public void MaxPlatesCountsPhysicalSheetsIncludingEmptyOnes(int? maxPlates, bool exceedsLimit)
{
var stock = new NestPlateStock("sheet", new Size(48, 96));
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock }, new NestJobOptions(maxPlates: maxPlates));
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[]
{
new NestJobPlateResult(7, stock, Array.Empty<NestJobPlacement>()),
new NestJobPlateResult(42, stock, Array.Empty<NestJobPlacement>()),
}, Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
var violations = NestLayoutCheck.Violations(job, result);
if (exceedsLimit)
Assert.Contains(violations, v => v.Contains("MaxPlates") && v.Contains("2") && v.Contains("1"));
else
Assert.Empty(violations);
}
[Theory]
[InlineData("ghost", 1, 1, 0)]
[InlineData(null, 1, 1, 0)]
[InlineData("p", double.NaN, 1, 0)]
[InlineData("p", 1, double.PositiveInfinity, 0)]
[InlineData("p", 1, 1, double.NaN)]
public void MalformedPlacementsProduceDiagnosticsRatherThanThrowing(string? partId, double x, double y, double rotation)
{
var stock = new NestPlateStock("sheet", new Size(48, 96));
var job = new NestJob(new[]
{
new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), 1),
}, new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement(partId!, 0, x, y, rotation) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
var violations = NestLayoutCheck.Violations(job, result);
Assert.NotEmpty(violations);
}
[Fact]
public void TangentDiscsClearAtSafeMarginAcrossRadiiAnglesAndTolerances()
{
var count = 0;
foreach (var radius in new[] { 0.1, 1.0, 10.0 })
foreach (var tolerance in new[] { 0.0, 0.0005, 0.01 })
foreach (var spacing in new[] { 0.0, 0.25 })
{
var program = new Program();
program.MoveTo(radius, 0);
program.Codes.Add(new ArcMove(radius, 0, 0, 0, RotationType.CW));
var geometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(program));
// Two inscribed engine outlines can underestimate true extent by t each.
var distance = 2 * radius + spacing
+ NestTolerances.SafeClearanceMargin(tolerance) - 2 * tolerance;
for (var degrees = 0; degrees < 360; degrees += 15)
{
var angle = degrees * System.Math.PI / 180;
var a = new NestJobPlacement("disc", 0, 0.12345, -0.54321, angle / 3);
var b = new NestJobPlacement("disc", 1, a.X + distance * System.Math.Cos(angle),
a.Y + distance * System.Math.Sin(angle), -angle / 7);
Assert.True(NestLayoutCheck.Clears(geometry, a, geometry, b, spacing));
Assert.True(NestLayoutCheck.Clears(geometry, b, geometry, a, spacing));
count++;
}
}
foreach (var tolerance in new[] { 0.0, 0.0005, 0.01 })
foreach (var spacing in new[] { 0.0, 0.25 })
{
var program = new Program();
program.MoveTo(radius, 0);
program.Codes.Add(new ArcMove(radius, 0, 0, 0, RotationType.CW));
var geometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(program));
// Two inscribed engine outlines can underestimate true extent by t each.
var distance = 2 * radius + spacing
+ NestTolerances.SafeClearanceMargin(tolerance) - 2 * tolerance;
for (var degrees = 0; degrees < 360; degrees += 15)
{
var angle = degrees * System.Math.PI / 180;
var a = new NestJobPlacement("disc", 0, 0.12345, -0.54321, angle / 3);
var b = new NestJobPlacement("disc", 1, a.X + distance * System.Math.Cos(angle),
a.Y + distance * System.Math.Sin(angle), -angle / 7);
Assert.True(NestLayoutCheck.Clears(geometry, a, geometry, b, spacing));
Assert.True(NestLayoutCheck.Clears(geometry, b, geometry, a, spacing));
count++;
}
}
Assert.Equal(432, count);
}
@@ -0,0 +1,90 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class NestPipelineCommitTests
{
private sealed class StubEngine(bool overlap) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => new(
NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], new[]
{
new NestJobPlacement(job.Parts[0].Id, 0, 2, 2, 0),
new NestJobPlacement(job.Parts[0].Id, 1, overlap ? 2 : 20, 2, 0),
}) },
new[] { new PartFulfillment(job.Parts[0].Id, 2, 2, 0) },
new[] { new StockUsage(job.Plates[0].Id, 1, null) });
}
private static NestPipelineResult Result(Drawing drawing, bool overlap = false) =>
NestPipeline.Run(new StubEngine(overlap), "stub", new NestPipelineRequest("stub",
new[] { new NestItem { Drawing = drawing, Quantity = 2 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1), 1) }));
[Fact]
public void AppliesValidatedSettingsToEmptyPlateAndNeverFillsOccupiedPlate()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var occupied = nest.CreatePlate();
occupied.Parts.Add(new Part(drawing));
var empty = nest.CreatePlate();
empty.Quantity = 7;
empty.Quadrant = 3;
empty.Size = new Size(4, 8);
empty.EdgeSpacing = new Spacing(0, 0);
using var manager = new PlateManager(nest);
var result = Result(drawing);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
Assert.Same(empty, Assert.Single(applied));
Assert.Single(occupied.Parts);
Assert.Equal(2, empty.Parts.Count);
Assert.Equal(1, empty.Quantity);
Assert.Equal(1, empty.Quadrant);
Assert.Equal(result.Job.Plates[0].Size, empty.Size);
Assert.Equal(result.Job.Plates[0].EdgeSpacing, empty.EdgeSpacing);
Assert.Equal(0.25, empty.PartSpacing);
Assert.All(empty.Parts, p => Assert.Same(drawing, p.BaseDrawing));
Assert.Equal(3, drawing.Quantity.Nested);
}
[Fact]
public void InvalidResultRequiresExplicitConsentAndDiscardDoesNotMutateNest()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = Result(drawing, overlap: true);
Assert.False(result.IsValid);
Assert.Throws<InvalidOperationException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true);
Assert.Equal(2, Assert.Single(applied).Parts.Count);
}
[Fact]
public void CancelledCommitDoesNotCreateAnyPlate()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
using var manager = new PlateManager(nest);
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.ThrowsAny<OperationCanceledException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(Result(drawing), manager, token: cts.Token));
Assert.Empty(nest.Plates);
}
}
@@ -0,0 +1,271 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class NestPipelineTests
{
private static NestPlateStock Sheet() =>
new("sheet", new Size(48, 96), quantity: null, partSpacing: 0.25);
private static NestItem Item(string name, int quantity) =>
new()
{
Drawing = new Drawing(name, TestDrawingFactory.Rectangle()),
Quantity = quantity,
};
private static NestPipelineRequest Request(string engine, params NestItem[] items) =>
new(engine, items, new[] { Sheet() });
private static NestJobResult OnePlate(NestJob job, params NestJobPlacement[] placements) =>
new(
NestJobStatus.Complete,
NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], placements) },
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, p.Quantity, 0)),
new[] { new StockUsage(job.Plates[0].Id, 1, null) }
);
private sealed class StubEngine(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
) => solve(job);
}
[Fact]
public void RegisteredEngineResultIsValidatedAndBoundToCallerDrawings()
{
var item = Item("bracket", 10);
var codes = item.Drawing.Program.Codes.Count;
var result = NestPipeline.Run(Request("Default", item));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.True(result.CanKeep);
Assert.Equal(NestJobStatus.Complete, result.Status);
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
Assert.Equal(10, parts.Count);
Assert.All(parts, part => Assert.Same(item.Drawing, part.BaseDrawing));
Assert.All(result.Plates, plate => Assert.Same(result.Job.Plates[0], plate.Stock));
Assert.Equal(10, item.Quantity);
Assert.Equal(codes, item.Drawing.Program.Codes.Count);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Fact]
public void OverlappingEngineOutputIsReportedByDrawingNameWithoutThrowing()
{
var item = Item("bracket", 2);
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
var engine = new StubEngine(job =>
OnePlate(
job,
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
new NestJobPlacement(job.Parts[0].Id, 1, 1, 1, 0)
)
);
var result = NestPipeline.Run(engine, "Overlapper", Request("Overlapper", item));
Assert.False(result.IsValid);
Assert.True(result.CanKeep);
Assert.Contains(result.Violations, v => v.Contains("bracket") && v.Contains("spacing"));
Assert.Equal(2, result.Plates.Single().Parts.Count);
Assert.All(result.Plates.Single().Parts, part => Assert.Same(item.Drawing, part.BaseDrawing));
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
Assert.Equal(2, item.Quantity);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Theory]
[InlineData("ghost")]
[InlineData(null)]
public void UnknownOrNullRequirementMakesTheEntireProposalNonKeepable(string? partId)
{
var item = Item("bracket", 1);
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
var engine = new StubEngine(job => new NestJobResult(
NestJobStatus.Complete,
NestJobStopReason.Completed,
new[]
{
new NestJobPlateResult(0, job.Plates[0], new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) }),
new NestJobPlateResult(1, job.Plates[0], new[] { new NestJobPlacement(partId!, 0, 30, 1, 0) }),
},
Array.Empty<PartFulfillment>(),
Array.Empty<StockUsage>()
));
var result = NestPipeline.Run(engine, "Ghost", Request("Ghost", item));
Assert.False(result.IsValid);
Assert.False(result.CanKeep);
Assert.Contains(result.Violations, v => v.Contains(partId ?? "null") && v.Contains("Plate 1"));
Assert.Empty(result.Plates);
Assert.Equal(2, result.Raw.Plates.Count);
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
Assert.Equal(1, item.Quantity);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Theory]
[InlineData(double.NaN, 1, 0, "X")]
[InlineData(double.PositiveInfinity, 1, 0, "X")]
[InlineData(double.NegativeInfinity, 1, 0, "X")]
[InlineData(1, double.NaN, 0, "Y")]
[InlineData(1, double.PositiveInfinity, 0, "Y")]
[InlineData(1, double.NegativeInfinity, 0, "Y")]
[InlineData(1, 1, double.NaN, "Rotation")]
[InlineData(1, 1, double.PositiveInfinity, "Rotation")]
[InlineData(1, 1, double.NegativeInfinity, "Rotation")]
public void NonfinitePoseIsReportedAndNeverProposed(double x, double y, double rotation, string field)
{
var item = Item("bracket", 2);
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
var engine = new StubEngine(job => OnePlate(job,
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
new NestJobPlacement(job.Parts[0].Id, 1, x, y, rotation)));
var result = NestPipeline.Run(engine, "Nonfinite", Request("Nonfinite", item));
Assert.False(result.IsValid);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains(field) && v.Contains("finite") && v.Contains("bracket"));
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
Assert.Equal(2, item.Quantity);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Fact]
public void ReportsEveryStructuralViolationWithoutMaterializingAnyGeometry()
{
var engine = new StubEngine(job => OnePlate(job,
new NestJobPlacement("ghost", 0, double.NaN, double.PositiveInfinity, double.NegativeInfinity),
new NestJobPlacement(null!, 0, 1, 1, 0),
new NestJobPlacement(job.Parts[0].Id, 0, double.NegativeInfinity, 1, 0)));
var result = NestPipeline.Run(engine, "Malformed", Request("Malformed", Item("bracket", 1)));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Equal(6, result.Violations.Count);
Assert.Contains(result.Violations, v => v.Contains("ghost") && v.Contains("not part of this job"));
Assert.Contains(result.Violations, v => v.Contains("null") && v.Contains("not part of this job"));
Assert.Equal(2, result.Violations.Count(v => v.Contains("nonfinite X")));
Assert.Contains(result.Violations, v => v.Contains("nonfinite Y"));
Assert.Contains(result.Violations, v => v.Contains("nonfinite Rotation"));
}
[Fact]
public void ExceedingMaxPlatesIsInvalidButRemainsFullyKeepable()
{
var item = Item("bracket", 2);
var request = Request("TooManySheets", item) with { Options = new NestJobOptions(maxPlates: 1) };
var engine = new StubEngine(job => new NestJobResult(
NestJobStatus.Complete,
NestJobStopReason.Completed,
Enumerable.Range(0, 2).Select(i => new NestJobPlateResult(i, job.Plates[0],
new[] { new NestJobPlacement(job.Parts[0].Id, i, 1, 1, 0) })),
new[] { new PartFulfillment(job.Parts[0].Id, 2, 2, 0) },
new[] { new StockUsage(job.Plates[0].Id, 2, null) }
));
var result = NestPipeline.Run(engine, "TooManySheets", request);
Assert.False(result.IsValid);
Assert.True(result.CanKeep);
Assert.Contains(result.Violations, v => v.Contains("MaxPlates") && v.Contains("2") && v.Contains("1"));
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, plate => Assert.Same(item.Drawing, Assert.Single(plate.Parts).BaseDrawing));
Assert.Equal(2, item.Quantity);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Fact]
public void InvalidStockIsRejectedBeforeCallingAnArbitraryEngine()
{
var called = false;
var engine = new StubEngine(job =>
{
called = true;
return OnePlate(job);
});
var request = Request("Unchecked", Item("bracket", 1)) with
{
Stock = new[] { new NestPlateStock("bad", new Size(48, 96), partSpacing: double.NaN) },
};
var error = Assert.Throws<ArgumentException>(() => NestPipeline.Run(engine, "Unchecked", request));
Assert.Contains("Invalid stock", error.Message);
Assert.False(called);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void InvalidGeometryIsRejectedBeforeCallingAnArbitraryEngine(bool nonfinite)
{
var item = Item("bracket", 1);
if (nonfinite)
((Motion)item.Drawing.Program.Codes[1]).EndPoint = new Vector(double.NaN, 0);
else
item.Drawing.Program.Codes.Clear();
var called = false;
var engine = new StubEngine(job =>
{
called = true;
return OnePlate(job);
});
var error = Assert.Throws<ArgumentException>(() =>
NestPipeline.Run(engine, "Unchecked", Request("Unchecked", item)));
Assert.Contains("Geometry must contain finite motions", error.Message);
Assert.False(called);
Assert.Equal(1, item.Quantity);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Fact]
public void UnknownEngineNameListsRegisteredEngines()
{
var error = Assert.Throws<NotSupportedException>(() =>
NestPipeline.Run(Request("Mystery Engine", Item("bracket", 1)))
);
Assert.Contains("Default", error.Message);
}
[Fact]
public void CancellationDuringSolveDiscardsEvenAnEngineThatReturnsNormally()
{
using var cts = new CancellationTokenSource();
var engine = new StubEngine(job =>
{
cts.Cancel();
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
});
Assert.ThrowsAny<OperationCanceledException>(() =>
NestPipeline.Run(engine, "IgnoresStop", Request("IgnoresStop", Item("bracket", 1)), null, cts.Token)
);
}
[Fact]
public void CancellationPropagatesWithoutAResult()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.ThrowsAny<OperationCanceledException>(() =>
NestPipeline.Run(Request("Default", Item("bracket", 1)), null, cts.Token)
);
}
}
@@ -0,0 +1,32 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class NestResultBinderTests
{
[Theory]
[InlineData("ghost", 1, 1, 0)]
[InlineData(null, 1, 1, 0)]
[InlineData("p", double.NaN, 1, 0)]
[InlineData("p", 1, double.PositiveInfinity, 0)]
[InlineData("p", 1, 1, double.NaN)]
public void MalformedSheetCannotBePartiallyBound(string? partId, double x, double y, double rotation)
{
var drawing = new Drawing("bracket", TestDrawingFactory.Rectangle());
var before = PartGeometrySnapshot.FromProgram(drawing.Program).Motions;
var sheet = new NestJobPlateResult(0, new NestPlateStock("sheet", new Size(48, 96)),
new[]
{
new NestJobPlacement("p", 0, 1, 1, 0),
new NestJobPlacement(partId!, 1, x, y, rotation),
});
var drawings = new Dictionary<string, Drawing> { ["p"] = drawing };
Assert.Throws<ArgumentException>(() => NestResultBinder.Bind(sheet, drawings));
Assert.Equal(before, PartGeometrySnapshot.FromProgram(drawing.Program).Motions);
Assert.Equal(0, drawing.Quantity.Nested);
}
}
@@ -0,0 +1,68 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class NestStockBuilderTests
{
private static Plate Template() => new(new Size(48, 96))
{
Quantity = 7,
PartSpacing = 0.25,
EdgeSpacing = new Spacing(1, 2, 3, 4),
Quadrant = 3,
};
[Fact]
public void TemplateWithoutOptionsUsesUnlimitedStockAndSnapshotsSettings()
{
var template = Template();
var stock = Assert.Single(NestStockBuilder.FromTemplate(template, null));
Assert.Equal("plate", stock.Id);
Assert.Null(stock.Quantity);
Assert.Equal(template.Size, stock.Size);
Assert.Equal(template.PartSpacing, stock.PartSpacing);
Assert.Equal(template.EdgeSpacing, stock.EdgeSpacing);
Assert.Equal(template.Quadrant, stock.Quadrant);
template.Size = new Size(60, 120);
template.EdgeSpacing = default;
Assert.Equal(new Size(48, 96), stock.Size);
Assert.Equal(new Spacing(1, 2, 3, 4), stock.EdgeSpacing);
}
[Fact]
public void OptionsHaveStableDistinctIdsAndCopyTemplateSettings()
{
var template = Template();
var options = new[]
{
new PlateOption { Width = 60, Length = 120 },
new PlateOption { Width = 72, Length = 144 },
};
var stock = NestStockBuilder.FromTemplate(template, options, quantityPerOption: 2);
Assert.Equal(new[] { "option-0", "option-1" }, stock.Select(s => s.Id));
Assert.Equal(new[] { new Size(60, 120), new Size(72, 144) }, stock.Select(s => s.Size));
Assert.All(stock, s =>
{
Assert.Equal(2, s.Quantity);
Assert.Equal(template.EdgeSpacing, s.EdgeSpacing);
Assert.Equal(template.PartSpacing, s.PartSpacing);
Assert.Equal(template.Quadrant, s.Quadrant);
});
Assert.Equal(new Size(48, 96), template.Size);
}
[Fact]
public void SinglePlateOffersExactlyOneSheetNotPlateRepeatQuantity()
{
var stock = Assert.Single(NestStockBuilder.SinglePlate(Template()));
Assert.Equal(1, stock.Quantity);
}
[Fact]
public void MissingTemplateIsRejected()
{
Assert.Throws<ArgumentNullException>(() => NestStockBuilder.FromTemplate(null, null));
Assert.Throws<ArgumentNullException>(() => NestStockBuilder.SinglePlate(null));
}
}
@@ -1,5 +1,5 @@
using Xunit;
using OpenNest.Engine.Jobs;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
@@ -10,12 +10,45 @@ public class NestingEngineRegistryTests
{
var names = NestingEngineRegistry.AvailableEngines.Select(e => e.Name).ToList();
Assert.Contains("Rectangles", names);
Assert.Contains("Irregular", names);
Assert.Contains("Default", names);
Assert.Contains("Strip", names);
Assert.Contains("Vertical Remnant", names);
Assert.Contains("Horizontal Remnant", names);
}
[Fact]
public void RenamedPlugInNamesResolveToTheirBuiltInReplacements()
{
Assert.Equal("Irregular", NestingEngineRegistry.ResolveName("Opus55NestingEngine"));
Assert.Equal("Rectangles", NestingEngineRegistry.ResolveName("rectanglesnestingengine"));
Assert.Equal("Rectangles", NestingEngineRegistry.ResolveName(" rectangles "));
Assert.IsType<OpenNest.Engine.NestingEngines.Irregular.IrregularNestingEngine>(
NestingEngineRegistry.Create("Opus55NestingEngine"));
}
[Fact]
public void RetiredEnginesAreNotSilentlyAliased()
{
Assert.Null(NestingEngineRegistry.ResolveName("Gpt6AstraNestingEngine"));
Assert.Null(NestingEngineRegistry.ResolveName("Qwen38FlashNextNestingEngine"));
Assert.Null(NestingEngineRegistry.ResolveName(" "));
Assert.Throws<NotSupportedException>(() => NestingEngineRegistry.Create("Qwen38FlashNextNestingEngine"));
}
[Fact]
public void LeftoverPlugInUnderARenamedNameCannotShadowItsReplacement()
{
var before = NestingEngineRegistry.AvailableEngines.Count;
NestingEngineRegistry.Register("Opus55NestingEngine", "stale plug-in",
() => new FixedStrategyNestingEngine("Default"));
Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count);
Assert.Equal("Irregular", NestingEngineRegistry.ResolveName("Opus55NestingEngine"));
}
[Fact]
public void EachBuiltInFactoryProducesAWorkingEngine()
{
@@ -0,0 +1,31 @@
using OpenNest.Engine.NestingEngines.Rectangles;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>
/// Curved extremes (discs, rings, obrounds) and sloped ones (triangles) must clear the layout check
/// at every spacing: the check circumscribes arcs, so box-touching copies are only valid when the
/// catalog reads their boxes the way the check does. Failed at 16-50 of these 80 jobs before that.
/// </summary>
public class CurvedExtremeSweepTests
{
[Fact]
public void CurvedAndSlopedPartsPassTheLayoutCheckAtEverySpacing()
{
var bad = new List<string>(); var n = 0;
foreach (var r in new[] { 0.37, 0.5, 0.731, 1.0, 1.23, 2.0, 3.3, 5.0, 7.77, 12.0 })
foreach (var sp in new[] { 0.0, 0.1, 0.25, 0.3125 })
foreach (var rot in new[] { false, true })
foreach (var ring in new[] { false, true })
{
var pol = rot ? RotationPolicy.Automatic : RotationPolicy.Fixed(0);
var job = Job(new[] { Part("d", ring ? Ring(2 * r, r) : Disc(r), 12, pol), Part("o", Obround(4 * r, 1.3 * r), 6, pol), Part("t", Triangle(3 * r, 2 * r), 4, pol) },
new[] { Stock("s", 12 * r + 5, 14 * r + 5, spacing: sp) });
var res = new RectanglesNestingEngine().Solve(job); n++;
var v = NestLayoutCheck.Violations(job, res);
if (v.Count > 0) bad.Add($"r={r} sp={sp} rot={rot} ring={ring}: {v[0]}");
}
Assert.True(bad.Count == 0, $"{bad.Count}/{n}\n" + string.Join("\n", bad));
}
}
@@ -0,0 +1,135 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using Xunit;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>Host contract only; engines retain their own packing-quality regressions.</summary>
public abstract class EngineContractTests<TEngine> where TEngine : INestingEngine, new()
{
[Fact]
public void ContractPublicConstructor() => Assert.IsAssignableFrom<INestingEngine>(Activator.CreateInstance(typeof(TEngine)));
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void ContractQuadrants(int quadrant)
{
var job = Job([Part("disc", Disc(2), 3), Part("ell", LShape(6, 5, 2), 3)],
[Stock("s", 20, 30, 0.2, new Spacing(0.2, 0.3, 0.4, 0.5), quadrant)]);
var result = new TEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void ContractOverflowIndicesAndProgress()
{
var job = Job([Part("p", Rectangle(8, 8), 3)], [Stock("s", 10, 10)]);
var commits = new List<NestJobProgress>();
var result = new TEngine().Solve(job, new Capture(p => { if (p.Stage == NestJobStage.PlateCommitted) commits.Add(p); }));
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(3, result.Plates.Count);
Assert.Equal(Enumerable.Range(0, 3), result.Plates.Select(p => p.PlateIndex));
Assert.Equal(3, commits.Count);
Assert.Equal(Enumerable.Range(0, 3), commits.Select(p => p.PlateIndex));
Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedPlates));
Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedParts));
Assert.All(result.StockUsage, s => Assert.Null(s.Remaining));
}
[Fact]
public void ContractOversize()
{
var job = Job([Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(2, 2), 2)], [Stock("s", 10, 10)]);
var result = new TEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
}
[Fact]
public void ContractLowerNumberPriorityWins()
{
var job = Job([Part("low", Rectangle(8, 8), 1, priority: 9), Part("high", Rectangle(8, 8), 1, priority: 0)],
[Stock("s", 10, 10, quantity: 1)]);
var result = new TEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId);
}
[Fact]
public void ContractEtchOutsideSheetIsIgnored()
{
var etched = NotchedPartWithEtch();
etched.Codes.Add(new RapidMove(5, 5));
etched.Codes.Add(new LinearMove(100, 100) { Layer = LayerType.Scribe });
var job = Job([Part("p", etched, 1, RotationPolicy.Fixed(0))], [Stock("s", 10.4, 10.4, quantity: 1)]);
var result = new TEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void ContractDeterminism()
{
NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12)],
[Stock("a", 30, 45, 0.3), Stock("b", 40, 40, 0.3)]);
var engine = new TEngine();
var job = Build();
var first = engine.Solve(job);
var second = engine.Solve(job);
var third = new TEngine().Solve(Build());
foreach (var result in new[] { first, second, third }) LayoutAssert.Valid(job, result);
Assert.Equal(Describe(first), Describe(second));
Assert.Equal(Describe(first), Describe(third));
}
[Fact]
public void ContractCancellationThrows()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var job = Job([Part("p", Rectangle(2, 2), 5)], [Stock("s", 10, 10)]);
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, token: cancellation.Token));
}
[Fact]
public void ContractCancellationDuringSolveThrows()
{
using var cancellation = new CancellationTokenSource();
var job = Job([Part("p", Rectangle(2, 2), 20)], [Stock("s", 10, 10)]);
var progress = new Capture(p =>
{
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
});
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, progress, cancellation.Token));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ContractStockAndPlateLimits(bool plateLimit)
{
var job = Job([Part("p", Rectangle(8, 8), 3)],
[Stock("s", 10, 10, quantity: plateLimit ? null : 1)],
new NestJobOptions(maxPlates: plateLimit ? 1 : null));
var result = new TEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Single(result.Plates);
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(plateLimit ? NestJobStopReason.PlateLimitReached : NestJobStopReason.StockExhausted, result.StopReason);
}
private static string Describe(NestJobResult result) => System.Text.Json.JsonSerializer.Serialize(result);
private sealed class Capture(Action<NestJobProgress> action) : IProgress<NestJobProgress>
{ public void Report(NestJobProgress value) => action(value); }
}
@@ -0,0 +1,137 @@
using Clipper2Lib;
using OpenNest.Engine.NestingEngines.Irregular;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularContainmentTests
{
[Fact]
public void ConcaveContainmentDoesNotCreateSpuriousFreeRegions()
{
// Synthetic radial profiles, both star-shaped about the origin. Their filled
// Minkowski sum is star-shaped too, so it cannot contain an enclosed free region.
// Rounding the boundary sweep before adding containment used to leave a thin hole.
var large = Orientation(0,
(11.1566, 0), (2.9107, 5.0415), (-5.8475, 10.1282),
(-5.5736, 0), (-5.3618, -9.287), (2.1573, -3.7365));
var small = Orientation(1,
(3.53487, 0), (0.95703, 0.95703), (0, 3.50634),
(-0.86934, 0.86934), (-3.05031, 0), (-1.06221, -1.06221),
(0, -3.56937), (0.97029, -0.97029));
var cache = new NoFitCache(0.25);
var nfp = cache.Get(large, small);
Assert.Single(nfp.Region);
Assert.True(Clipper.IsPositive(nfp.Region[0]));
Assert.Equal(PointInPolygonResult.IsInside,
Clipper.PointInPolygon(new PointD(0, 0), nfp.Region[0], NoFitCache.Precision));
}
[Fact]
public void NarrowEntranceStillPreservesAUsableConcavePocket()
{
// The opening is narrower than the moving square, but the chamber is larger.
// A legal static placement inside it is an enclosed hole in configuration space,
// not a material cutout. Removing every negative NFP ring would lose this fit.
var chamber = Orientation(0,
(0, 0), (10, 0), (10, 4), (8, 4), (8, 2), (2, 2),
(2, 8), (8, 8), (8, 6), (10, 6), (10, 10), (0, 10));
var square = Orientation(1, (0, 0), (2.5, 0), (2.5, 2.5), (0, 2.5));
var cache = new NoFitCache(0.25);
var nfp = cache.Get(chamber, square);
Assert.Contains(nfp.Region, path => !Clipper.IsPositive(path));
Assert.False(Forbidden(nfp, new PointD(4, 4)));
Assert.True(Forbidden(nfp, new PointD(1, 4)));
Assert.False(Forbidden(nfp, new PointD(11, 4)));
}
[Theory]
[InlineData(1.0)]
[InlineData(100000000.0)]
public void RemovesOnlyRingsWhollyInsideOneForbiddenCell(double scale)
{
var a = new PathD { new(0, 0), new(10 * scale, 0), new(0, 10 * scale) };
var b = new PathD { new(0, 0), new(scale, 0), new(0, scale) };
var outer = new PathD { new(-scale, -scale), new(12 * scale, -scale), new(12 * scale, 12 * scale), new(-scale, 12 * scale) };
var covered = new PathD { new(scale, scale), new(scale, 2 * scale), new(2 * scale, scale) };
// One vertex and the centroid are forbidden, but this ring extends beyond A+B.
var partlyCovered = new PathD { new(2 * scale, 2 * scale), new(2 * scale, 8 * scale), new(12 * scale, 2 * scale) };
var region = new PathsD { outer, covered, partlyCovered };
NfpHoleFilter.RemoveCoveredHoles(region, a, b);
Assert.Equal(2, region.Count);
Assert.Same(outer, region[0]);
Assert.Same(partlyCovered, region[1]);
}
[Fact]
public void SeparateForbiddenCellsDoNotCertifyTheSpaceBetweenThem()
{
var a = new PathD { new(0, 0), new(10, 0), new(10, 2), new(2, 2), new(2, 10), new(0, 10) };
var b = new PathD { new(0, 0), new(0.1, 0), new(0, 0.1) };
// Both ends overlap an arm, but the middle crosses usable space in the notch.
var ring = new PathD { new(1, 8), new(8, 1), new(7.9, 1) };
if (Clipper.IsPositive(ring)) ring.Reverse();
var region = new PathsD { ring };
NfpHoleFilter.RemoveCoveredHoles(region, a, b);
Assert.Same(ring, Assert.Single(region));
}
[Fact]
public void RoundedEarDecisionCannotCertifySpaceOutsideTheSource()
{
// ABP is a positive turn on the 1e-4 lattice, but its double cross product
// is negative. Floating-point ear clipping can emit C,A,B across the notch.
var a = new PathD
{
new(-269144028945 / 10000.0, 180906060632 / 10000.0),
new(463355031050 / 10000.0, 832229610235 / 10000.0),
new(-702076855 / 10000.0, 419599288578 / 10000.0),
new(-269144028945 / 10000.0, 1483553159838 / 10000.0),
};
var b = new PathD { new(0, 0), new(0.0001, 0), new(0, 0.0001) };
var x = (a[1].x + a[2].x + a[3].x) / 3;
var y = (a[1].y + a[2].y + a[3].y) / 3;
var ring = new PathD { new(x - 10, y - 10), new(x - 10, y + 10), new(x + 10, y - 10) };
var region = new PathsD { ring };
Assert.Equal(PointInPolygonResult.IsOutside,
Clipper.PointInPolygon(new PointD(x, y), a, NoFitCache.Precision));
NfpHoleFilter.RemoveCoveredHoles(region, a, b);
Assert.Same(ring, Assert.Single(region));
}
private static bool Forbidden(Nfp nfp, PointD point)
{
var winding = 0;
foreach (var path in nfp.Region)
if (Clipper.PointInPolygon(point, path, NoFitCache.Precision) == PointInPolygonResult.IsInside)
winding += Clipper.IsPositive(path) ? 1 : -1;
return winding != 0;
}
private static Orientation Orientation(int type, params (double X, double Y)[] points)
{
var outline = new PathD(points.Select(p => new PointD(p.X, p.Y)));
var bounds = Clipper.GetBounds(outline);
return new Orientation
{
TypeIndex = type,
Index = 0,
Rotation = 0,
Outline = outline,
Tolerance = 0.002,
MinX = bounds.left - 0.002,
MinY = bounds.top - 0.002,
MaxX = bounds.right + 0.002,
MaxY = bounds.bottom + 0.002,
};
}
}
@@ -0,0 +1,223 @@
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularNestingEngineTests
{
[Fact]
public void RectanglesFitOnOneSheetWithSpacing()
{
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Assert.Equal(12, result.Plates[0].Placements.Count);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
{
var job = Job(
new[]
{
Part("disc", Disc(3), 10),
Part("ell", LShape(12, 8, 4), 10),
Part("tri", Triangle(9, 6), 10),
Part("slot", Obround(10, 3), 6),
},
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void ZeroSpacingStillKeepsPartsApartForValidation()
{
var job = Job(new[] { Part("disc", Disc(2), 30), Part("rect", Rectangle(7, 3), 20) }, new[] { Stock("sheet", 30, 40) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void LargeAndSmallConcavePartsShareASheet()
{
// End-to-end companion to NoFitCacheTests' containment cases (the precise regression guard).
var job = Job(
new[] { Part("small", LShape(3, 3, 1), 6), Part("big", Rectangle(20, 20), 2) },
new[] { Stock("sheet", 25, 45, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void PicksTheCheaperSheetWhenItHoldsEverything()
{
var job = Job(
new[] { Part("square", Rectangle(10, 10), 4) },
new[] { Stock("big", 60, 120, spacing: 0.25), Stock("small", 25, 25, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("small", Assert.Single(result.Plates).StockId);
}
[Fact]
public void SpillsOntoAdditionalSheets()
{
var job = Job(new[] { Part("rect", Rectangle(20, 10), 25) }, new[] { Stock("sheet", 30, 50, spacing: 0.5) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Count > 1);
Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count));
var indices = result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex).OrderBy(i => i);
Assert.Equal(Enumerable.Range(0, 25), indices);
}
[Fact]
public void RespectsFixedAndBoundedRotationPolicies()
{
var fixedPolicy = RotationPolicy.Fixed(0);
var sweep = RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4);
var job = Job(
new[]
{
Part("fixed", LShape(10, 6, 3), 8, fixedPolicy),
Part("swept", Triangle(8, 5), 8, sweep),
},
new[] { Stock("sheet", 40, 60, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
{
var policy = placement.PartId == "fixed" ? fixedPolicy : sweep;
Assert.True(policy.Allows(placement.Rotation), $"{placement.PartId} at {placement.Rotation}");
}
}
[Fact]
public void OversizedPartIsReportedUnplacedWithoutBlockingOthers()
{
var job = Job(
new[] { Part("huge", Rectangle(100, 100), 1), Part("small", Rectangle(5, 5), 3) },
new[] { Stock("sheet", 20, 20) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
Assert.Equal(3, result.Fulfillment.Single(f => f.PartId == "small").Placed);
}
[Fact]
public void StopsWhenFiniteStockRunsOut()
{
var job = Job(new[] { Part("rect", Rectangle(9, 9), 20) }, new[] { Stock("sheet", 20, 20, quantity: 2) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
Assert.Equal(2, result.Plates.Count);
var usage = Assert.Single(result.StockUsage);
Assert.Equal(2, usage.Used);
Assert.Equal(0, usage.Remaining);
}
[Fact]
public void HonorsMaxPlates()
{
var job = Job(
new[] { Part("rect", Rectangle(9, 9), 20) },
new[] { Stock("sheet", 20, 20) },
new NestJobOptions(maxPlates: 1)
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Single(result.Plates);
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
}
[Fact]
public void IsDeterministic()
{
NestJob Build() =>
Job(
new[] { Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12) },
new[] { Stock("a", 30, 45, spacing: 0.3), Stock("b", 40, 40, spacing: 0.3) }
);
var first = new IrregularNestingEngine().Solve(Build());
var second = new IrregularNestingEngine().Solve(Build());
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
}
[Fact]
public void EtchMarksAreLeftOutOfNestingGeometry()
{
// A bend tick starts on material and ends 1.0 into a side notch, outside the part but
// inside its bounding box (the PEP case that crashed nesting before 1b5e1b1). As
// material it is open geometry leaving the part; as a mark it must be ignored.
var etched = Polyline((0, 0), (10, 0), (10, 4), (8, 4), (8, 6), (10, 6), (10, 10), (0, 10));
etched.Codes.Add(new RapidMove(7.5, 5));
etched.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
var job = Job(new[] { Part("part", etched, 2, RotationPolicy.Fixed(0)) }, new[] { Stock("sheet", 10.4, 20.6, spacing: 0.2) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
var engine = Activator.CreateInstance(typeof(IrregularNestingEngine));
Assert.IsAssignableFrom<INestingEngine>(engine);
}
}
public sealed class IrregularContractTests : EngineContractTests<IrregularNestingEngine> { }
@@ -0,0 +1,69 @@
using OpenNest.Engine.NestingEngines.Irregular;
using System.Linq;
using Clipper2Lib;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularNoFitCacheTests
{
[Theory]
[InlineData(0.0, 0.0)] // B's corner at A's corner: B covers A completely.
[InlineData(-5.0, -5.0)] // A deep inside B.
[InlineData(2.0, 0.5)] // Partial overlap.
public void ForbidsEveryOverlappingOffsetIncludingContainment(double dx, double dy)
{
var (small, big) = Orientations();
var nfp = new NoFitCache(0.1).Get(small, big);
Assert.True(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be forbidden");
}
[Theory]
[InlineData(4.0, 0.0)] // Beside A, clear by more than the clearance.
[InlineData(0.0, -21.0)] // Below A.
[InlineData(-21.0, 0.0)] // Left of A.
public void AllowsClearOffsets(double dx, double dy)
{
var (small, big) = Orientations();
var nfp = new NoFitCache(0.1).Get(small, big);
Assert.False(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be free");
}
/// <summary>A = 3x3 L (concave), B = 20x20 square; both at rotation 0 with origin at the lower-left.</summary>
private static (Orientation Small, Orientation Big) Orientations()
{
var job = new NestJob(
new[]
{
new NestJobPart("small", Snapshot((0, 0), (3, 0), (3, 1), (1, 1), (1, 3), (0, 3)), 1, 0, RotationPolicy.Fixed(0)),
new NestJobPart("big", Snapshot((0, 0), (20, 0), (20, 20), (0, 20)), 1, 0, RotationPolicy.Fixed(0)),
},
new[] { new NestPlateStock("s", new Size(100, 100)) }
);
var types = PartCatalog.Build(job);
return (types[0].Orientations.Single(), types[1].Orientations.Single());
}
private static bool Forbidden(Nfp nfp, PointD point)
{
var winding = 0;
foreach (var path in nfp.Region)
if (Clipper.PointInPolygon(point, path) == PointInPolygonResult.IsInside)
winding += Clipper.IsPositive(path) ? 1 : -1;
return winding != 0;
}
private static PartGeometrySnapshot Snapshot(params (double X, double Y)[] points)
{
var program = new Program();
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
foreach (var (x, y) in points.Skip(1))
program.Codes.Add(new LinearMove(x, y));
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
return PartGeometrySnapshot.FromProgram(program);
}
}
@@ -0,0 +1,26 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.NestingEngines;
public static class JobBuilder
{
public static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
new(parts, stock, options);
public static NestJobPart Part(string id, Program program, int quantity,
RotationPolicy? rotation = null, int priority = 0) =>
new(id, PartGeometrySnapshot.FromProgram(program), quantity, priority, rotation);
/// <param name="width">Y extent.</param>
/// <param name="length">X extent.</param>
public static NestPlateStock Stock(string id, double width, double length, double spacing = 0,
Spacing edge = default, int quadrant = 1, int? quantity = null) =>
new(id, new Size(width, length), quantity, spacing, edge, quadrant);
public static NestJobPart Rectangle(string id, double w, double h, int count,
RotationPolicy? rotation = null, double x = 0, double y = 0) =>
Part(id, Shapes.Polyline((x, y), (x + w, y), (x + w, y + h), (x, y + h)),
count, rotation ?? RotationPolicy.Fixed(0));
}
@@ -0,0 +1,51 @@
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using Xunit;
namespace OpenNest.Engine.Tests.NestingEngines;
public static class LayoutAssert
{
public static void Valid(NestJob job, NestJobResult result)
{
var violations = NestLayoutCheck.Violations(job, result);
Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations));
Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex));
foreach (var f in result.Fulfillment)
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
foreach (var sheet in result.Plates)
{
var s = sheet.Stock;
var work = s.WorkArea;
foreach (var pose in sheet.Placements)
{
var part = job.Parts.Single(p => p.Id == pose.PartId);
Assert.True(part.Rotation.Allows(pose.Rotation));
var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToArray();
foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); }
var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom),
R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top));
var slack = NestTolerances.WorkAreaSlack;
Assert.True(b.L >= work.Left - slack && b.B >= work.Bottom - slack
&& b.R <= work.Right + slack && b.T <= work.Top + slack);
}
}
foreach (var part in job.Parts)
{
var placed = result.Plates.SelectMany(s => s.Placements).Where(p => p.PartId == part.Id).ToArray();
Assert.Equal(Enumerable.Range(0, placed.Length), placed.Select(p => p.InstanceIndex).Order());
var fulfillment = result.Fulfillment.Single(f => f.PartId == part.Id);
Assert.Equal(placed.Length, fulfillment.Placed);
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
}
foreach (var usage in result.StockUsage)
{
var stock = job.Plates.Single(s => s.Id == usage.StockId);
Assert.Equal(result.Plates.Count(s => s.StockId == stock.Id), usage.Used);
Assert.Equal(stock.Quantity - usage.Used, usage.Remaining);
Assert.True(usage.Remaining is null or >= 0);
}
}
}
@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>
/// Starter acceptance tests. Every layout is checked by the shared NestLayoutCheck the benchmark
/// scores with, so a passing test means the benchmark will accept the layout. They fail until
/// Solve() is implemented; add engine-specific tests alongside them.
/// </summary>
public class RectanglesNestingEngineTests
{
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
var engine = Activator.CreateInstance(typeof(RectanglesNestingEngine));
Assert.IsAssignableFrom<INestingEngine>(engine);
}
[Fact]
public void RectanglesFitOnOneSheetWithSpacing()
{
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Assert.Equal(12, result.Plates[0].Placements.Count);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
{
var job = Job(
new[]
{
Part("disc", Disc(3), 10),
Part("ell", LShape(12, 8, 4), 10),
Part("tri", Triangle(9, 6), 10),
},
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
);
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void OverflowSpillsOntoAdditionalSheets()
{
var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Count > 1);
}
[Fact]
public void PartTooBigForAnySheetIsReportedUnplaced()
{
var job = Job(
new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) }
);
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
Assert.Equal(1, huge.Unplaced);
}
[Fact]
public void ExactFitGridPacksAtExactlyThePartSpacing()
{
// 4 x 3 boxes of 10 x 5 at 0.5 spacing need exactly 41.5 x 16.
var job = Job(new[] { Part("r", Rectangle(10, 5), 12) },
new[] { Stock("s", 16, 41.5, spacing: 0.5, quantity: 1) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(12, Assert.Single(result.Plates).Placements.Count);
}
[Fact]
public void ArcExtremePartsStayClearAtTheSpacing()
{
// Discs and obrounds have arcs, not vertices, at their box edges: the validator's
// circumscribed flattening would read box-touching copies as closer than the spacing.
var job = Job(new[] { Part("disc", Disc(2), 20), Part("ob", Obround(8, 3), 12) },
new[] { Stock("s", 30, 40, spacing: 0.25) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void MixedSizesFillOneSheetThatShelfPackingWouldSplit()
{
// Area check: 2*(24x20) + 4*(12x10) + 8*(6x5) = 960 + 480 + 240 = 1680 of 48 x 40 = 1920.
// A maximal-rectangles packing fits all of it on one sheet with zero spacing.
var job = Job(new[]
{
Rectangle("big", 24, 20, 2, RotationPolicy.Automatic),
Rectangle("mid", 12, 10, 4, RotationPolicy.Automatic),
Rectangle("small", 6, 5, 8, RotationPolicy.Automatic),
},
new[] { Stock("s", 40, 48) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
}
[Fact]
public void RotatedInputIsNestedAtItsMinimumBoundingRectangle()
{
// A 10 x 4 rectangle drawn at 30 degrees: only its squared-up box fits 4 per 20.5 x 8.5 sheet.
var c = System.Math.Cos(System.Math.PI / 6);
var s = System.Math.Sin(System.Math.PI / 6);
(double, double) R(double x, double y) => (x * c - y * s + 5, x * s + y * c + 5);
var tilted = Polyline(R(0, 0), R(10, 0), R(10, 4), R(0, 4));
var job = Job(new[] { Part("tilted", tilted, 4, RotationPolicy.Automatic) },
new[] { Stock("s", 8.5, 20.5, spacing: 0.5, quantity: 1) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void PartAFewMillionthsWiderThanTheWorkAreaIsPlaced()
{
// Exported panels are often drawn a few millionths over their sheet's work area; the
// layout check accepts that overhang, so the engine must place them. The chamfer keeps the
// material area inside the sheet's area budget, as a real panel's corner radii do.
var panel = Polyline((0, 0), (36.125006, 0), (36.125006, 74), (35.125006, 75), (0, 75));
var job = Job(new[] { Part("panel", panel, 1) },
new[] { Stock("s", 75, 36.125, spacing: 0.25, quantity: 1) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void PartBeyondTheOverhangAllowanceStaysUnplaced()
{
var panel = Polyline((0, 0), (36.12502, 0), (36.12502, 74), (35.12502, 75), (0, 75));
var job = Job(new[] { Part("panel", panel, 1) },
new[] { Stock("s", 75, 36.125, spacing: 0.25, quantity: 1) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Empty(result.Plates);
Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced);
}
}
public sealed class RectanglesContractTests : EngineContractTests<RectanglesNestingEngine> { }
@@ -0,0 +1,56 @@
using OpenNest.CNC;
namespace OpenNest.Engine.Tests.NestingEngines;
public static class Shapes
{
public static Program Polyline(params (double X, double Y)[] points)
{
var program = new Program();
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
foreach (var (x, y) in points.Skip(1))
program.Codes.Add(new LinearMove(x, y));
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
return program;
}
public static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
public static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
public static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
public static Program Disc(double r)
{
var program = new Program();
program.Codes.Add(new RapidMove(r, 0));
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
return program;
}
/// <summary>Stadium: two semicircular ends joined by straight sides, offset from the origin.</summary>
public static Program Obround(double length, double width)
{
var r = width / 2;
var program = new Program();
program.Codes.Add(new RapidMove(1 + r, 1));
program.Codes.Add(new LinearMove(1 + length - r, 1));
program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW));
program.Codes.Add(new LinearMove(1 + r, 1 + width));
program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW));
return program;
}
public static Program NotchedPartWithEtch()
{
var p = new Program();
p.MoveTo(0, 0); p.LineTo(10, 0); p.LineTo(10, 4); p.LineTo(8, 4); p.LineTo(8, 6);
p.LineTo(10, 6); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(0, 0);
p.MoveTo(7.5, 5);
p.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
return p;
}
public static Program Ring(double outerDiameter, double innerDiameter) =>
new OpenNest.Shapes.RingShape { OuterDiameter = outerDiameter, InnerDiameter = innerDiameter }.GetDrawing().Program;
}
@@ -0,0 +1,273 @@
using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.RectanglePacking;
using OpenNest.Engine.Tests.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.RectanglePacking;
public class AreaPackerTests
{
private const double Eps = 1e-9;
private static NestItem Item(string name, double width, double length, int quantity, int priority = 0) =>
new()
{
Drawing = new Drawing(name, TestDrawingFactory.Rectangle(width, length)),
Quantity = quantity,
Priority = priority,
};
private static void AssertInsideAndSpaced(IReadOnlyList<Part> parts, Box area, double spacing)
{
foreach (var part in parts)
{
var b = part.BoundingBox;
Assert.True(
b.Left >= area.Left - Eps && b.Bottom >= area.Bottom - Eps
&& b.Right <= area.Right + Eps && b.Top <= area.Top + Eps,
$"{part.BaseDrawing.Name} at ({b.Left},{b.Bottom})-({b.Right},{b.Top}) is outside the area"
);
}
for (var i = 0; i < parts.Count; i++)
for (var j = i + 1; j < parts.Count; j++)
{
var a = parts[i].BoundingBox;
var b = parts[j].BoundingBox;
var gapX = System.Math.Max(b.Left - a.Right, a.Left - b.Right);
var gapY = System.Math.Max(b.Bottom - a.Top, a.Bottom - b.Top);
Assert.True(
System.Math.Max(gapX, gapY) >= spacing - Eps,
$"parts {i} and {j} are {System.Math.Max(gapX, gapY)} apart, spacing is {spacing}"
);
}
}
[Theory]
[InlineData(0, 0)]
[InlineData(1, 0)]
[InlineData(0, 3)]
[InlineData(1, 3)]
public void PackArea_AllowsBoundaryOverhangAndValidates(double spacing, double edge)
{
var plate = new Plate(new Size(10 + 2 * edge, 10 + 2 * edge))
{
PartSpacing = spacing,
EdgeSpacing = new Spacing(edge, edge),
};
var area = plate.WorkArea();
var items = new List<NestItem> { Item("panel", 10 + SheetPacker.OverhangAllowance * 0.8, 4, 2) };
var parts = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
var again = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
Assert.Equal(2, parts.Count);
Assert.Equal(parts.Select(p => (p.Location, p.Rotation)), again.Select(p => (p.Location, p.Rotation)));
Assert.All(parts, p =>
{
Assert.Equal(area.Left, p.BoundingBox.Left, 9);
Assert.True(p.BoundingBox.Right > area.Right);
Assert.True(p.BoundingBox.Right <= area.Right + NestTolerances.WorkAreaSlack);
});
Assert.True(parts[1].BoundingBox.Bottom - parts[0].BoundingBox.Top >= spacing - Eps);
var requirements = items.ToDictionary(i => i.Drawing, i => (i.Drawing.Name, i.Quantity));
Assert.Empty(NestLayoutCheck.Validate(new() { (plate, parts) }, requirements));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void PackArea_TopOverhangKeepsSpacingInEveryQuadrant(int quadrant)
{
var plate = new Plate(new Size(12, 12))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(1, 1),
PartSpacing = 1,
};
var area = plate.WorkArea();
var items = new List<NestItem> { Item("panel", 4, 10 + SheetPacker.OverhangAllowance * 0.8, 2) };
var parts = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
Assert.Equal(2, parts.Count);
Assert.All(parts, p =>
{
Assert.Equal(area.Bottom, p.BoundingBox.Bottom, 9);
Assert.True(p.BoundingBox.Top > area.Top);
Assert.True(p.BoundingBox.Top <= area.Top + NestTolerances.WorkAreaSlack);
});
var ordered = parts.OrderBy(p => p.BoundingBox.Left).ToList();
Assert.True(ordered[1].BoundingBox.Left - ordered[0].BoundingBox.Right >= 1 - Eps);
Assert.Empty(NestLayoutCheck.Validate(new() { (plate, parts) },
items.ToDictionary(i => i.Drawing, i => (i.Drawing.Name, i.Quantity))));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void PackArea_FullAreaDemandCapIncludesSlack(bool rotated)
{
var plate = new Plate(new Size(6, 10)) { PartSpacing = 0 };
var excess = SheetPacker.OverhangAllowance * 0.8;
var items = new List<NestItem> { rotated ? Item("panel", 6 + excess, 10 + excess, 1)
: Item("panel", 10 + excess, 6 + excess, 1) };
var part = Assert.Single(PlateFillService.PackArea("Default", plate, plate.WorkArea(), items, null, CancellationToken.None));
Assert.True(part.BoundingBox.Right <= 10 + NestTolerances.WorkAreaSlack);
Assert.True(part.BoundingBox.Top <= 6 + NestTolerances.WorkAreaSlack);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
public void PackArea_RejectsBeyondBoundaryAllowance(double spacing)
{
var plate = new Plate(new Size(6, 10)) { PartSpacing = spacing };
var items = new List<NestItem> { Item("panel", 10 + NestTolerances.WorkAreaSlack * 1.1, 4, 1) };
Assert.Empty(PlateFillService.PackArea("Default", plate, plate.WorkArea(), items, null, CancellationToken.None));
}
[Theory]
[InlineData(0, 0, 5, 6, false)]
[InlineData(5, 0, 5, 6, true)]
[InlineData(0, 0, 10, 3, false)]
[InlineData(0, 3, 10, 3, true)]
public void PackArea_OnlyMatchingPositivePlateEdgesAllowSlack(double x, double y, double w, double h, bool fits)
{
var plate = new Plate(new Size(6, 10)) { PartSpacing = 0.5 };
var area = new Box(x, y, w, h);
var excess = SheetPacker.OverhangAllowance * 0.8;
var items = new List<NestItem> { w == 5 ? Item("panel", w + excess, h - 0.25, 1) : Item("panel", 8, h + excess, 1) };
var parts = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
Assert.Equal(fits ? 1 : 0, parts.Count);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void MaxRects_InternalFreeEdgesStayStrict(bool horizontal)
{
var sheet = new MaxRectsSheet(10, 10, SheetPacker.OverhangAllowance, SheetPacker.OverhangAllowance);
sheet.Place(horizontal ? new Rect(5, 0, 5, 10) : new Rect(0, 5, 10, 5));
var excess = SheetPacker.OverhangAllowance * 0.8;
Assert.Null(sheet.FindBest(horizontal ? 5 + excess : 10, horizontal ? 10 : 5 + excess, FitRule.BottomLeft));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void MaxRects_BoundaryFreeEdgesAllowSlackAfterPlacement(bool horizontal)
{
var sheet = new MaxRectsSheet(10, 10, SheetPacker.OverhangAllowance, SheetPacker.OverhangAllowance);
sheet.Place(horizontal ? new Rect(0, 0, 5, 10) : new Rect(0, 0, 10, 5));
var excess = SheetPacker.OverhangAllowance * 0.8;
var fit = sheet.FindBest(horizontal ? 5 + excess : 10, horizontal ? 10 : 5 + excess, FitRule.BottomLeft);
Assert.NotNull(fit);
Assert.Equal(horizontal ? new Rect(5, 0, 5, 10) : new Rect(0, 5, 10, 5), fit.Value.Place);
}
[Fact]
public void DefaultPackArea_PlacesDemandThatCornerPointPackingMissed()
{
// Three 5x7 and two 4x3 boxes fit a 12x12 area exactly when packed by free space;
// packing only at placed parts' corners left one 4x3 out.
var plate = new Plate(new Size(12, 12)) { PartSpacing = 0 };
var area = new Box(0, 0, 12, 12);
var items = new List<NestItem> { Item("tall", 5, 7, 3), Item("small", 4, 3, 2) };
var parts = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
Assert.Equal(5, parts.Count);
Assert.Equal(3, parts.Count(p => p.BaseDrawing.Name == "tall"));
Assert.Equal(2, parts.Count(p => p.BaseDrawing.Name == "small"));
AssertInsideAndSpaced(parts, area, 0);
}
[Fact]
public void Pack_KeepsSpacingInsideTranslatedArea()
{
var area = new Box(10, 20, 30, 20);
var items = new List<NestItem> { Item("a", 6, 4, 5), Item("b", 4, 3, 4) };
var parts = AreaPacker.Pack(area, items, 1, new DefaultFillComparer(), CancellationToken.None);
Assert.Equal(9, parts.Count);
AssertInsideAndSpaced(parts, area, 1);
}
[Fact]
public void Pack_TurnsPartThatOnlyFitsRotated()
{
// 20 long in X, but the area is only 10 wide in X and 30 tall.
var area = new Box(0, 0, 10, 30);
var items = new List<NestItem> { Item("long", 20, 5, 1) };
var parts = AreaPacker.Pack(area, items, 0, new DefaultFillComparer(), CancellationToken.None);
var part = Assert.Single(parts);
Assert.Equal(System.Math.PI / 2, part.Rotation, 9);
AssertInsideAndSpaced(parts, area, 0);
}
[Fact]
public void Pack_ServesLowerPriorityNumberFirst()
{
var area = new Box(0, 0, 10, 10);
var items = new List<NestItem> { Item("later", 10, 10, 1, priority: 1), Item("first", 6, 6, 1, priority: 0) };
var parts = AreaPacker.Pack(area, items, 0, new DefaultFillComparer(), CancellationToken.None);
Assert.Equal("first", Assert.Single(parts).BaseDrawing.Name);
}
[Fact]
public void Pack_NeverExceedsQuantityAndSkipsEmptyDrawings()
{
var area = new Box(0, 0, 100, 100);
var items = new List<NestItem>
{
Item("two", 3, 3, 2),
new() { Drawing = new Drawing("empty", new OpenNest.CNC.Program()), Quantity = 4 },
};
var parts = AreaPacker.Pack(area, items, 0.5, new DefaultFillComparer(), CancellationToken.None);
Assert.Equal(2, parts.Count);
Assert.All(parts, p => Assert.Equal("two", p.BaseDrawing.Name));
}
[Fact]
public void Pack_CancelledTokenStillReturnsFirstValidLayout()
{
var area = new Box(0, 0, 30, 20);
var items = new List<NestItem> { Item("a", 6, 4, 5), Item("b", 4, 3, 4) };
var parts = AreaPacker.Pack(area, items, 1, new DefaultFillComparer(), new CancellationToken(canceled: true));
Assert.NotEmpty(parts);
AssertInsideAndSpaced(parts, area, 1);
}
[Fact]
public void Pack_LetsStrategyComparerChooseBetweenEqualLayouts()
{
// Two 60x2 strips fit stacked or end to end. Both place everything, so the strategy decides:
// Vertical Remnant keeps the right side clear, Horizontal Remnant keeps the top clear.
var area = new Box(0, 0, 130, 30);
var items = new List<NestItem> { Item("a", 60, 2, 1), Item("b", 60, 2, 1) };
var vertical = AreaPacker.Pack(area, items, 0, new VerticalRemnantComparer(), CancellationToken.None);
var horizontal = AreaPacker.Pack(area, items, 0, new HorizontalRemnantComparer(), CancellationToken.None);
Assert.Equal(2, vertical.Count);
Assert.Equal(60, vertical.Max(p => p.BoundingBox.Right) - vertical.Min(p => p.BoundingBox.Left), 9);
Assert.Equal(2, horizontal.Count);
Assert.Equal(2, horizontal.Max(p => p.BoundingBox.Top) - horizontal.Min(p => p.BoundingBox.Bottom), 9);
}
}
+29 -447
View File
@@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.BestFit
{
@@ -13,117 +12,21 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets
)
{
var count = offsets.Length;
var results = new double[count];
var results = new double[offsets.Length];
var movingVertices = Vertices(movingTemplateLines);
var stationaryVertices = Vertices(stationaryLines);
var contacts = SlideContactClassifier.FromLines(
movingTemplateLines, Vector.Zero, stationaryLines, Vector.Zero).Prepare();
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
var vertexCache =
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(
allMovingVerts,
offset.DirX,
offset.DirY,
keepHigh: true
);
var facing = FilterVerticesByProjection(
allStationaryVerts,
offset.DirX,
offset.DirY,
keepHigh: false
);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(
0,
count,
i =>
{
var offset = offsets[i];
var dirX = offset.DirX;
var dirY = offset.DirY;
var oppX = -dirX;
var oppY = -dirY;
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
var minDist = double.MaxValue;
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
var vy = leadingMoving[v].Y + offset.Dy;
for (var j = 0; j < stationaryLines.Count; j++)
{
var e = stationaryLines[j];
var d = SpatialQuery.RayEdgeDistance(
vx,
vy,
e.StartPoint.X,
e.StartPoint.Y,
e.EndPoint.X,
e.EndPoint.Y,
dirX,
dirY
);
if (d < minDist)
{
minDist = d;
if (d <= 0)
{
results[i] = 0;
return;
}
}
}
}
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
var svy = facingStationary[v].Y;
for (var j = 0; j < movingTemplateLines.Count; j++)
{
var e = movingTemplateLines[j];
var d = SpatialQuery.RayEdgeDistance(
svx,
svy,
e.StartPoint.X + offset.Dx,
e.StartPoint.Y + offset.Dy,
e.EndPoint.X + offset.Dx,
e.EndPoint.Y + offset.Dy,
oppX,
oppY
);
if (d < minDist)
{
minDist = d;
if (d <= 0)
{
results[i] = 0;
return;
}
}
}
}
results[i] = minDist;
}
);
var offset = offsets[i];
var source = new LineSlideEvents(
movingTemplateLines, movingVertices, offset.Dx, offset.Dy,
stationaryLines, stationaryVertices, offset.DirX, offset.DirY);
results[i] = SlideResolver.FirstBlocking(ref source,
contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY);
});
return results;
}
@@ -133,347 +36,26 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets
)
{
var count = offsets.Length;
var results = new double[count];
var results = new double[offsets.Length];
var movingVertices = SpatialQuery.ExtractEntityVertices(movingEntities);
var stationaryVertices = SpatialQuery.ExtractEntityVertices(stationaryEntities);
var contacts = new SlideContactClassifier(movingEntities, stationaryEntities).Prepare();
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
var movingCurves = ExtractCurveParams(movingEntities);
var stationaryCurves = ExtractCurveParams(stationaryEntities);
var vertexCache =
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
// All vertices participate: a leading-half filter can miss the next contact
// after sliding past an initial touch on a concave boundary.
System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(
allMovingVerts,
offset.DirX,
offset.DirY,
keepHigh: true
);
var facing = FilterVerticesByProjection(
allStationaryVerts,
offset.DirX,
offset.DirY,
keepHigh: false
);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(
0,
count,
i =>
{
var offset = offsets[i];
var dirX = offset.DirX;
var dirY = offset.DirY;
var oppX = -dirX;
var oppY = -dirY;
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary entities
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
var vy = leadingMoving[v].Y + offset.Dy;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(
vx,
vy,
stationaryEntities[j],
0,
0,
dirX,
dirY
);
if (d < minDist)
{
minDist = d;
if (d <= 0)
{
results[i] = 0;
return;
}
}
}
}
// Case 2: Facing stationary vertices → moving entities (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
var svy = facingStationary[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(
svx,
svy,
movingEntities[j],
offset.Dx,
offset.Dy,
oppX,
oppY
);
if (d < minDist)
{
minDist = d;
if (d <= 0)
{
results[i] = 0;
return;
}
}
}
}
// Phase 3: Curve-to-curve direct distance.
// Vertex sampling misses the true contact between two curved entities
// when the approach angle doesn't align with a sampled vertex.
for (var m = 0; m < movingCurves.Length; m++)
{
var mc = movingCurves[m];
var mcx = mc.Cx + offset.Dx;
var mcy = mc.Cy + offset.Dy;
for (var s = 0; s < stationaryCurves.Length; s++)
{
var sc = stationaryCurves[s];
var d = SpatialQuery.CurveTangencyDistance(
mcx, mcy, mc.Radius, mc.Entity as Arc,
sc.Cx, sc.Cy, sc.Radius, sc.Entity as Arc, dirX, dirY);
if (d >= minDist)
continue;
minDist = d;
if (d <= 0)
{
results[i] = 0;
return;
}
}
}
results[i] = minDist;
}
);
var offset = offsets[i];
var source = new EntitySlideEvents(
movingEntities, movingVertices, offset.Dx, offset.Dy,
stationaryEntities, stationaryVertices, offset.DirX, offset.DirY, arcToLine: true);
results[i] = SlideResolver.FirstBlocking(ref source,
contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY);
});
return results;
}
private readonly struct CurveParams
{
public readonly Entity Entity;
public readonly double Cx,
Cy,
Radius;
public CurveParams(Entity entity, double cx, double cy, double radius)
{
Entity = entity;
Cx = cx;
Cy = cy;
Radius = radius;
}
}
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
{
var curves = new List<CurveParams>();
for (var i = 0; i < entities.Count; i++)
{
if (entities[i] is Circle circle)
curves.Add(
new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius)
);
else if (entities[i] is Arc arc)
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
}
return curves.ToArray();
}
private static double RayEntityDistance(
double vx,
double vy,
Entity entity,
double entityOffsetX,
double entityOffsetY,
double dirX,
double dirY
)
{
if (entity is Line line)
{
return SpatialQuery.RayEdgeDistance(
vx,
vy,
line.StartPoint.X + entityOffsetX,
line.StartPoint.Y + entityOffsetY,
line.EndPoint.X + entityOffsetX,
line.EndPoint.Y + entityOffsetY,
dirX,
dirY
);
}
if (entity is Arc arc)
{
return SpatialQuery.RayArcDistance(
vx,
vy,
arc.Center.X + entityOffsetX,
arc.Center.Y + entityOffsetY,
arc.Radius,
arc.StartAngle,
arc.EndAngle,
arc.IsReversed,
dirX,
dirY
);
}
if (entity is Circle circle)
{
return SpatialQuery.RayCircleDistance(
vx,
vy,
circle.Center.X + entityOffsetX,
circle.Center.Y + entityOffsetY,
circle.Radius,
dirX,
dirY
);
}
return double.MaxValue;
}
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity is Line line)
{
vertices.Add(line.StartPoint);
vertices.Add(line.EndPoint);
}
else if (entity is Arc arc)
{
vertices.Add(arc.StartPoint());
vertices.Add(arc.EndPoint());
AddArcExtremes(vertices, arc);
}
else if (entity is Circle circle)
{
// Four cardinal points
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
}
}
return vertices.ToArray();
}
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
var reversed = arc.IsReversed;
if (reversed)
Generic.Swap(ref a1, ref a2);
// Right (0°)
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
// Top (90°)
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
// Left (180°)
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
// Bottom (270°)
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
private static Vector[] ExtractUniqueVertices(List<Line> lines)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < lines.Count; i++)
{
vertices.Add(lines[i].StartPoint);
vertices.Add(lines[i].EndPoint);
}
return vertices.ToArray();
}
private static Vector[] FilterVerticesByProjection(
Vector[] vertices,
double dirX,
double dirY,
bool keepHigh
)
{
if (vertices.Length == 0)
return vertices;
var projections = new double[vertices.Length];
var min = double.MaxValue;
var max = double.MinValue;
for (var i = 0; i < vertices.Length; i++)
{
projections[i] = vertices[i].X * dirX + vertices[i].Y * dirY;
if (projections[i] < min)
min = projections[i];
if (projections[i] > max)
max = projections[i];
}
var midpoint = (min + max) / 2;
var count = 0;
for (var i = 0; i < vertices.Length; i++)
{
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
count++;
}
var result = new Vector[count];
var idx = 0;
for (var i = 0; i < vertices.Length; i++)
{
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
result[idx++] = vertices[i];
}
return result;
}
private static Vector[] Vertices(List<Line> lines) =>
lines.SelectMany(line => new[] { line.StartPoint, line.EndPoint }).Distinct().ToArray();
}
}
+10 -1
View File
@@ -18,6 +18,15 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets
)
{
// ISlideComputer is axis-only; do not quantize an arbitrary direction into
// an unrelated cardinal push. Native curves already use this same fallback.
foreach (var offset in offsets)
{
if (!((offset.DirX == 0 && System.Math.Abs(offset.DirY) == 1)
|| (offset.DirY == 0 && System.Math.Abs(offset.DirX) == 1)))
return new CpuDistanceComputer().ComputeDistances(stationaryLines, movingTemplateLines, offsets);
}
var stationarySegments = SpatialQuery.FlattenLines(stationaryLines);
var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines);
var count = offsets.Length;
@@ -55,7 +64,7 @@ namespace OpenNest.Engine.BestFit
/// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
/// Left=0, Down=1, Right=2, Up=3.
/// Up=0, Down=1, Left=2, Right=3.
/// </summary>
private static int DirectionVectorToInt(double dirX, double dirY)
{
+3 -2
View File
@@ -9,7 +9,8 @@ namespace OpenNest.Engine.BestFit
public interface ISlideComputer : IDisposable
{
/// <summary>
/// Computes the minimum directional distance for each offset position.
/// Computes the first blocking contact distance for each offset position.
/// Separating/tangential contacts on closed boundaries do not block.
/// </summary>
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
@@ -30,7 +31,7 @@ namespace OpenNest.Engine.BestFit
);
/// <summary>
/// Computes minimum directional distance for offsets with per-offset directions.
/// Computes first blocking contact distances with per-offset directions.
/// Uploads segment data once for all offsets, reducing GPU round-trips.
/// </summary>
double[] ComputeBatchMultiDir(
+2 -2
View File
@@ -1,4 +1,4 @@
using System;
using System;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -62,7 +62,7 @@ namespace OpenNest.Engine.CirclePacking
for (; inner <= innerMax; inner += primaryOffset)
{
var addedItem = item.Clone() as Item;
var addedItem = item.Clone();
addedItem.Center = horizontal
? new Vector(inner, outer)
: new Vector(outer, inner);
+9 -5
View File
@@ -1,19 +1,23 @@
using OpenNest.Geometry;
using OpenNest.Geometry;
namespace OpenNest.Engine.CirclePacking
{
internal class Item : Circle
{
public int Id { get; set; }
// Optional caller-supplied packing identifier; not assigned automatically; preserved by clones.
public int PackingId { get; set; }
public object Clone()
public override Item Clone()
{
return new Item
var copy = new Item
{
Radius = this.Radius,
Center = this.Center,
Id = this.Id,
Rotation = this.Rotation,
PackingId = this.PackingId,
};
CopyBaseTo(copy);
return copy;
}
}
}
+20 -26
View File
@@ -127,24 +127,20 @@ namespace OpenNest.Engine.Fill
: PartGeometry.GetPerimeterEntities(moving)
);
// A moving part can be inside an obstacle's cutout. Omitting that
// loop would let it cross the inner wall before seeing the perimeter.
obstacleEntities[i] ??=
halfSpacing > 0
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
? PartGeometry.GetOffsetPartEntities(obstacleParts[i], halfSpacing)
: PartGeometry.GetPartEntities(obstacleParts[i]);
// Contacts left by a previous push only block directions that would
// push material into material; the kernel classifies them.
var d = SpatialQuery.DirectionalDistance(
movingEntities,
obstacleEntities[i],
direction
);
if (
d <= Tolerance.Epsilon
&& partSpacing <= Tolerance.Epsilon
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction)
)
{
continue;
}
if (d < distance)
distance = d;
@@ -176,27 +172,25 @@ namespace OpenNest.Engine.Fill
{
for (var i = 0; i < parts.Count; i++)
{
if (candidate.Intersects(parts[i], out _))
if (!candidate.Intersects(parts[i], out _))
continue;
// Part.Intersects compares outer perimeters only. A valid insert in a
// cutout must remain an obstacle, not be discarded as already overlapping.
var a = new ShapeProfile(PartGeometry.GetPartEntities(candidate));
var b = new ShapeProfile(PartGeometry.GetPartEntities(parts[i]));
if (a.Cutouts.Count == 0 && b.Cutouts.Count == 0)
return true;
if (Collision.HasOverlap(
a.Perimeter.ToPolygonWithTolerance(0.001),
b.Perimeter.ToPolygonWithTolerance(0.001),
a.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList(),
b.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList()))
return true;
}
return false;
}
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
{
var nudge = direction * (Tolerance.Epsilon * 10);
moving.Offset(nudge);
try
{
return !moving.Intersects(obstacle, out _);
}
finally
{
moving.Offset(-nudge);
}
}
public static double Push(
List<Part> movingParts,
List<Part> obstacleParts,
+771
View File
@@ -0,0 +1,771 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Pushes a group of selected parts apart until every constrained pair
/// (selected↔selected and selected↔obstacle) reaches a target part-to-part
/// spacing, with the plate work area and all non-selected parts as hard
/// boundaries. The inverse of <see cref="Compactor"/>: it grows gaps instead
/// of closing them.
/// <para>
/// Input with overlaps is accepted: an overlapping pair is a pair with
/// negative clearance and is separated along the minimum-translation
/// direction. Moves are straight-line only — a pair that could separate only
/// by routing around a blocker is reported as a violation instead. Final
/// positions are always overlap-free; nothing moves off the work area.
/// </para>
/// <para>
/// Anchor policy: within a violated pair the later-indexed selected part
/// moves; the counterpart moves only as a fallback when the anchor mover is
/// fully blocked by a work-area edge. Walls (non-selected parts) never move.
/// </para>
/// </summary>
public static class Expander
{
public sealed class Options
{
/// <summary>First spacing probed by the search; also the floor for the doubling step.</summary>
public double InitialStep = 1.0;
/// <summary>Bisection stops once the achievable spacing is known within this tolerance.</summary>
public double Tolerance = 0.01;
/// <summary>Relaxation iteration cap per separation run.</summary>
public int MaxIterations = 100;
/// <summary>Upper bound for the spacing search. 0 = auto (work-area diagonal).</summary>
public double MaxSpacing = 0;
}
public sealed class Violation
{
public Part A;
public Part B;
/// <summary>Clearance actually reached (may be negative for unresolvable overlaps).</summary>
public double Achieved;
/// <summary>True when a work-area edge, not a part, blocked the last needed move.</summary>
public bool BlockedByEdge;
}
public sealed class Result
{
/// <summary>
/// Minimum part-to-part clearance the returned layout satisfies,
/// never below zero. 0 with violations means the layout was only
/// cleaned up as far as possible, not opened up.
/// </summary>
public double AchievedSpacing;
/// <summary>True when the run was cancelled; no positions were changed.</summary>
public bool Cancelled;
/// <summary>Pairs that could not reach <see cref="AchievedSpacing"/>.</summary>
public List<Violation> Violations = new();
}
/// <summary>
/// Raises the part-to-part spacing of the selection as far as the plate
/// and its other parts allow, applying the best spacing found. Plate
/// PartSpacing/EdgeSpacing are not modified; edges keep their own
/// EdgeSpacing floor while only part-to-part clearance chases the target.
/// Mutates <paramref name="selected"/> locations; probing never touches
/// them, so a failed or cancelled run leaves the layout unchanged.
/// </summary>
public static Result Expand(
List<Part> selected,
Plate plate,
Options options = null,
CancellationToken token = default
)
{
if (plate == null)
throw new ArgumentNullException(nameof(plate));
if (selected == null || selected.Count < 2)
throw new ArgumentException(
"Expand requires at least two selected parts.",
nameof(selected)
);
if (selected.Any(p => !plate.Parts.Contains(p)))
throw new ArgumentException(
"All selected parts must belong to the plate.",
nameof(selected)
);
var opt = options ?? new Options();
var context = SeparationContext.Prepare(selected, plate);
var entry = context.Positions(); // all parts; movers are [0, count)
var result = new Result();
var sMin = MinimumPairClearance(context, entry);
result.AchievedSpacing = System.Math.Max(0, sMin);
if (token.IsCancellationRequested)
{
result.Cancelled = true;
return result;
}
var sGood = sMin;
var cap = opt.MaxSpacing > 0 ? opt.MaxSpacing : context.SpacingCap;
double hi; // first spacing that failed; double.NaN = none yet
(bool Converged, List<Vector> Positions) Probe(double spacing)
{
var attempt = Separate(context, entry, spacing, opt.MaxIterations, token);
return (attempt.Converged, attempt.Positions);
}
// Doubling phase: commit every spacing that converges. The cap gets
// its own probe even when the step jumps past it, and no spacing is
// probed twice.
var s = System.Math.Max(opt.InitialStep, 2 * System.Math.Max(0, sGood));
hi = double.NaN;
while (true)
{
if (s > cap)
{
if (cap > sGood + opt.Tolerance)
s = cap;
else
break;
}
if (s <= sGood + opt.Tolerance)
break;
if (token.IsCancellationRequested)
{
result.Cancelled = true;
return result;
}
var probe = Probe(s);
if (probe.Converged)
{
sGood = s;
if (sGood >= cap)
break;
s = System.Math.Max(s * 2, sGood + opt.Tolerance);
}
else
{
hi = s;
break;
}
}
// Bisection between the last spacing that converged and the first that failed.
if (!double.IsNaN(hi))
{
var lo = System.Math.Max(0, sGood);
while (hi - lo > opt.Tolerance)
{
if (token.IsCancellationRequested)
{
result.Cancelled = true;
return result;
}
var mid = (lo + hi) / 2;
if (Probe(mid).Converged)
lo = mid;
else
hi = mid;
}
sGood = lo;
}
// Finalize: re-separate at the applied spacing from the entry state.
// Deterministic, so this reproduces any committed probe exactly; when
// sGood was never probed (overlapping entry) it still performs the
// best-effort cleanup and yields the violation report.
var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token);
if (token.IsCancellationRequested)
{
result.Cancelled = true;
return result;
}
var positions = final.Positions;
Apply(selected, entry, positions);
var measured = MinimumPairClearance(context, positions);
result.AchievedSpacing = System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured));
result.Violations = final.Violations;
return result;
}
/// <summary>
/// Relaxes the given parts apart to a fixed target spacing against the
/// plate. Works on scratch positions; the caller applies them. Exposed
/// for testing and for callers that manage their own spacing search.
/// </summary>
public static (bool Converged, List<Vector> Positions, List<Violation> Violations) Separate(
List<Part> selected,
Plate plate,
double spacing,
int maxIterations = 100,
CancellationToken token = default
)
{
var context = SeparationContext.Prepare(selected, plate);
var positions = context.Positions();
return Separate(context, positions, spacing, maxIterations, token);
}
private static void Apply(List<Part> selected, List<Vector> from, List<Vector> to)
{
// Only movers occupy [0, selected.Count); walls never move.
for (var i = 0; i < selected.Count; i++)
{
var delta = to[i] - from[i];
if (delta.X != 0 || delta.Y != 0)
selected[i].Offset(delta);
}
}
private static double MinimumPairClearance(SeparationContext context, List<Vector> positions)
{
var min = double.MaxValue;
foreach (var pair in context.Pairs)
{
var (distance, _) = context.PairClearance(pair.IndexA, pair.IndexB, positions);
if (distance < min)
min = distance;
}
return min == double.MaxValue ? 0 : min;
}
private static (bool Converged, List<Vector> Positions, List<Violation> Violations) Separate(
SeparationContext context,
List<Vector> start,
double spacing,
int maxIterations,
CancellationToken token
)
{
var positions = new List<Vector>(start);
var stuck = new HashSet<int>();
var violations = new List<Violation>();
var epsMove = 1e-4;
// Internal margin absorbs the clearance kernel's tessellation error so
// the applied spacing holds against the production validators.
var target = spacing + 0.002;
var iterationLimit = maxIterations < 1 ? 1 : maxIterations;
for (var iteration = 0; iteration < iterationLimit; iteration++)
{
if (token.IsCancellationRequested)
return (false, start, violations);
var moved = 0.0;
var stuckChanged = false;
foreach (var pair in context.Pairs)
{
if (stuck.Contains(pair.Id))
continue;
var (distance, directionA) = context.PairClearance(
pair.IndexA,
pair.IndexB,
positions
);
// Trigger at the user spacing, not the internal margin: a
// pair already at the requested spacing must not be nudged,
// or feasible layouts at the ceiling (every pair exactly at
// spacing) would oscillate forever. The margin only sets how
// far past the trigger a push carries, absorbing tessellation
// error in the measurement.
if (distance >= spacing)
continue;
var need = target - distance;
// Anchor policy (decided): only the later-indexed selected
// part of a violated pair moves. Counterparts and walls
// never do — a pair whose anchor mover cannot reach the
// target is a violation, not an invitation to drift the
// anchor.
var moverIndex = pair.Mover;
// Clearance direction translates A away from B; a mover on
// the B side travels the opposite way.
var direction = moverIndex == pair.IndexA ? directionA : -directionA;
var room = context.ClipToWorkArea(
moverIndex,
positions[moverIndex],
direction,
need
);
// Take the largest valid step up to `room`: partial moves let
// a blocked mover advance again once its own blockers move
// away in later iterations (a wave separates a chain).
var applied = 0.0;
var blockedByEdge = false;
if (room > 0)
{
var trial = positions[moverIndex] + direction * room;
if (context.MaintainsValidity(moverIndex, trial, positions))
{
positions[moverIndex] = trial;
applied = room;
}
else
{
var lo = 0.0;
var hi2 = room;
for (var bisect = 0; bisect < 24 && hi2 - lo > 1e-6; bisect++)
{
var mid = (lo + hi2) / 2;
if (
context.MaintainsValidity(
moverIndex,
positions[moverIndex] + direction * mid,
positions
)
)
lo = mid;
else
hi2 = mid;
}
if (lo > epsMove)
{
positions[moverIndex] = positions[moverIndex] + direction * lo;
applied = lo;
}
}
blockedByEdge = applied < need - epsMove;
moved += applied;
}
else
{
blockedByEdge = true;
}
// A pair fully separated to the user target (the internal margin
// absorbs tessellation slack) is satisfied even if not to target.
if (applied > 0)
{
var (finalDistance, _) = context.PairClearance(
pair.IndexA,
pair.IndexB,
positions
);
if (finalDistance >= spacing)
continue;
// Partial progress: keep the pair live — its blockers may
// move away in later iterations and unblock the rest.
continue;
}
// Zero progress twice in a row parks the pair; the final sweep
// re-measures everything, so mid-loop bookkeeping never lies.
if (stuck.Contains(pair.Id))
continue;
stuck.Add(pair.Id);
stuckChanged = true;
}
if (moved < epsMove && !stuckChanged)
break;
}
// Honest verdict: stuck bookkeeping and the internal margin can both
// let a pair read as satisfied mid-loop while a later pair move
// un-does it (oscillation). Re-measure every constrained pair at the
// final positions once; the violations this sweep finds are the
// report, and any violation makes the run non-converged.
violations.Clear();
foreach (var pair in context.Pairs)
{
var (distance, _) = context.PairClearance(
pair.IndexA,
pair.IndexB,
positions
);
if (distance >= spacing)
continue;
violations.Add(
new Violation
{
A = context.PartOf(pair.IndexA),
B = context.PartOf(pair.IndexB),
Achieved = distance,
BlockedByEdge = stuck.Contains(pair.Id),
}
);
}
return (violations.Count == 0, positions, violations);
}
/// <summary>One constrained part↔part pair with its anchor mover.</summary>
private sealed class Pair
{
public int Id;
public int IndexA;
public int IndexB;
public int Mover;
}
/// <summary>
/// Per-run prepared geometry. Rings are local-frame polygons (world = local
/// + scratch position), prepared once per distinct Program by reference,
/// mirroring <see cref="PartOverlapChecker"/>'s caching but translatable.
/// </summary>
private sealed class SeparationContext
{
private readonly List<Part> parts; // movers [0, moverCount) then walls
private readonly List<RingSet> shapes; // per part
private readonly Box[] localBoxes; // per part, local frame
private readonly int moverCount;
private readonly Box workArea;
public readonly List<Pair> Pairs = new();
public readonly double SpacingCap;
private sealed class RingSet
{
public Polygon Outer;
public List<Polygon> Rings = new(); // outer + cutout rings, local frame
public List<Polygon> Holes = new();
}
private SeparationContext(
List<Part> parts,
List<RingSet> shapes,
Box[] localBoxes,
int moverCount,
Box workArea,
double spacingCap
)
{
this.parts = parts;
this.shapes = shapes;
this.localBoxes = localBoxes;
this.moverCount = moverCount;
this.workArea = workArea;
SpacingCap = spacingCap;
}
public static SeparationContext Prepare(List<Part> selected, Plate plate)
{
var movers = new List<Part>(selected);
var walls = plate.Parts.Where(p => !movers.Contains(p)).ToList();
var parts = new List<Part>(movers.Count + walls.Count);
parts.AddRange(movers);
parts.AddRange(walls);
var programs = new Dictionary<CNC.Program, RingSet>(
ReferenceEqualityComparer.Instance
);
var shapes = new List<RingSet>(parts.Count);
var localBoxes = new Box[parts.Count];
for (var i = 0; i < parts.Count; i++)
{
shapes.Add(PrepareProgram(programs, parts[i].Program));
localBoxes[i] = LocalBox(parts[i]);
}
var workArea = plate.WorkArea();
var spacingCap = System.Math.Sqrt(
workArea.Length * workArea.Length + workArea.Width * workArea.Width
);
var context = new SeparationContext(
parts,
shapes,
localBoxes,
movers.Count,
workArea,
spacingCap
);
context.BuildPairs();
return context;
}
private static RingSet PrepareProgram(
Dictionary<CNC.Program, RingSet> programs,
CNC.Program program
)
{
if (programs.TryGetValue(program, out var existing))
return existing;
var prepared = new RingSet();
var entities = ConvertProgram
.ToGeometry(program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
if (entities.Count > 0)
{
var profile = new ShapeProfile(entities);
if (profile.Perimeter != null)
{
prepared.Outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
prepared.Rings.Add(prepared.Outer);
foreach (var cutout in profile.Cutouts)
{
var hole = cutout.ToPolygonWithTolerance(0.001);
prepared.Rings.Add(hole);
prepared.Holes.Add(hole);
}
}
}
programs.Add(program, prepared);
return prepared;
}
private static Box LocalBox(Part part)
{
var box = part.BoundingBox;
return new Box(
box.Left - part.Location.X,
box.Bottom - part.Location.Y,
box.Length,
box.Width
);
}
private void BuildPairs()
{
var id = 0;
for (var a = 0; a < parts.Count; a++)
{
for (var b = a + 1; b < parts.Count; b++)
{
var aMover = a < moverCount;
var bMover = b < moverCount;
if (!aMover && !bMover)
continue;
Pairs.Add(
new Pair
{
Id = id++,
IndexA = a,
IndexB = b,
// Anchor policy: the later-index mover moves.
Mover = bMover ? b : a,
}
);
}
}
}
public bool IsMover(int index) => index < moverCount;
public Part PartOf(int index) => parts[index];
/// <summary>Current world positions of every part in pair-index order.</summary>
public List<Vector> Positions() => parts.Select(p => p.Location).ToList();
/// <summary>
/// Signed material clearance between two parts at the given scratch
/// positions. Material overlap (Collision oracle with hole subtraction)
/// reports negative penetration through the outer rings; otherwise the
/// clearance is the minimum boundary distance over all ring pairs, so a
/// part inside another's cutout measures its true gap to the hole ring
/// instead of a bogus outer-ring penetration.
/// </summary>
public (double Distance, Vector Direction) PairClearance(
int indexA,
int indexB,
List<Vector> positions
)
{
var setA = shapes[indexA];
var setB = shapes[indexB];
if (setA.Outer == null || setB.Outer == null)
return (0, new Vector(1, 0));
var offsetA = positions[indexA];
var offsetB = positions[indexB];
var outerA = CloneAt(setA.Outer, offsetA);
var outerB = CloneAt(setB.Outer, offsetB);
var holesA = setA.Holes.Count == 0 ? null : CloneAll(setA.Holes, offsetA);
var holesB = setB.Holes.Count == 0 ? null : CloneAll(setB.Holes, offsetB);
if (Collision.HasOverlap(outerA, outerB, holesA, holesB))
{
var penetration = Clearance.Between(outerA, outerB);
if (penetration.Distance < 0)
return (penetration.Distance, penetration.Direction);
// Hole subtraction resolved what the outers overlap: touching.
return (0, penetration.Direction);
}
double best = double.MaxValue;
var bestDir = new Vector(1, 0);
foreach (var ringA in setA.Rings)
{
var worldA = CloneAt(ringA, offsetA);
foreach (var ringB in setB.Rings)
{
var worldB = CloneAt(ringB, offsetB);
var clearance = Clearance.BoundaryDistance(worldA, worldB);
if (clearance.Distance < best)
{
best = clearance.Distance;
bestDir = clearance.Direction;
}
}
}
return (best, bestDir);
}
/// <summary>
/// Largest α ≤ need such that translating the part by direction·α keeps
/// its AABB inside the work area.
/// </summary>
public double ClipToWorkArea(int index, Vector position, Vector direction, double need)
{
var box = localBoxes[index];
var left = position.X + box.Left - workArea.Left;
var right = workArea.Right - (position.X + box.Right);
var bottom = position.Y + box.Bottom - workArea.Bottom;
var top = workArea.Top - (position.Y + box.Top);
var max = need;
if (direction.X > 0)
max = System.Math.Min(max, right / direction.X);
else if (direction.X < 0)
max = System.Math.Min(max, left / -direction.X);
if (direction.Y > 0)
max = System.Math.Min(max, top / direction.Y);
else if (direction.Y < 0)
max = System.Math.Min(max, bottom / -direction.Y);
return max < 0 ? 0 : max;
}
/// <summary>
/// True when the part at <paramref name="trial"/> stays inside the work
/// area and keeps no material overlap with any other part (Collision
/// oracle with hole subtraction, so part-in-cutout stays legal).
/// </summary>
public bool MaintainsValidity(int index, Vector trial, List<Vector> positions)
{
var box = localBoxes[index];
var movedBox = box.Translate(trial);
if (
movedBox.Left < workArea.Left - 1e-9
|| movedBox.Right > workArea.Right + 1e-9
|| movedBox.Bottom < workArea.Bottom - 1e-9
|| movedBox.Top > workArea.Top + 1e-9
)
return false;
var outer = shapes[index].Outer;
if (outer == null)
return true;
var worldOuter = CloneAt(outer, trial);
var worldHoles = shapes[index].Holes.Count == 0
? null
: CloneAll(shapes[index].Holes, trial);
for (var i = 0; i < parts.Count; i++)
{
if (i == index)
continue;
var otherOuter = shapes[i].Outer;
if (otherOuter == null)
continue;
if (!BoxOverlap(movedBox, localBoxes[i].Translate(positions[i]), 0.002))
continue;
var worldOther = CloneAt(otherOuter, positions[i]);
var worldOtherHoles = shapes[i].Holes.Count == 0
? null
: CloneAll(shapes[i].Holes, positions[i]);
if (
Collision.HasOverlap(
worldOuter,
worldOther,
worldHoles,
worldOtherHoles
)
)
return false;
}
return true;
}
private static List<Polygon> CloneAll(List<Polygon> polygons, Vector offset)
{
var list = new List<Polygon>(polygons.Count);
foreach (var polygon in polygons)
list.Add(CloneAt(polygon, offset));
return list;
}
/// <summary>Clone with world bounds applied — prepared rings are never mutated.</summary>
private static Polygon CloneAt(Polygon polygon, Vector offset)
{
var clone = (Polygon)polygon.Clone();
clone.UpdateBounds();
clone.Offset(offset);
return clone;
}
private static bool BoxOverlap(Box a, Box b, double slack)
{
return !(
a.Right + slack < b.Left
|| b.Right + slack < a.Left
|| a.Top + slack < b.Bottom
|| b.Top + slack < a.Bottom
);
}
}
}
}
+5 -10
View File
@@ -83,8 +83,9 @@ namespace OpenNest.Engine.Fill
// Slide uses locations, not cached bounds; Offset already translates the box.
// Slide part2 left toward part1.
var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left);
var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right);
// Keep complete loops so the shared kernel can classify tangential contacts.
var movingLines = boundary2.GetLines(part2.Location);
var stationaryLines = boundary1.GetLines(part1.Location);
var dist = SpatialQuery.DirectionalDistance(
movingLines,
stationaryLines,
@@ -234,15 +235,9 @@ namespace OpenNest.Engine.Fill
PushDirection direction
)
{
var opposite = SpatialQuery.OppositeDirection(direction);
var movingEdges = movingBoundary.GetEdges(direction);
var stationaryEdges = stationaryBoundary.GetEdges(opposite);
return SpatialQuery.DirectionalDistance(
movingEdges,
movingLocation,
stationaryEdges,
stationaryLocation,
movingBoundary.GetLines(movingLocation),
stationaryBoundary.GetLines(stationaryLocation),
direction
);
}
+12 -1
View File
@@ -362,6 +362,9 @@ namespace OpenNest.Engine.Fill
{
var perpAxis = PerpendicularAxis(direction);
// Set only when Step 1's check executed, passed, and no fallback replaced the row.
var rowIsVerified = false;
// Step 1: Tile along primary axis
var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction, cache));
@@ -372,6 +375,10 @@ namespace OpenNest.Engine.Fill
row = new List<Part>(pattern.Parts);
row.AddRange(TilePatternBbox(pattern, direction));
}
else if (pattern.Parts.Count > 1)
{
rowIsVerified = true;
}
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
@@ -396,7 +403,11 @@ namespace OpenNest.Engine.Fill
var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache));
if (HasOverlappingParts(gridResult, out var a3, out var b3))
// Only the unchanged row is covered by Step 1's clean verdict: skip Step 2
// only when the perpendicular tiling appended zero parts, so gridResult
// still contains the same Part objects, in the same order and poses.
if ((!rowIsVerified || gridResult.Count != row.Count)
&& HasOverlappingParts(gridResult, out var a3, out var b3))
{
LogOverlap("Step2-Perp", perpAxis, rowPattern, gridResult, a3, b3);
gridResult = new List<Part>(rowPattern.Parts);
+6 -102
View File
@@ -56,9 +56,12 @@ namespace OpenNest.Engine.Fill
if (grid.Rows <= 0 || grid.Cols <= 0)
return new List<Box>();
var merged = MergeCells(grid);
var sized = FilterBySize(merged, minDimension);
var unique = RemoveDominated(sized);
var unique = MaximalRectangles.FromGrid(
grid.XCoords,
grid.YCoords,
grid.Empty,
minDimension
);
SortByEdgeProximity(unique);
return unique;
}
@@ -188,56 +191,6 @@ namespace OpenNest.Engine.Fill
return false;
}
private static List<Box> FilterBySize(List<Box> boxes, double minDimension)
{
if (minDimension <= 0)
return boxes;
var result = new List<Box>();
foreach (var box in boxes)
{
if (box.Width >= minDimension && box.Length >= minDimension)
result.Add(box);
}
return result;
}
private static List<Box> RemoveDominated(List<Box> boxes)
{
boxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
var results = new List<Box>();
foreach (var box in boxes)
{
var dominated = false;
foreach (var larger in results)
{
if (IsContainedIn(box, larger))
{
dominated = true;
break;
}
}
if (!dominated)
results.Add(box);
}
return results;
}
private static bool IsContainedIn(Box inner, Box outer)
{
var eps = Math.Tolerance.Epsilon;
return inner.Left >= outer.Left - eps
&& inner.Right <= outer.Right + eps
&& inner.Bottom >= outer.Bottom - eps
&& inner.Top <= outer.Top + eps;
}
private void SortByEdgeProximity(List<Box> boxes)
{
boxes.Sort(
@@ -451,54 +404,5 @@ namespace OpenNest.Engine.Fill
return new Box(left, bottom, right - left, top - bottom);
}
/// <summary>
/// Finds maximal empty rectangles using the histogram method.
/// For each row, builds a height histogram of consecutive empty cells
/// above, then extracts the largest rectangles from the histogram.
/// </summary>
private static List<Box> MergeCells(CellGrid grid)
{
var height = new int[grid.Rows, grid.Cols];
for (var c = 0; c < grid.Cols; c++)
{
for (var r = 0; r < grid.Rows; r++)
height[r, c] = grid.Empty[r, c] ? (r > 0 ? height[r - 1, c] + 1 : 1) : 0;
}
var candidates = new List<Box>();
for (var r = 0; r < grid.Rows; r++)
{
var stack = new Stack<(int startCol, int h)>();
for (var c = 0; c <= grid.Cols; c++)
{
var h = c < grid.Cols ? height[r, c] : 0;
var startCol = c;
while (stack.Count > 0 && stack.Peek().h > h)
{
var top = stack.Pop();
startCol = top.startCol;
candidates.Add(
new Box(
grid.XCoords[top.startCol],
grid.YCoords[r - top.h + 1],
grid.XCoords[c] - grid.XCoords[top.startCol],
grid.YCoords[r + 1] - grid.YCoords[r - top.h + 1]
)
);
}
if (h > 0)
stack.Push((startCol, h));
}
}
return candidates;
}
}
}
@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Adapters;
/// <summary>
/// Binds one result sheet's poses to the caller's own drawings. The pose semantics match
/// <see cref="NestResultMaterializer"/>: rotate about the snapshot origin, then translate.
/// Every pose must be finite and refer to a mapped drawing. Malformed sheets are rejected
/// before binding any parts; the pipeline reports these violations without invoking binding.
/// </summary>
public static class NestResultBinder
{
public static IReadOnlyList<Part> Bind(
NestJobPlateResult sheet,
IReadOnlyDictionary<string, Drawing> drawingsByPartId
)
{
ArgumentNullException.ThrowIfNull(sheet);
ArgumentNullException.ThrowIfNull(drawingsByPartId);
foreach (var pose in sheet.Placements)
{
if (pose.PartId == null || !drawingsByPartId.TryGetValue(pose.PartId, out var drawing) || drawing == null)
throw new ArgumentException("Result contains a requirement not present in the drawing map.", nameof(sheet));
if (!double.IsFinite(pose.X) || !double.IsFinite(pose.Y) || !double.IsFinite(pose.Rotation))
throw new ArgumentException("Result contains a nonfinite placement pose.", nameof(sheet));
}
var parts = new List<Part>(sheet.Placements.Count);
foreach (var pose in sheet.Placements)
{
var part = new Part(drawingsByPartId[pose.PartId]);
part.Rotate(pose.Rotation);
part.Location = new Vector(pose.X, pose.Y);
part.UpdateBounds();
parts.Add(part);
}
return parts;
}
}
+77 -14
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
@@ -20,20 +21,77 @@ namespace OpenNest.Engine.Jobs;
/// </summary>
public static class NestLayoutCheck
{
/// <summary>Checks bounds, spacing, quantities, offered stock and rotation policies.
/// <summary>Checks placement structure, bounds, spacing, quantities, offered stock,
/// MaxPlates and rotation policies. Unrepresentable placements skip geometry checks.
/// Requirement IDs are used in messages. Instance indices and fulfillment metadata are
/// not checked, matching the benchmark contract.</summary>
public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result)
{
var materialized = NestResultMaterializer.Materialize(job, result);
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
p => (p.Id, p.Quantity));
var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList();
var violations = Validate(runs, requirements);
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), violations);
ArgumentNullException.ThrowIfNull(job);
return Violations(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id));
}
/// <summary>Same checks as <see cref="Violations(NestJob, NestJobResult)"/>, with messages
/// naming each requirement by <paramref name="displayNames"/> (requirement ID -> name).</summary>
public static IReadOnlyList<string> Violations(
NestJob job,
NestJobResult result,
IReadOnlyDictionary<string, string> displayNames
) => Violations(job, result, displayNames, out _);
internal static IReadOnlyList<string> Violations(
NestJob job,
NestJobResult result,
IReadOnlyDictionary<string, string> displayNames,
out bool canKeep
)
{
ArgumentNullException.ThrowIfNull(job);
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(displayNames);
var violations = new List<string>();
ValidateStructure(job, result, displayNames, violations);
canKeep = violations.Count == 0;
if (canKeep)
{
var materialized = NestResultMaterializer.Materialize(job, result);
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
p => (Name: displayNames.GetValueOrDefault(p.Id, p.Id), p.Quantity));
var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList();
violations.AddRange(Validate(runs, requirements));
}
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id,
p => displayNames.GetValueOrDefault(p.Id, p.Id)), violations);
return violations;
}
private static void ValidateStructure(
NestJob job,
NestJobResult result,
IReadOnlyDictionary<string, string> displayNames,
List<string> violations
)
{
var partIds = job.Parts.Select(p => p.Id).ToHashSet(StringComparer.Ordinal);
foreach (var sheet in result.Plates)
{
for (var i = 0; i < sheet.Placements.Count; i++)
{
var pose = sheet.Placements[i];
var name = pose.PartId == null ? "<null>" : displayNames.GetValueOrDefault(pose.PartId, pose.PartId);
var description = $"Plate {sheet.PlateIndex} placement {i} for '{name}'";
if (pose.PartId == null || !partIds.Contains(pose.PartId))
violations.Add($"{description}, which is not part of this job");
if (!double.IsFinite(pose.X))
violations.Add($"{description} has nonfinite X ({pose.X})");
if (!double.IsFinite(pose.Y))
violations.Add($"{description} has nonfinite Y ({pose.Y})");
if (!double.IsFinite(pose.Rotation))
violations.Add($"{description} has nonfinite Rotation ({pose.Rotation})");
}
}
}
/// <summary>Tests material clearance using the benchmark's conservative outlines.
/// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain
/// argument order. Geometry is cloned before transformation.</summary>
@@ -104,7 +162,7 @@ public static class NestLayoutCheck
/// Checks what the materialized layout cannot show: every sheet must be
/// one of the job's own stock entries (an engine may not invent a sheet
/// size or loosen its spacing/edge settings, which the layout checks
/// would otherwise trust), finite stock may not be overdrawn, and every
/// would otherwise trust), finite stock and MaxPlates may not be overdrawn, and every
/// placement's rotation must satisfy its part's RotationPolicy.
/// </summary>
internal static void ValidateAgainstJob(
@@ -118,6 +176,9 @@ public static class NestLayoutCheck
var partsById = job.Parts.ToDictionary(p => p.Id);
var sheetsUsed = new Dictionary<string, int>();
if (job.Options.MaxPlates is int maxPlates && jobResult.Plates.Count > maxPlates)
result.Add($"Used {jobResult.Plates.Count} sheet(s) but the job MaxPlates limit is {maxPlates}");
foreach (var sheet in jobResult.Plates)
{
if (
@@ -150,8 +211,10 @@ public static class NestLayoutCheck
{
foreach (var placement in sheet.Placements)
{
if (!partsById.TryGetValue(placement.PartId, out var part))
continue; // reported by ValidateQuantities
if (placement.PartId == null
|| !partsById.TryGetValue(placement.PartId, out var part)
|| !double.IsFinite(placement.Rotation))
continue; // reported by ValidateStructure
if (!part.Rotation.Allows(placement.Rotation))
{
@@ -228,10 +291,10 @@ public static class NestLayoutCheck
{
var bb = MaterialBounds(part);
var outLeft = bb.Left < workArea.X - Tolerance.Epsilon;
var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon;
var outRight = bb.Right > workArea.Right + Tolerance.Epsilon;
var outTop = bb.Top > workArea.Top + Tolerance.Epsilon;
var outLeft = bb.Left < workArea.X - NestTolerances.WorkAreaSlack;
var outBottom = bb.Bottom < workArea.Y - NestTolerances.WorkAreaSlack;
var outRight = bb.Right > workArea.Right + NestTolerances.WorkAreaSlack;
var outTop = bb.Top > workArea.Top + NestTolerances.WorkAreaSlack;
if (outLeft || outBottom || outRight || outTop)
{
+156
View File
@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Engine.Jobs;
/// <summary>
/// One automatic-nesting request: the named engine, the caller's items (drawing, quantity,
/// priority, rotation), the stock it may use, and optional job options. Items with a
/// nonpositive quantity are skipped.
/// </summary>
public sealed record NestPipelineRequest(
string EngineName,
IReadOnlyList<NestItem> Items,
IReadOnlyList<NestPlateStock> Stock,
NestJobOptions Options = null
);
/// <summary>A proposed sheet: new parts bound by reference to the caller's drawings.</summary>
public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList<Part> Parts);
/// <summary>
/// The engine's raw result, its independent validation, and the proposed plates. Nothing
/// has been committed; the caller decides what to do with an invalid result.
/// </summary>
public sealed class NestPipelineResult
{
internal NestPipelineResult(
string engineName,
NestJob job,
NestJobResult raw,
IReadOnlyList<ProposedPlate> plates,
IReadOnlyList<string> violations,
bool canKeep,
TimeSpan solveTime,
TimeSpan validationTime
)
{
EngineName = engineName;
Job = job;
Raw = raw;
Plates = plates;
Violations = violations;
CanKeep = canKeep;
SolveTime = solveTime;
ValidationTime = validationTime;
}
public string EngineName { get; }
public NestJob Job { get; }
public NestJobResult Raw { get; }
public IReadOnlyList<ProposedPlate> Plates { get; }
public IReadOnlyList<string> Violations { get; }
public bool IsValid => Violations.Count == 0;
/// <summary>True when every placement can be represented, even if layout rules fail.
/// False for unknown/null requirement IDs or nonfinite poses; Plates is then empty.</summary>
public bool CanKeep { get; }
public NestJobStatus Status => Raw.Status;
public NestJobStopReason StopReason => Raw.StopReason;
public TimeSpan SolveTime { get; }
public TimeSpan ValidationTime { get; }
}
/// <summary>
/// The single automatic-nesting path shared by every front end: build the job, solve it
/// with the named engine, validate the result with the benchmark's rules, then bind
/// placements to the caller's drawings. The pipeline never mutates caller items, drawings,
/// or plates, and it does not care which engine is selected.
/// </summary>
public static class NestPipeline
{
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
/// Engine cancellation propagates unchanged.</summary>
public static NestPipelineResult Run(
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(request);
var engine = NestingEngineRegistry.Create(request.EngineName);
return Run(engine, request.EngineName, request, progress, token);
}
/// <summary>Runs a caller-supplied engine instance through the same validation and binding.</summary>
public static NestPipelineResult Run(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(engine);
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Items);
ArgumentNullException.ThrowIfNull(request.Stock);
token.ThrowIfCancellationRequested();
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
var parts = new List<NestJobPart>();
for (var i = 0; i < request.Items.Count; i++)
{
var item = request.Items[i];
if (item == null || item.Quantity <= 0)
continue;
var partId = $"part-{i}";
parts.Add(DrawingJobMapper.FromItem(partId, item));
drawingsByPartId[partId] = item.Drawing;
}
var job = new NestJob(parts, request.Stock, request.Options);
NestJobValidator.Validate(job);
var clock = Stopwatch.StartNew();
var raw =
engine.Solve(job, progress, token)
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
var solveTime = clock.Elapsed;
token.ThrowIfCancellationRequested();
clock.Restart();
var names = drawingsByPartId.ToDictionary(
kv => kv.Key,
kv => kv.Value.Name ?? kv.Key,
StringComparer.Ordinal
);
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep);
var validationTime = clock.Elapsed;
var plates = canKeep
? raw.Plates.Select(sheet => new ProposedPlate(
sheet.PlateIndex,
sheet.Stock,
NestResultBinder.Bind(sheet, drawingsByPartId)
))
.ToList()
: new List<ProposedPlate>();
token.ThrowIfCancellationRequested();
return new NestPipelineResult(
engineName,
job,
raw,
plates,
violations,
canKeep,
solveTime,
validationTime
);
}
}
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.Engine.Jobs;
/// <summary>Applies an explicitly accepted whole-job proposal to empty physical sheets only.
/// Caller must keep the nest and its drawings stable from request construction through commit.</summary>
public static class NestPipelineCommit
{
public static IReadOnlyList<Plate> ApplyToEmptyPlates(
NestPipelineResult result,
PlateManager manager,
bool allowInvalid = false,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(manager);
token.ThrowIfCancellationRequested();
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
var applied = new List<Plate>();
// Commit is synchronous on the caller's owning thread. Cancellation is checked
// before mutation, not partway through attachment (which would leave half a job).
manager.BeginBatch();
try
{
foreach (var proposed in result.Plates)
{
if (proposed.Parts.Count == 0)
continue;
var plate = manager.GetOrCreateEmpty();
plate.Size = proposed.Stock.Size;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = 1;
plate.Parts.AddRange(proposed.Parts);
applied.Add(plate);
}
}
finally
{
manager.EndBatch();
}
return applied.AsReadOnly();
}
}
+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
/// <summary>Shared stock snapshots for whole-job front ends. Plate repeat count is not inventory.</summary>
public static class NestStockBuilder
{
public static IReadOnlyList<NestPlateStock> FromTemplate(
Plate template,
IReadOnlyList<PlateOption> options,
int? quantityPerOption = null
)
{
ArgumentNullException.ThrowIfNull(template);
if (options == null || options.Count == 0)
return new[] { DrawingJobMapper.FromPlate("plate", template, quantityPerOption) };
var stock = new List<NestPlateStock>(options.Count);
for (var i = 0; i < options.Count; i++)
{
var option = options[i];
ArgumentNullException.ThrowIfNull(option);
stock.Add(new NestPlateStock(
$"option-{i}",
new Size(option.Width, option.Length),
quantityPerOption,
template.PartSpacing,
template.EdgeSpacing,
template.Quadrant
));
}
return stock.AsReadOnly();
}
/// <summary>Single-sheet callers may not silently create additional sheets.</summary>
public static IReadOnlyList<NestPlateStock> SinglePlate(Plate plate) =>
FromTemplate(plate, null, quantityPerOption: 1);
}
+6
View File
@@ -1,5 +1,6 @@
using System;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Jobs;
@@ -18,6 +19,11 @@ public static class NestTolerances
/// with margin and is far below anything a cutting machine can resolve.</summary>
public const double SpacingSlack = 0.0005;
/// <summary>How far material may extend past the work area and still pass the layout check's
/// bounds test. Matches the general geometry tolerance, so a part drawn a few millionths
/// larger than its sheet's work area (as exported dimensions often are) is still accepted.</summary>
public const double WorkAreaSlack = Tolerance.Epsilon;
/// <summary>Clipper decimal precision (a 1e-4 coordinate grid).</summary>
public const int ClipperPrecision = ClipperBridge.Precision;
+58 -6
View File
@@ -1,24 +1,49 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
namespace OpenNest.Engine.Jobs;
/// <summary>
/// Registry of whole-job <see cref="INestingEngine"/> implementations. The four production
/// strategies are exposed through <see cref="FixedStrategyNestingEngine"/> so they compete on
/// equal footing with model-submitted engines. Callers choose an engine explicitly from
/// <see cref="AvailableEngines"/>; there is no process-global active selection.
/// strategies are exposed through <see cref="FixedStrategyNestingEngine"/>. Callers choose an
/// engine explicitly from <see cref="AvailableEngines"/>; there is no process-global active
/// selection. Plug-ins loaded from an Engines/ folder register under their CLR type name.
/// </summary>
public static class NestingEngineRegistry
{
private static readonly List<NestingEngineInfo> engines = new();
/// <summary>
/// Registry names used by earlier releases, mapped to the engine that replaced them, so saved
/// selections and scripts keep working. Consulted only when no engine has the requested name.
/// </summary>
private static readonly Dictionary<string, string> RenamedEngines = new(StringComparer.OrdinalIgnoreCase)
{
["Opus55NestingEngine"] = "Irregular",
["RectanglesNestingEngine"] = "Rectangles",
};
static NestingEngineRegistry()
{
Register(
"Rectangles",
"Plain and near-rectangular parts: maximal-rectangles box packing",
() => new RectanglesNestingEngine()
);
Register(
"Irregular",
"Irregular parts: no-fit-polygon frontier packing with look-ahead stock selection",
() => new IrregularNestingEngine()
);
Register(
"StockLadder",
"Caller-stock constrained-first fill and equivalent-demand area repacking",
@@ -53,13 +78,33 @@ public static class NestingEngineRegistry
public static IReadOnlyList<NestingEngineInfo> AvailableEngines => engines;
/// <summary>
/// Creates the engine registered under <paramref name="name"/> (case-insensitive). The caller's
/// explicit choice is the whole selection mechanism; unknown names throw.
/// Registered name for <paramref name="name"/>: an exact (case-insensitive) match, else the
/// engine a renamed legacy name now maps to, else null. Hosts use this to restore a saved
/// selection made under an old name.
/// </summary>
public static string? ResolveName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var trimmed = name.Trim();
var info = engines.FirstOrDefault(e => e.Name.Equals(trimmed, StringComparison.OrdinalIgnoreCase));
if (info != null)
return info.Name;
return RenamedEngines.TryGetValue(trimmed, out var renamed)
&& engines.FirstOrDefault(e => e.Name.Equals(renamed, StringComparison.OrdinalIgnoreCase)) is { } target
? target.Name
: null;
}
/// <summary>
/// Creates the engine registered under <paramref name="name"/> (case-insensitive, renamed legacy
/// names accepted). The caller's explicit choice is the whole selection mechanism; unknown names throw.
/// </summary>
public static INestingEngine Create(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var info = engines.FirstOrDefault(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
var resolved = ResolveName(name);
var info = resolved == null ? null : engines.First(e => e.Name == resolved);
if (info == null)
throw new NotSupportedException(
$"Unknown nesting engine: {name}. Available: {string.Join(", ", engines.Select(e => e.Name))}."
@@ -75,6 +120,13 @@ public static class NestingEngineRegistry
return;
}
// A leftover plug-in under a renamed engine's old name would shadow its built-in replacement.
if (RenamedEngines.ContainsKey(name))
{
Debug.WriteLine($"[NestingEngineRegistry] '{name}' skipped: replaced by built-in '{RenamedEngines[name]}'");
return;
}
engines.Add(new NestingEngineInfo(name, description, factory));
}

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