55 Commits
Author SHA1 Message Date
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
185 changed files with 27517 additions and 3831 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' } if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
- name: Run all test projects on Windows - name: Run all test projects on Windows
run: | 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 dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." } if ($LASTEXITCODE -ne 0) { throw "$project failed." }
} }
+9 -1
View File
@@ -207,13 +207,21 @@ FakesAssemblies/
*.db *.db
*.db-journal *.db-journal
# Claude Code # Local agent state and temporary planning/progress documents
.claude/ .claude/
/.hermes/plans/
/.hermes/progress/
.superpowers/ .superpowers/
docs/superpowers/ docs/superpowers/
/docs/*-plan.md
/docs/*-progress.md
# Launch settings # Launch settings
**/Properties/launchSettings.json **/Properties/launchSettings.json
# Local test config (contains user-specific paths to proprietary test assets) # Local test config (contains user-specific paths to proprietary test assets)
OpenNest.Tests/test-config.json 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: ```sh
# Full solution: Windows
```bash
dotnet build OpenNest.sln 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. Gitea is authoritative for Git refs.
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.
- Engine plug-ins implement `INestingEngine` with a public parameterless constructor, reference Engine and load from `Engines/` beside the host. Build them outside this repository/solution; do not add their projects here.
- `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. - [Nest file format](docs/nest-file-format.md)
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
## Architecture - [Lead-in placement](docs/geometry/lead-in-placement.md)
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
Nine projects form a layered architecture: - [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md)
### OpenNest.Core (class library) - [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
Domain model, geometry, and CNC primitives organized into namespaces: - [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.
- **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).
+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> /// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
public string PlacementStrategy { get; init; } = "Default"; public string PlacementStrategy { get; init; } = "Default";
/// <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 string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06; public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1; public double Spacing { get; init; } = 0.1;
+14 -2
View File
@@ -5,8 +5,8 @@ using System.IO.Compression;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.IO;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.IO;
namespace OpenNest.Api; 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> /// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
public sealed record NestPlateStockMapping(int PlateIndex, string StockId); 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 class NestResponse
{ {
public const int CurrentSchemaVersion = 2; public const int CurrentSchemaVersion = 3;
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary> /// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
public int SchemaVersion { get; init; } = CurrentSchemaVersion; public int SchemaVersion { get; init; } = CurrentSchemaVersion;
@@ -35,6 +38,9 @@ public class NestResponse
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary> /// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
public NestJobStatus? Status { get; init; } public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; } public NestJobStopReason? StopReason { get; init; }
/// <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<NestPartFulfillment> Fulfillment { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = []; public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = []; public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
@@ -75,6 +81,8 @@ public class NestResponse
ElapsedTicks = Elapsed.Ticks, ElapsedTicks = Elapsed.Ticks,
Status = Status, Status = Status,
StopReason = StopReason, StopReason = StopReason,
ValidationStatus = ValidationStatus,
Violations = Violations is null ? [] : new List<string>(Violations),
Fulfillment = Fulfillment is null Fulfillment = Fulfillment is null
? [] ? []
: new List<NestPartFulfillment>(Fulfillment), : new List<NestPartFulfillment>(Fulfillment),
@@ -158,6 +166,8 @@ public class NestResponse
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks), Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
Status = hasStatusMetadata ? archive.Status : null, Status = hasStatusMetadata ? archive.Status : null,
StopReason = hasStatusMetadata ? archive.StopReason : null, StopReason = hasStatusMetadata ? archive.StopReason : null,
ValidationStatus = archive.ValidationStatus,
Violations = archive.Violations ?? [],
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [], Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [], StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [], PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
@@ -175,6 +185,8 @@ public class NestResponse
public long ElapsedTicks { get; init; } public long ElapsedTicks { get; init; }
public NestJobStatus? Status { get; init; } public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { 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<NestPartFulfillment> Fulfillment { get; init; } = [];
public List<NestStockUsage> StockUsage { get; init; } = []; public List<NestStockUsage> StockUsage { get; init; } = [];
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = []; public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
+67 -39
View File
@@ -5,10 +5,9 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.IO;
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.IO;
namespace OpenNest.Api; namespace OpenNest.Api;
@@ -32,7 +31,7 @@ public static class NestRunner
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts); var parts = IdentifyParts(requestParts);
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal); 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) foreach (var part in parts)
{ {
@@ -68,23 +67,64 @@ public static class NestRunner
importedByPath.Add(part.Request.DxfPath, drawing); importedByPath.Add(part.Request.DxfPath, drawing);
} }
ConfigureDrawingForRequirement(drawing, part.Request); // Each requirement keeps its own identity/constraints even when paths are shared.
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity)); 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( var stock = CreateStock(request);
jobParts, var engineName = request.Engine ?? ResolvePlacementStrategy(request);
CreateStock(request),
new NestJobOptions(ResolvePlacementStrategy(request))
);
var jobProgress = progress == null ? null : new JobProgressBridge(progress); var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token); var result = 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. // API returns a detached proposal, not an acceptance/commit to a caller's nest.
var materialized = NestResultMaterializer.Materialize(job, result); // Invalid but representable proposals retain every pose and carry explicit validation status.
var nest = materialized.Nest; var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
nest.Thickness = request.Thickness; foreach (var item in items)
nest.Material = new Material(request.Material); 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 timingInfo = Timing.GetTimingInfo(nest);
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting); var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
@@ -97,29 +137,15 @@ public static class NestRunner
Utilization = CalculateUtilization(nest), Utilization = CalculateUtilization(nest),
CutTime = cutTime, CutTime = cutTime,
Elapsed = sw.Elapsed, Elapsed = sw.Elapsed,
Status = result.Status, Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
StopReason = result.StopReason, StopReason = stopReason,
Fulfillment = result ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
.Fulfillment.Select(value => new NestPartFulfillment( : result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
value.PartId, Violations = result.Violations,
value.Requested, Fulfillment = fulfillment,
value.Placed, StockUsage = usage,
value.Unplaced PlateStockMappings = result.Plates.Select((value, index) =>
)) new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
.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(),
Nest = nest, Nest = nest,
Request = request, Request = request,
} }
@@ -140,6 +166,8 @@ public static class NestRunner
"Request parts must not contain null entries.", "Request parts must not contain null entries.",
nameof(requestParts) nameof(requestParts)
); );
if (part.Quantity < 0)
throw new ArgumentException("Part quantities must be nonnegative.", nameof(requestParts));
var id = part.Id ?? $"part-{index}"; var id = part.Id ?? $"part-{index}";
if (string.IsNullOrWhiteSpace(id)) if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts)); throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
+125 -56
View File
@@ -7,9 +7,9 @@ using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading; using System.Threading;
using OpenNest; using OpenNest;
using OpenNest.Diagnostics;
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement; using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
@@ -21,6 +21,33 @@ static class NestConsole
{ {
public static int Run(string[] args) 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); var options = ParseArgs(args);
if (options == null) if (options == null)
@@ -100,6 +127,12 @@ static class NestConsole
var plate = nest.Plates[options.PlateIndex]; 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); ApplyTemplate(plate, options);
ApplyOverrides(plate, options); ApplyOverrides(plate, options);
@@ -110,18 +143,21 @@ static class NestConsole
var existingCount = plate.Parts.Count; var existingCount = plate.Parts.Count;
if (!options.KeepParts) if (!options.AutoNest && !options.KeepParts)
plate.Parts.Clear(); plate.Parts.Clear();
PrintHeader(nest, plate, drawing, existingCount, options); 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); var overlapCount = CheckOverlaps(plate, options);
PrintResults(success, plate, elapsed); PrintResults(success, plate, elapsed);
Save(nest, options); token.ThrowIfCancellationRequested();
PostProcess(nest, options); if (!SaveAndPost(nest, options))
return 1;
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0; return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
} }
@@ -185,6 +221,9 @@ static class NestConsole
case "--template" when i + 1 < args.Length: case "--template" when i + 1 < args.Length:
o.TemplateFile = args[++i]; o.TemplateFile = args[++i];
break; break;
case "--allow-invalid":
o.AllowInvalid = true;
break;
case "--autonest": case "--autonest":
o.AutoNest = true; o.AutoNest = true;
break; break;
@@ -194,6 +233,9 @@ static class NestConsole
case "--post" when i + 1 < args.Length: case "--post" when i + 1 < args.Length:
o.PostName = args[++i]; o.PostName = args[++i];
break; break;
case "--acknowledge-post-risks":
o.AcknowledgePostRisks = true;
break;
case "--post-output" when i + 1 < args.Length: case "--post-output" when i + 1 < args.Length:
o.PostOutput = args[++i]; o.PostOutput = args[++i];
break; break;
@@ -394,6 +436,8 @@ static class NestConsole
); );
var existingPartsMessage = options.KeepParts var existingPartsMessage = options.KeepParts
? $"Keeping {existingCount} existing parts" ? $"Keeping {existingCount} existing parts"
: options.AutoNest
? $"Will replace {existingCount} existing parts only after acceptance"
: $"Cleared {existingCount} existing parts"; : $"Cleared {existingCount} existing parts";
Console.WriteLine( Console.WriteLine(
$""" $"""
@@ -404,15 +448,17 @@ static class NestConsole
); );
} }
static (bool success, long elapsedMs) Fill( static (bool success, long elapsedMs, bool accepted) Fill(
Nest nest, Nest nest,
Plate plate, Plate plate,
Drawing drawing, Drawing drawing,
Options options Options options,
CancellationToken token
) )
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
bool success; bool success;
var accepted = true;
if (options.AutoNest) if (options.AutoNest)
{ {
@@ -433,7 +479,9 @@ static class NestConsole
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts" $"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 else
{ {
@@ -447,8 +495,9 @@ static class NestConsole
item, item,
plate.WorkArea(), plate.WorkArea(),
null, null,
CancellationToken.None token
); );
token.ThrowIfCancellationRequested();
if (parts.Count > 0) if (parts.Count > 0)
plate.Parts.AddRange(parts); plate.Parts.AddRange(parts);
@@ -456,52 +505,45 @@ static class NestConsole
} }
sw.Stop(); sw.Stop();
return (success, sw.ElapsedMilliseconds); return (success, sw.ElapsedMilliseconds, accepted);
} }
/// <summary> static (bool accepted, int committed) AutoNestJob(
/// Solves the drawings as one whole job against this single plate using the named jobs Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
/// engine, then commits the returned placements onto the plate. Placements are mapped back
/// onto the caller's original drawings (same pose semantics as NestResultMaterializer), so
/// the saved nest keeps its existing drawing identities.
/// </summary>
static bool AutoNestJob(Plate plate, List<NestItem> nestItems, string engineName)
{ {
var engine = NestingEngineRegistry.Create(engineName); var 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); if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
for (var i = 0; i < nestItems.Count; i++)
{ {
var partId = $"part-{i}"; Console.Error.WriteLine(result.Plates.Count > 1
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i])); ? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
drawingsByPartId[partId] = nestItems[i].Drawing; : !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. token.ThrowIfCancellationRequested();
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1); var proposed = result.Plates.SingleOrDefault();
var job = new NestJob(parts, [stock]); var committed = proposed?.Parts.Count ?? 0;
if (committed == 0)
var result = engine.Solve(job, null, CancellationToken.None);
var committed = 0;
foreach (var plateResult in result.Plates)
{ {
foreach (var pose in plateResult.Placements) Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
{ return (false, 0);
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++;
}
} }
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"); Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
return committed > 0; return (true, committed);
} }
static string ResolveFillStrategy(string engineName) static string ResolveFillStrategy(string engineName)
@@ -557,18 +599,16 @@ static class NestConsole
if (options.NoSave) if (options.NoSave)
return; return;
var firstInput = options.InputFiles[0]; var outputFile = NestOutputPath(options);
var outputFile =
options.OutputFile
?? Path.Combine(
Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
);
new NestWriter(nest).Write(outputFile); new NestWriter(nest).Write(outputFile);
Console.WriteLine($"Saved: {outputFile}"); Console.WriteLine($"Saved: {outputFile}");
} }
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) static string ResolvePostsDir(Options options)
{ {
if (options.PostsDir != null) if (options.PostsDir != null)
@@ -633,10 +673,13 @@ static class NestConsole
Console.WriteLine($" {p.Name,-30} {p.Description}"); 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) if (options.PostName == null)
return; {
Save(nest, options);
return true;
}
var postsDir = ResolvePostsDir(options); var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir); var processors = LoadPostProcessors(postsDir);
@@ -655,7 +698,15 @@ static class NestConsole
else else
Console.Error.WriteLine($"No post processors found in: {postsDir}"); 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; 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); 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() static void PrintUsage()
@@ -697,7 +762,8 @@ static class NestConsole
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--output <path> Output nest file path (default: <input>-result.nest) --output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing) --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, ...). --engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant) Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
--keep-parts Don't clear existing parts before filling --keep-parts Don't clear existing parts before filling
@@ -705,6 +771,7 @@ static class NestConsole
--no-save Skip saving output file --no-save Skip saving output file
--post <name> Run a post processor after nesting --post <name> Run a post processor after nesting
--post-output <path> Output file for post processor (default: <input>.cnc) --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/) --posts-dir <path> Directory containing post processor DLLs (default: Posts/)
--list-posts List available post processors and exit --list-posts List available post processors and exit
-h, --help Show this help -h, --help Show this help
@@ -725,9 +792,11 @@ static class NestConsole
public bool NoSave; public bool NoSave;
public bool KeepParts; public bool KeepParts;
public bool AutoNest; public bool AutoNest;
public bool AllowInvalid;
public string Engine = "Default"; public string Engine = "Default";
public string TemplateFile; public string TemplateFile;
public string PostName; public string PostName;
public bool AcknowledgePostRisks;
public string PostOutput; public string PostOutput;
public string PostsDir; public string PostsDir;
public bool ListPosts; 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; 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); var reindexedShape = shape.ReindexAt(point, entity);
if ( var tabbed = Parameters.TabsEnabled
Parameters.TabsEnabled
&& Parameters.TabConfig != null && Parameters.TabConfig != null
&& contourType == ContourType.External && contourType == ContourType.External;
) if (tabbed)
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size); 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(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) private void EmitScribeContours(Program program, List<Entity> scribeEntities)
@@ -436,6 +443,259 @@ namespace OpenNest.CNC.CuttingStrategy
return ContourType.Internal; 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( public static double ComputeNormal(
Vector point, Vector point,
Entity entity, Entity entity,
+28 -2
View File
@@ -90,6 +90,9 @@ namespace OpenNest.CNC
SetModeAbs(); 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) for (int i = 0; i < Codes.Count; ++i)
{ {
var code = Codes[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); subpgm.Program.Rotate(angle, origin);
} }
@@ -519,8 +522,31 @@ namespace OpenNest.CNC
foreach (var kvp in Variables) foreach (var kvp in Variables)
pgm.Variables[kvp.Key] = kvp.Value; pgm.Variables[kvp.Key] = kvp.Value;
// 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) 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; return pgm;
} }
+27 -5
View File
@@ -7,20 +7,42 @@ namespace OpenNest.CNC
{ {
public readonly record struct Segment(Vector From, Vector To); 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) public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
{ {
var results = new List<Segment>(); 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 // Draw the rapid from the previous tool position to the program's first
// pierce point. This also primes pos so the interior walk interprets // pierce point. The walk then starts at the program origin (basePos), not
// Incremental deltas from the correct absolute location (basePos), which // the pierce: the skipped first rapid still advances pos, so starting at
// matters for raw pre-lead-in programs that are emitted Incremental. // 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); var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce)); results.Add(new Segment(startPos, firstPierce));
var pos = firstPierce; var pos = basePos;
Walk(pgm, basePos, ref pos, skipFirst: true, results); 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) 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.Geometry;
using OpenNest.Math; using OpenNest.Math;
@@ -79,12 +79,28 @@ namespace OpenNest.CNC
} }
/// <summary> /// <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> /// </summary>
/// <returns></returns> /// <returns></returns>
public ICode Clone() 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() public override string ToString()
+35
View File
@@ -135,8 +135,15 @@ namespace OpenNest
) )
{ {
var bb = part.BoundingBox; var bb = part.BoundingBox;
var roundoff = GetBoundsRoundoff(part);
var (partMin, partMax) = AxisBounds(bb, clearance); var (partMin, partMax) = AxisBounds(bb, clearance);
var (partStart, partEnd) = CrossAxisBounds(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) if (cutPosition < partMin || cutPosition > partMax)
return EmptyExclusions; return EmptyExclusions;
@@ -157,6 +164,25 @@ namespace OpenNest
return new List<(double Start, double End)> { (partStart, partEnd) }; 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( private List<(double Start, double End)> IntersectPerimeter(
Entity perimeter, Entity perimeter,
double cutPosition, double cutPosition,
@@ -191,6 +217,15 @@ namespace OpenNest
if (coords.Count % 2 != 0) if (coords.Count % 2 != 0)
return null; 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 padding = usedOffset ? 0 : clearance;
var result = new List<(double Start, double End)>(); var result = new List<(double Start, double End)>();
for (var i = 0; i < coords.Count; i += 2) 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;
}
}
+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
);
}
}
}
@@ -148,14 +148,12 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
private static double SignedArea(List<Vector> verts) 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; var area = 0.0;
for (var i = 1; i + 1 < verts.Count; i++)
for (var i = 0; i < verts.Count; i++) area += Cross(verts[0], verts[i], verts[i + 1]);
{
var j = (i + 1) % verts.Count;
area += verts[i].X * verts[j].Y;
area -= verts[j].X * verts[i].Y;
}
return area * 0.5; return area * 0.5;
} }
@@ -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;
}
}
}
+806
View File
@@ -0,0 +1,806 @@
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)
{
// 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);
}
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;
}
}
/// <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;
}
}
}
}
+160 -345
View File
@@ -320,8 +320,9 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Computes the minimum translation distance along a push direction before /// Computes the translation distance along a push direction before any edge of
/// any edge of movingLines contacts any edge of stationaryLines. /// 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. /// Returns double.MaxValue if no collision path exists.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
@@ -334,7 +335,7 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <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. /// by (movingDx, movingDy) without creating new Line objects.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
@@ -345,37 +346,57 @@ namespace OpenNest.Geometry
PushDirection direction PushDirection direction
) )
{ {
var minDist = double.MaxValue; return DirectionalDistance(
var movingOffset = new Vector(movingDx, movingDy); movingLines,
movingDx,
movingDy,
stationaryLines,
direction,
SlideContactClassifier.FromLines(
movingLines,
new Vector(movingDx, movingDy),
stationaryLines,
Vector.Zero
)
);
}
// Case 1: Each moving vertex -> each stationary edge /// <summary>
var movingVertices = CollectVertices(movingLines, movingOffset); /// <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); var stationaryEdges = ToEdgeArray(stationaryLines);
SortEdgesForPruning(stationaryEdges, direction); SortEdgesForPruning(stationaryEdges, direction);
foreach (var mv in movingVertices) var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero).ToArray();
{
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 movingEdges = ToEdgeArray(movingLines); var movingEdges = ToEdgeArray(movingLines);
SortEdgesForPruning(movingEdges, opposite); SortEdgesForPruning(movingEdges, OppositeDirection(direction));
foreach (var sv in stationaryVertices) var source = new AxisSlideEvents(
{ movingEdges,
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); movingOffset,
if (d < minDist) movingVertices,
minDist = d; stationaryEdges,
} Vector.Zero,
stationaryVertices,
return minDist; direction
);
var unit = DirectionToOffset(direction, 1.0);
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
} }
/// <summary> /// <summary>
@@ -396,8 +417,8 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Computes the minimum directional distance using raw edge arrays and location offsets /// Computes the blocking directional distance using raw edge arrays and location
/// to avoid all intermediate object allocations. /// offsets. Sorts both edge arrays in place for pruning.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
(Vector start, Vector end)[] movingEdges, (Vector start, Vector end)[] movingEdges,
@@ -407,36 +428,58 @@ namespace OpenNest.Geometry
PushDirection direction PushDirection direction
) )
{ {
var minDist = double.MaxValue; 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); SortEdgesForPruning(stationaryEdges, direction);
var movingVertices = CollectVertices(movingEdges, movingOffset).ToArray();
// Case 1: Each moving vertex -> each stationary edge SortEdgesForPruning(movingEdges, OppositeDirection(direction));
var movingVertices = CollectVertices(movingEdges, movingOffset); var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset).ToArray();
foreach (var mv in movingVertices) var source = new AxisSlideEvents(
{ movingEdges,
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction); movingOffset,
if (d < minDist) movingVertices,
minDist = d; stationaryEdges,
} stationaryOffset,
stationaryVertices,
// Case 2: Each stationary vertex -> each moving edge (opposite direction) direction
var opposite = OppositeDirection(direction); );
SortEdgesForPruning(movingEdges, opposite); var unit = DirectionToOffset(direction, 1.0);
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist)
minDist = d;
}
return minDist;
} }
/// <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( public static double OneWayDistance(
Vector vertex, Vector vertex,
(Vector start, Vector end)[] edges, (Vector start, Vector end)[] edges,
@@ -628,8 +671,8 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction /// Computes the translation distance along an arbitrary unit direction before any
/// before any edge of movingLines contacts any edge of stationaryLines. /// edge of movingLines first blocks against an edge of stationaryLines.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
List<Line> movingLines, List<Line> movingLines,
@@ -637,58 +680,41 @@ namespace OpenNest.Geometry
Vector direction Vector direction
) )
{ {
var minDist = double.MaxValue; return DirectionalDistance(
var dirX = direction.X; movingLines,
var dirY = direction.Y; stationaryLines,
direction,
var movingVertices = CollectVertices(movingLines, Vector.Zero); SlideContactClassifier.FromLines(
movingLines,
foreach (var mv in movingVertices) Vector.Zero,
{ stationaryLines,
for (var i = 0; i < stationaryLines.Count; i++) Vector.Zero
{ )
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; /// <summary>
var oppY = -dirY; /// <see cref="DirectionalDistance(List{Line}, List{Line}, Vector)"/> with
/// caller-supplied contact topology.
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero); /// </summary>
public static double DirectionalDistance(
foreach (var sv in stationaryVertices) List<Line> movingLines,
List<Line> stationaryLines,
Vector direction,
SlideContactClassifier contacts
)
{ {
for (var i = 0; i < movingLines.Count; i++) var source = new LineSlideEvents(
{ movingLines,
var e = movingLines[i]; CollectVertices(movingLines, Vector.Zero).ToArray(),
var d = RayEdgeDistance( 0,
sv.X, 0,
sv.Y, stationaryLines,
e.pt1.X, CollectVertices(stationaryLines, Vector.Zero).ToArray(),
e.pt1.Y, direction.X,
e.pt2.X, direction.Y
e.pt2.Y,
oppX,
oppY
); );
if (d < minDist) return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
minDist = d;
}
}
return minDist;
} }
/// <summary> /// <summary>
@@ -710,10 +736,10 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction /// Computes the translation distance along an arbitrary unit direction before any
/// before any vertex/edge of movingEntities contacts any vertex/edge of /// vertex/edge of movingEntities first blocks against stationaryEntities. Works with
/// stationaryEntities. Works with native Line, Arc, and Circle entities /// native Line, Arc, and Circle entities without tessellation. A contact that the
/// without tessellation. /// push slides along or leaves does not block (see <see cref="SlideContact"/>).
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> movingEntities,
@@ -721,228 +747,42 @@ namespace OpenNest.Geometry
Vector direction Vector direction
) )
{ {
var minDist = double.MaxValue; return DirectionalDistance(
var dirX = direction.X;
var dirY = direction.Y;
var movingVertices = ExtractEntityVertices(movingEntities);
for (var v = 0; v < movingVertices.Length; v++)
{
var vx = movingVertices[v].X;
var vy = movingVertices[v].Y;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0)
return 0;
}
}
}
var oppX = -dirX;
var oppY = -dirY;
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
for (var v = 0; v < stationaryVertices.Length; v++)
{
var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0)
return 0;
}
}
}
// Phase 3: Arc-to-line closest-point check.
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
// closest point on a small corner arc to a straight edge may lie between
// those samples. Use ClosestPointTo to find it and fire a ray from there.
minDist = ArcToLineClosestDistance(
movingEntities, movingEntities,
stationaryEntities, stationaryEntities,
dirX, direction,
dirY, new SlideContactClassifier(movingEntities, stationaryEntities)
minDist
); );
if (minDist <= 0) }
return 0;
minDist = ArcToLineClosestDistance( /// <summary>
stationaryEntities, /// <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, movingEntities,
oppX, ExtractEntityVertices(movingEntities),
oppY, 0,
minDist 0,
stationaryEntities,
ExtractEntityVertices(stationaryEntities),
direction.X,
direction.Y,
arcToLine: true
); );
if (minDist <= 0) return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
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; public static Vector[] ExtractEntityVertices(List<Entity> entities)
}
private static double ArcToLineClosestDistance(
List<Entity> arcEntities,
List<Entity> lineEntities,
double dirX,
double dirY,
double minDist
)
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (arcEntities[i] is not Arc arc)
continue;
var cx = arc.Center.X;
var cy = arc.Center.Y;
var r = arc.Radius;
for (var j = 0; j < lineEntities.Count; j++)
{
if (lineEntities[j] is not Line line)
continue;
var p1x = line.pt1.X;
var p1y = line.pt1.Y;
var ex = line.pt2.X - p1x;
var ey = line.pt2.Y - p1y;
var det = ex * dirY - ey * dirX;
if (System.Math.Abs(det) < Tolerance.Epsilon)
continue;
// The directional distance from an arc point at angle θ to the
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
for (var k = 0; k < 2; k++)
{
var theta = k == 0 ? theta1 : theta2;
if (
!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)
)
continue;
var qx = cx + r * System.Math.Cos(theta);
var qy = cy + r * System.Math.Sin(theta);
var d = RayEdgeDistance(
qx,
qy,
p1x,
p1y,
line.pt2.X,
line.pt2.Y,
dirX,
dirY
);
if (d < minDist)
{
minDist = d;
if (d <= 0)
return 0;
}
}
}
}
return minDist;
}
private static double RayEntityDistance(
double vx,
double vy,
Entity entity,
double dirX,
double dirY
)
{
if (entity is Line line)
{
return RayEdgeDistance(
vx,
vy,
line.pt1.X,
line.pt1.Y,
line.pt2.X,
line.pt2.Y,
dirX,
dirY
);
}
if (entity is Arc arc)
{
return RayArcDistance(
vx,
vy,
arc.Center.X,
arc.Center.Y,
arc.Radius,
arc.StartAngle,
arc.EndAngle,
arc.IsReversed,
dirX,
dirY
);
}
if (entity is Circle circle)
{
return RayCircleDistance(
vx,
vy,
circle.Center.X,
circle.Center.Y,
circle.Radius,
dirX,
dirY
);
}
return double.MaxValue;
}
private static Vector[] ExtractEntityVertices(List<Entity> entities)
{ {
var vertices = new HashSet<Vector>(); var vertices = new HashSet<Vector>();
@@ -1041,31 +881,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) private static double BoxProjectionMin(Box box, double dx, double dy)
{ {
var x = dx >= 0 ? box.Left : box.Right; var x = dx >= 0 ? box.Left : box.Right;
+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; }
}
+53 -2
View File
@@ -111,6 +111,30 @@ namespace OpenNest
UpdateBounds(); 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() public void RemoveLeadIns()
{ {
var rotation = preLeadInRotation; var rotation = preLeadInRotation;
@@ -145,7 +169,7 @@ namespace OpenNest
EnsureOwnedProgram(); EnsureOwnedProgram();
Program.Rotate(angle); Program.Rotate(angle);
location = Location.Rotate(angle); location = Location.Rotate(angle);
preLeadInRotation = Program.Rotation; TrackRotation(angle);
UpdateBounds(); UpdateBounds();
} }
@@ -159,10 +183,22 @@ namespace OpenNest
EnsureOwnedProgram(); EnsureOwnedProgram();
Program.Rotate(angle); Program.Rotate(angle);
location = Location.Rotate(angle, origin); location = Location.Rotate(angle, origin);
preLeadInRotation = Program.Rotation; TrackRotation(angle);
UpdateBounds(); 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> /// <summary>
/// Offsets the part. /// Offsets the part.
/// </summary> /// </summary>
@@ -323,6 +359,7 @@ namespace OpenNest
new Box(BoundingBox.X, BoundingBox.Y, BoundingBox.Length, BoundingBox.Width) new Box(BoundingBox.X, BoundingBox.Y, BoundingBox.Length, BoundingBox.Width)
); );
part.ownsProgram = true; part.ownsProgram = true;
part.CopyLeadInStateFrom(this);
return part; return part;
} }
@@ -347,10 +384,24 @@ namespace OpenNest
BoundingBox.Width BoundingBox.Width
) )
); );
part.CopyLeadInStateFrom(this);
return part; 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() private void EnsureOwnedProgram()
{ {
if (!ownsProgram) if (!ownsProgram)
+42 -5
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Collections; using OpenNest.Collections;
@@ -94,18 +94,40 @@ namespace OpenNest
/// <summary> /// <summary>
/// Regenerates all cut-off drawings and materializes them as parts. /// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is /// 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> /// </summary>
public void RegenerateCutOffs(CutOffSettings settings) 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--) for (var i = Parts.Count - 1; i >= 0; i--)
{ {
if (Parts[i].BaseDrawing.IsCutOff) 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); 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 cache = BuildPerimeterCache(this);
var placed = new List<(int Index, Part Part)>();
// Regenerate and materialize each cut-off // Regenerate and materialize each cut-off
foreach (var cutoff in CutOffs) foreach (var cutoff in CutOffs)
@@ -116,8 +138,17 @@ namespace OpenNest
continue; continue;
var part = new Part(cutoff.Drawing); var part = new Part(cutoff.Drawing);
if (sequence != null && sequence.TryGetValue(cutoff, out var index))
placed.Add((index, part));
else
Parts.Add(part); 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> /// <summary>
@@ -143,7 +174,13 @@ namespace OpenNest
if (entities.Count > 0) 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()) if (profile.Perimeter.IsClosed())
{ {
@@ -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;
}
}
}
+95
View File
@@ -0,0 +1,95 @@
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.
/// </summary>
public string Resolve(IEnumerable<string> availableEngineNames, out string? statusMessage)
{
ArgumentNullException.ThrowIfNull(availableEngineNames);
var requestedName = NormalizeName(EngineName);
var registeredName = availableEngineNames.FirstOrDefault(
name => string.Equals(name, requestedName, StringComparison.OrdinalIgnoreCase));
if (registeredName is not null)
{
statusMessage = null;
return registeredName;
}
statusMessage = $"Saved Auto Nest engine '{requestedName}' is unavailable. Using Default.";
return DefaultEngineName;
}
/// <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();
}
+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; }
}
}
+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);
}
}
@@ -6,6 +6,52 @@ namespace OpenNest.Engine.Tests.Jobs;
public class NestLayoutCheckTests 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] [Fact]
public void TangentDiscsClearAtSafeMarginAcrossRadiiAnglesAndTolerances() public void TangentDiscsClearAtSafeMarginAcrossRadiiAnglesAndTolerances()
{ {
@@ -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));
}
}
+27 -445
View File
@@ -1,7 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.BestFit namespace OpenNest.Engine.BestFit
{ {
@@ -13,117 +12,21 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets SlideOffset[] offsets
) )
{ {
var count = offsets.Length; var results = new double[offsets.Length];
var results = new double[count]; var movingVertices = Vertices(movingTemplateLines);
var stationaryVertices = Vertices(stationaryLines);
var contacts = SlideContactClassifier.FromLines(
movingTemplateLines, Vector.Zero, stationaryLines, Vector.Zero).Prepare();
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines); System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
var vertexCache =
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(
allMovingVerts,
offset.DirX,
offset.DirY,
keepHigh: true
);
var facing = FilterVerticesByProjection(
allStationaryVerts,
offset.DirX,
offset.DirY,
keepHigh: false
);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(
0,
count,
i =>
{ {
var offset = offsets[i]; var offset = offsets[i];
var dirX = offset.DirX; var source = new LineSlideEvents(
var dirY = offset.DirY; movingTemplateLines, movingVertices, offset.Dx, offset.Dy,
var oppX = -dirX; stationaryLines, stationaryVertices, offset.DirX, offset.DirY);
var oppY = -dirY; results[i] = SlideResolver.FirstBlocking(ref source,
contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.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;
}
);
return results; return results;
} }
@@ -133,347 +36,26 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets SlideOffset[] offsets
) )
{ {
var count = offsets.Length; var results = new double[offsets.Length];
var results = new double[count]; var movingVertices = SpatialQuery.ExtractEntityVertices(movingEntities);
var stationaryVertices = SpatialQuery.ExtractEntityVertices(stationaryEntities);
var contacts = new SlideContactClassifier(movingEntities, stationaryEntities).Prepare();
var allMovingVerts = ExtractVerticesFromEntities(movingEntities); // All vertices participate: a leading-half filter can miss the next contact
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities); // after sliding past an initial touch on a concave boundary.
System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
var movingCurves = ExtractCurveParams(movingEntities);
var stationaryCurves = ExtractCurveParams(stationaryEntities);
var vertexCache =
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(
allMovingVerts,
offset.DirX,
offset.DirY,
keepHigh: true
);
var facing = FilterVerticesByProjection(
allStationaryVerts,
offset.DirX,
offset.DirY,
keepHigh: false
);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(
0,
count,
i =>
{ {
var offset = offsets[i]; var offset = offsets[i];
var dirX = offset.DirX; var source = new EntitySlideEvents(
var dirY = offset.DirY; movingEntities, movingVertices, offset.Dx, offset.Dy,
var oppX = -dirX; stationaryEntities, stationaryVertices, offset.DirX, offset.DirY, arcToLine: true);
var oppY = -dirY; results[i] = SlideResolver.FirstBlocking(ref source,
contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.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;
}
);
return results; return results;
} }
private readonly struct CurveParams private static Vector[] Vertices(List<Line> lines) =>
{ lines.SelectMany(line => new[] { line.StartPoint, line.EndPoint }).Distinct().ToArray();
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;
}
} }
} }
+10 -1
View File
@@ -18,6 +18,15 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets 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 stationarySegments = SpatialQuery.FlattenLines(stationaryLines);
var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines); var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines);
var count = offsets.Length; var count = offsets.Length;
@@ -55,7 +64,7 @@ namespace OpenNest.Engine.BestFit
/// <summary> /// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface. /// Maps a unit direction vector to a PushDirection int for the GPU interface.
/// Left=0, Down=1, Right=2, Up=3. /// Up=0, Down=1, Left=2, Right=3.
/// </summary> /// </summary>
private static int DirectionVectorToInt(double dirX, double dirY) private static int DirectionVectorToInt(double dirX, double dirY)
{ {
+3 -2
View File
@@ -9,7 +9,8 @@ namespace OpenNest.Engine.BestFit
public interface ISlideComputer : IDisposable public interface ISlideComputer : IDisposable
{ {
/// <summary> /// <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> /// </summary>
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param> /// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param> /// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
@@ -30,7 +31,7 @@ namespace OpenNest.Engine.BestFit
); );
/// <summary> /// <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. /// Uploads segment data once for all offsets, reducing GPU round-trips.
/// </summary> /// </summary>
double[] ComputeBatchMultiDir( double[] ComputeBatchMultiDir(
+20 -26
View File
@@ -127,24 +127,20 @@ namespace OpenNest.Engine.Fill
: PartGeometry.GetPerimeterEntities(moving) : 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] ??= obstacleEntities[i] ??=
halfSpacing > 0 halfSpacing > 0
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing) ? PartGeometry.GetOffsetPartEntities(obstacleParts[i], halfSpacing)
: PartGeometry.GetPerimeterEntities(obstacleParts[i]); : 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( var d = SpatialQuery.DirectionalDistance(
movingEntities, movingEntities,
obstacleEntities[i], obstacleEntities[i],
direction direction
); );
if (
d <= Tolerance.Epsilon
&& partSpacing <= Tolerance.Epsilon
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction)
)
{
continue;
}
if (d < distance) if (d < distance)
distance = d; distance = d;
@@ -176,27 +172,25 @@ namespace OpenNest.Engine.Fill
{ {
for (var i = 0; i < parts.Count; i++) 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 true;
} }
return false; 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( public static double Push(
List<Part> movingParts, List<Part> movingParts,
List<Part> obstacleParts, 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 uses locations, not cached bounds; Offset already translates the box.
// Slide part2 left toward part1. // Slide part2 left toward part1.
var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left); // Keep complete loops so the shared kernel can classify tangential contacts.
var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right); var movingLines = boundary2.GetLines(part2.Location);
var stationaryLines = boundary1.GetLines(part1.Location);
var dist = SpatialQuery.DirectionalDistance( var dist = SpatialQuery.DirectionalDistance(
movingLines, movingLines,
stationaryLines, stationaryLines,
@@ -234,15 +235,9 @@ namespace OpenNest.Engine.Fill
PushDirection direction PushDirection direction
) )
{ {
var opposite = SpatialQuery.OppositeDirection(direction);
var movingEdges = movingBoundary.GetEdges(direction);
var stationaryEdges = stationaryBoundary.GetEdges(opposite);
return SpatialQuery.DirectionalDistance( return SpatialQuery.DirectionalDistance(
movingEdges, movingBoundary.GetLines(movingLocation),
movingLocation, stationaryBoundary.GetLines(stationaryLocation),
stationaryEdges,
stationaryLocation,
direction direction
); );
} }
@@ -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;
}
}
+70 -7
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Converters; using OpenNest.Converters;
@@ -20,20 +21,77 @@ namespace OpenNest.Engine.Jobs;
/// </summary> /// </summary>
public static class NestLayoutCheck 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 /// Requirement IDs are used in messages. Instance indices and fulfillment metadata are
/// not checked, matching the benchmark contract.</summary> /// not checked, matching the benchmark contract.</summary>
public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result) public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result)
{
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 materialized = NestResultMaterializer.Materialize(job, result);
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id], var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
p => (p.Id, p.Quantity)); p => (Name: displayNames.GetValueOrDefault(p.Id, p.Id), p.Quantity));
var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(); var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList();
var violations = Validate(runs, requirements); violations.AddRange(Validate(runs, requirements));
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), violations); }
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id,
p => displayNames.GetValueOrDefault(p.Id, p.Id)), violations);
return 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. /// <summary>Tests material clearance using the benchmark's conservative outlines.
/// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain /// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain
/// argument order. Geometry is cloned before transformation.</summary> /// 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 /// 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 /// 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 /// 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. /// placement's rotation must satisfy its part's RotationPolicy.
/// </summary> /// </summary>
internal static void ValidateAgainstJob( internal static void ValidateAgainstJob(
@@ -118,6 +176,9 @@ public static class NestLayoutCheck
var partsById = job.Parts.ToDictionary(p => p.Id); var partsById = job.Parts.ToDictionary(p => p.Id);
var sheetsUsed = new Dictionary<string, int>(); 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) foreach (var sheet in jobResult.Plates)
{ {
if ( if (
@@ -150,8 +211,10 @@ public static class NestLayoutCheck
{ {
foreach (var placement in sheet.Placements) foreach (var placement in sheet.Placements)
{ {
if (!partsById.TryGetValue(placement.PartId, out var part)) if (placement.PartId == null
continue; // reported by ValidateQuantities || !partsById.TryGetValue(placement.PartId, out var part)
|| !double.IsFinite(placement.Rotation))
continue; // reported by ValidateStructure
if (!part.Rotation.Allows(placement.Rotation)) if (!part.Rotation.Allows(placement.Rotation))
{ {
+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);
}
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Sequencing
{
/// <summary>Applies a sequencer's exit-first route as the plate's cutting order.</summary>
public static class PlateSequencing
{
/// <summary>Sequences the plates present when the operation starts.</summary>
public static void ApplyAll(IEnumerable<Plate> plates, SequenceParameters parameters)
{
// Reordering parts can make PlateManager replace the trailing empty
// plate. Snapshot before applying so those events cannot invalidate
// enumeration of the nest's live plate collection.
foreach (var plate in plates.ToArray())
Apply(plate, parameters);
}
public static void Apply(Plate plate, SequenceParameters parameters)
{
var sequencer = PartSequencerFactory.Create(parameters);
var ordered = sequencer.Sequence(plate.Parts.ToList(), plate)
.Select(p => p.Part).Reverse().ToList();
// Enforce dependencies AFTER reversing the exit-first route. Checking
// the sequencer's output itself would invert the safety rule on apply.
var cutOrder = OrderCutOffsFirst(ordered, plate);
plate.Parts.Clear();
foreach (var part in cutOrder)
plate.Parts.Add(part);
}
private static List<Part> OrderCutOffsFirst(List<Part> ordered, Plate plate)
{
var cuts = ordered.Select((part, index) => (Part: part, Index: index))
.Where(item => item.Part.BaseDrawing.IsCutOff).ToList();
if (cuts.Count == 0)
return ordered;
var definitions = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
foreach (var cutOff in plate.CutOffs)
definitions[cutOff.Drawing] = cutOff;
var bounds = plate.BoundingBox(includeParts: false);
var emitted = new bool[ordered.Count];
var result = new List<Part>(ordered.Count);
for (var i = 0; i < ordered.Count; i++)
{
var part = ordered[i];
if (!part.BaseDrawing.IsCutOff)
{
foreach (var cut in cuts)
{
if (emitted[cut.Index])
continue;
// An orphaned cutoff still must not follow potentially
// crossed parts when its nominal span cannot be recovered.
if (!definitions.TryGetValue(cut.Part.BaseDrawing, out var definition)
|| CrossesBounds(definition, part.BoundingBox, bounds))
{
result.Add(cut.Part);
emitted[cut.Index] = true;
}
}
}
if (!emitted[i])
{
result.Add(part);
emitted[i] = true;
}
}
return result;
}
private static bool CrossesBounds(CutOff cutOff, Box part, Box plate)
{
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var position = vertical ? cutOff.Position.X : cutOff.Position.Y;
var acrossMin = vertical ? part.Left : part.Bottom;
var acrossMax = vertical ? part.Right : part.Top;
var alongMin = vertical ? part.Bottom : part.Left;
var alongMax = vertical ? part.Top : part.Right;
var start = cutOff.StartLimit ?? (vertical ? plate.Bottom : plate.Left);
var end = cutOff.EndLimit ?? (vertical ? plate.Top : plate.Right);
// Use the nominal line, not its trimmed cutting segments (which
// deliberately skip the parts). Bounds conservatively include edge
// contacts and concave recesses; limits prevent unrelated dependencies
// beyond the cutoff's span. Negative coordinates need no special case.
return !(position < acrossMin - Tolerance.Epsilon
|| position > acrossMax + Tolerance.Epsilon
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
}
}
}
@@ -0,0 +1,351 @@
using System.Reflection;
using OpenNest.Api;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
[CollectionDefinition("FrontEndRegistry", DisableParallelization = true)]
public class FrontEndRegistryCollection { }
[Collection("FrontEndRegistry")]
public class NestingPipelineTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-frontends-" + Guid.NewGuid());
public NestingPipelineTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
private static Drawing Square()
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
return new Drawing("square", OpenNest.Converters.ConvertGeometry.ToProgram(shape));
}
private static NestSession Session(bool occupied = false)
{
var session = new NestSession { Nest = new Nest("fixture") };
session.Nest.Drawings.Add(Square());
var plate = session.Nest.CreatePlate();
plate.Size = new Size(20, 30);
plate.PartSpacing = 0.2;
plate.EdgeSpacing = new Spacing(0.5, 0.5, 0.5, 0.5);
plate.Quantity = 7;
if (occupied)
plate.Parts.Add(new Part(session.Nest.Drawings.Single()));
return session;
}
private static string Engine(Func<NestJob, CancellationToken, NestJobResult> solve)
{
var name = "FrontEnd-" + Guid.NewGuid();
NestingEngineRegistry.Register(name, "test", () => new Stub(solve));
return name;
}
private sealed class Stub(Func<NestJob, CancellationToken, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job, token);
}
private static NestJobResult Result(NestJob job, string mode)
{
var poses = mode switch
{
"overlap" => new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0), new NestJobPlacement(job.Parts[0].Id, 1, 1, 1, 0) },
"unknown" => new[] { new NestJobPlacement("ghost", 0, 1, 1, 0) },
"nan" => new[] { new NestJobPlacement(job.Parts[0].Id, 0, double.NaN, 1, 0) },
_ => new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) },
};
var sheets = mode == "empty" ? Array.Empty<NestJobPlateResult>()
: mode == "multi" ? new[] { new NestJobPlateResult(0, job.Plates[0], poses), new NestJobPlateResult(1, job.Plates[0], poses) }
: new[] { new NestJobPlateResult(0, job.Plates[0], poses) };
// Deliberately lying metadata: front ends must count actual poses.
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, sheets,
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 999, 0)),
new[] { new StockUsage(job.Plates[0].Id, 999, 0) });
}
private static int RunConsole(params string[] args)
{
var method = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
.GetMethod("Run", BindingFlags.Static | BindingFlags.Public)!;
return (int)method.Invoke(null, new object[] { args })!;
}
[Theory]
[InlineData("overlap", false, 2)]
[InlineData("overlap", true, 0)]
[InlineData("multi", true, 2)]
[InlineData("unknown", true, 2)]
[InlineData("nan", true, 2)]
public void Console_RefusesInvalidWithoutOverwritingUnlessFaithfullyKeepable(string mode, bool allow, int expected)
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var original = File.ReadAllBytes(input);
File.WriteAllText(output, "must survive rejection");
var engine = Engine((job, _) => Result(job, mode));
var args = new List<string> { input, "--autonest", "--engine", engine, "--quantity", "2", "--output", output };
if (allow) args.Add("--allow-invalid");
Assert.Equal(expected, RunConsole(args.ToArray()));
Assert.Equal(original, File.ReadAllBytes(input));
if (expected == 2)
Assert.Equal("must survive rejection", File.ReadAllText(output));
else
{
var nest = new NestReader(output).Read();
Assert.Equal(2, nest.Plates[0].Parts.Count);
Assert.Equal(1, nest.Plates[0].Quantity);
Assert.Equal(2, nest.Drawings.Single().Quantity.Nested);
}
}
[Fact]
public void Console_EmptyResultDoesNotOverwriteOutputOrOriginal()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var original = File.ReadAllBytes(input);
File.WriteAllText(output, "keep existing output");
var engine = Engine((job, _) => Result(job, "empty"));
Assert.Equal(2, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
Assert.Equal(original, File.ReadAllBytes(input));
Assert.Equal("keep existing output", File.ReadAllText(output));
}
[Fact]
public void Console_CancelledSolveDoesNotSave()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var engine = Engine((_, _) => throw new OperationCanceledException());
Assert.Equal(2, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
Assert.False(File.Exists(output));
}
[Fact]
public void Console_KeepPartsOnOccupiedSheetRejectsBeforeSolve()
{
var input = Path.Combine(directory, "input.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var original = File.ReadAllBytes(input);
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve occupied stock"));
Assert.Equal(2, RunConsole(input, "--autonest", "--keep-parts", "--engine", engine));
Assert.Equal(original, File.ReadAllBytes(input));
}
[Fact]
public void Console_ValidCommitPreservesSettingsAndUsesOnePhysicalSheet()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var engine = Engine((job, _) =>
{
Assert.Equal(1, Assert.Single(job.Plates).Quantity);
return Result(job, "valid");
});
Assert.Equal(0, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
var plate = Assert.Single(new NestReader(output).Read().Plates);
Assert.Single(plate.Parts);
Assert.Equal(1, plate.Quantity);
Assert.Equal(0.2, plate.PartSpacing);
Assert.Equal(new Spacing(0.5, 0.5, 0.5, 0.5), plate.EdgeSpacing);
}
[Theory]
[InlineData("overlap", false, false)]
[InlineData("overlap", true, true)]
[InlineData("multi", true, false)]
[InlineData("unknown", true, false)]
[InlineData("nan", true, false)]
[InlineData("valid", false, true)]
public void Mcp_OnlyCommitsFaithfulSingleSheet(string mode, bool allow, bool committed)
{
var session = Session();
var plate = session.GetPlate(0);
var engine = Engine((job, _) =>
{
Assert.Equal(1, Assert.Single(job.Plates).Quantity);
return Result(job, mode);
});
var tool = new NestingTools(session);
var result = CallMcp(tool, engine, allow);
if (committed)
{
Assert.NotEmpty(plate.Parts);
Assert.Equal(1, plate.Quantity);
Assert.All(plate.Parts, p => Assert.Same(session.Nest.Drawings.Single(), p.BaseDrawing));
Assert.Equal(plate.Parts.Count, session.Nest.Drawings.Single().Quantity.Nested);
}
else
{
Assert.Contains("Error", result);
Assert.Empty(plate.Parts);
Assert.Equal(7, plate.Quantity);
}
if (mode != "valid") Assert.Contains("Violation", result);
}
private static string CallMcp(NestingTools tool, string engine, bool allow = false, CancellationToken token = default)
{
// Reflection lets RED exercise the old implementation before optional parameters exist.
var method = typeof(NestingTools).GetMethod(nameof(NestingTools.AutoNestPlate))!;
var args = method.GetParameters().Select(p => p.Name switch
{
"plateIndex" => (object)0,
"drawingNames" => "square",
"quantities" => "2",
"engine" => engine,
"allow_invalid" => allow,
"cancellationToken" => token,
_ => p.DefaultValue,
}).ToArray();
try { return (string)method.Invoke(tool, args)!; }
catch (TargetInvocationException ex) when (ex.InnerException is OperationCanceledException)
{ throw ex.InnerException; }
}
[Fact]
public void Mcp_OccupiedSheetRejectsBeforeSolve()
{
var session = Session(true);
var original = session.GetPlate(0).Parts[0];
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve occupied stock"));
Assert.Contains("occupied", CallMcp(new NestingTools(session), engine));
Assert.Same(original, Assert.Single(session.GetPlate(0).Parts));
}
[Fact]
public void Mcp_CancellationDoesNotCommit()
{
var session = Session();
using var cts = new CancellationTokenSource();
var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); });
Assert.ThrowsAny<OperationCanceledException>(() => CallMcp(new NestingTools(session), engine, token: cts.Token));
Assert.Empty(session.GetPlate(0).Parts);
}
[Fact]
public void UnknownEngineDoesNotWriteOrCommit()
{
var session = Session();
Assert.Contains("unknown", CallMcp(new NestingTools(session), "missing-engine"), StringComparison.OrdinalIgnoreCase);
Assert.Empty(session.GetPlate(0).Parts);
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(session.Nest).Write(input));
Assert.Equal(1, RunConsole(input, "--autonest", "--engine", "missing-engine", "--output", output));
Assert.False(File.Exists(output));
}
private NestRequest Request(string engine)
{
var path = Path.Combine(directory, "square.dxf");
Dxf.ExportProgram(Square().Program, path);
var request = new NestRequest
{
Parts = [new NestRequestPart { Id = "external-id", DxfPath = path, Quantity = 2, AllowRotation = false }],
Plates = [new NestRequestPlate { Id = "stock", Size = new Size(20, 30), Quantity = 1 }],
};
typeof(NestRequest).GetProperty("Engine")?.SetValue(request, engine);
return request;
}
[Theory]
[InlineData("valid", 1, "Valid")]
[InlineData("empty", 0, "Valid")]
[InlineData("overlap", 2, "Invalid")]
[InlineData("unknown", 0, "Unrepresentable")]
[InlineData("nan", 0, "Unrepresentable")]
public async Task Api_PluginSelectionDerivesCountsPreservesIdsAndReportsValidation(string mode, int placed, string validation)
{
var called = false;
var engine = Engine((job, _) => { called = true; return Result(job, mode); });
var response = await NestRunner.RunAsync(Request(engine));
Assert.True(called);
var fulfillment = Assert.Single(response.Fulfillment);
Assert.Equal("external-id", fulfillment.PartId);
Assert.Equal(placed, fulfillment.Placed);
Assert.Equal(2 - placed, fulfillment.Unplaced);
Assert.Equal(placed == 2 ? NestJobStatus.Complete : NestJobStatus.Incomplete, response.Status);
Assert.Equal(validation, typeof(NestResponse).GetProperty("ValidationStatus")?.GetValue(response)?.ToString());
Assert.Equal(placed > 0 ? 1 : 0, Assert.Single(response.StockUsage).Used);
Assert.Equal(placed, response.Nest.Plates.Sum(p => p.Parts.Count));
Assert.Equal("external-id", Assert.Single(response.Nest.Drawings).Name);
var archive = Path.Combine(directory, "result.nestquote");
await response.SaveAsync(archive);
var loaded = await NestResponse.LoadAsync(archive);
Assert.Equal(validation, typeof(NestResponse).GetProperty("ValidationStatus")?.GetValue(loaded)?.ToString());
}
[Fact]
public async Task Mcp_SessionGateSerializesCallsBeforeCheckingOccupiedTarget()
{
var session = Session();
var tool = new NestingTools(session);
var engine = Engine((job, _) => Result(job, "valid"));
using var gate = new SessionToolGate();
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var first = gate.RunAsync(async token =>
{
entered.SetResult();
await release.Task;
return CallMcp(tool, engine, token: token);
}, CancellationToken.None).AsTask();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var second = gate.RunAsync(token => new ValueTask<string>(CallMcp(tool, engine, token: token)),
CancellationToken.None).AsTask();
try
{
Assert.False(second.IsCompleted);
}
finally
{
release.TrySetResult();
}
Assert.Contains("success", await first.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Contains("occupied", await second.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Single(session.GetPlate(0).Parts);
}
[Fact]
public void Console_CancelledOrdinaryFillDoesNotOverwriteOutput()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
File.WriteAllText(output, "existing output");
using var cts = new CancellationTokenSource();
cts.Cancel();
var run = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
.GetMethod("RunCore", BindingFlags.Static | BindingFlags.NonPublic)!;
var error = Assert.Throws<TargetInvocationException>(() => run.Invoke(null,
new object[] { new[] { input, "--quantity", "1", "--output", output }, cts.Token }));
Assert.IsAssignableFrom<OperationCanceledException>(error.InnerException);
Assert.Equal("existing output", File.ReadAllText(output));
}
[Fact]
public async Task Api_UnknownEngineAndCancellationPropagate()
{
await Assert.ThrowsAsync<NotSupportedException>(() => NestRunner.RunAsync(Request("missing-engine")));
using var cts = new CancellationTokenSource();
var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); });
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => NestRunner.RunAsync(Request(engine), token: cts.Token));
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Console/OpenNest.Console.csproj" />
<ProjectReference Include="../OpenNest.Mcp/OpenNest.Mcp.csproj" />
<ProjectReference Include="../OpenNest.Api/OpenNest.Api.csproj" />
</ItemGroup>
</Project>
+173 -90
View File
@@ -1,8 +1,13 @@
#nullable enable
using System; using System;
using System.Collections.Generic;
using ILGPU; using ILGPU;
using ILGPU.Algorithms; using ILGPU.Algorithms;
using ILGPU.Runtime; using ILGPU.Runtime;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Gpu namespace OpenNest.Gpu
{ {
@@ -19,7 +24,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
ArrayView1D<double, Stride1D.Dense>, // movingPrep ArrayView1D<double, Stride1D.Dense>, // movingPrep
ArrayView1D<double, Stride1D.Dense>, // offsets ArrayView1D<double, Stride1D.Dense>, // offsets
ArrayView1D<double, Stride1D.Dense>, // results ArrayView1D<ContactWitness, Stride1D.Dense>, // results
int, int,
int, int,
int int
@@ -30,7 +35,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
ArrayView1D<double, Stride1D.Dense>, // movingPrep ArrayView1D<double, Stride1D.Dense>, // movingPrep
ArrayView1D<double, Stride1D.Dense>, // offsets ArrayView1D<double, Stride1D.Dense>, // offsets
ArrayView1D<double, Stride1D.Dense>, // results ArrayView1D<ContactWitness, Stride1D.Dense>, // results
ArrayView1D<int, Stride1D.Dense>, // directions ArrayView1D<int, Stride1D.Dense>, // directions
int, int,
int int
@@ -47,22 +52,24 @@ namespace OpenNest.Gpu
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw; private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryPrep; private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryPrep;
private double[]? _lastStationaryData; // Keep CPU copy/ref for content check private double[]? _lastStationaryData; // Active segment snapshot used for upload and contact topology
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingRaw; private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingRaw;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingPrep; private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingPrep;
private double[]? _lastMovingData; // Keep CPU copy/ref for content check private double[]? _lastMovingData; // Active segment snapshot used for upload and contact topology
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuOffsets; private MemoryBuffer1D<double, Stride1D.Dense>? _gpuOffsets;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuResults; private MemoryBuffer1D<ContactWitness, Stride1D.Dense>? _gpuResults;
private MemoryBuffer1D<int, Stride1D.Dense>? _gpuDirs; private MemoryBuffer1D<int, Stride1D.Dense>? _gpuDirs;
private int _offsetCapacity; private int _offsetCapacity;
public GpuSlideComputer() public GpuSlideComputer() : this(preferCPU: false) { }
public GpuSlideComputer(bool preferCPU)
{ {
_context = Context.CreateDefault(); _context = Context.CreateDefault();
_accelerator = _context _accelerator = _context
.GetPreferredDevice(preferCPU: false) .GetPreferredDevice(preferCPU)
.CreateAccelerator(_context); .CreateAccelerator(_context);
_kernel = _accelerator.LoadAutoGroupedStreamKernel< _kernel = _accelerator.LoadAutoGroupedStreamKernel<
@@ -70,7 +77,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<ContactWitness, Stride1D.Dense>,
int, int,
int, int,
int int
@@ -81,7 +88,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<ContactWitness, Stride1D.Dense>,
ArrayView1D<int, Stride1D.Dense>, ArrayView1D<int, Stride1D.Dense>,
int, int,
int int
@@ -118,21 +125,24 @@ namespace OpenNest.Gpu
EnsureMoving(movingTemplateSegments, movingCount); EnsureMoving(movingTemplateSegments, movingCount);
EnsureOffsetBuffers(offsetCount); EnsureOffsetBuffers(offsetCount);
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets); _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(
_accelerator.DefaultStream, (ReadOnlySpan<double>)offsets.AsSpan(0, offsetCount * 2));
_kernel( _kernel(
offsetCount, offsetCount,
_gpuStationaryPrep!.View, _gpuStationaryPrep!.View,
_gpuMovingPrep!.View, _gpuMovingPrep!.View,
_gpuOffsets.View, _gpuOffsets.View.SubView(0, offsetCount * 2),
_gpuResults!.View, _gpuResults!.View.SubView(0, offsetCount),
stationaryCount, stationaryCount,
movingCount, movingCount,
(int)direction (int)direction
); );
_accelerator.Synchronize(); _accelerator.Synchronize();
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results); var witnesses = new ContactWitness[offsetCount];
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(witnesses);
ResolveContacts(witnesses, offsets, results, direction, null);
} }
return results; return results;
@@ -161,93 +171,138 @@ namespace OpenNest.Gpu
EnsureMoving(movingTemplateSegments, movingCount); EnsureMoving(movingTemplateSegments, movingCount);
EnsureOffsetBuffers(offsetCount); EnsureOffsetBuffers(offsetCount);
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets); _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(directions); _accelerator.DefaultStream, (ReadOnlySpan<double>)offsets.AsSpan(0, offsetCount * 2));
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(
_accelerator.DefaultStream, (ReadOnlySpan<int>)directions.AsSpan(0, offsetCount));
_kernelMultiDir( _kernelMultiDir(
offsetCount, offsetCount,
_gpuStationaryPrep!.View, _gpuStationaryPrep!.View,
_gpuMovingPrep!.View, _gpuMovingPrep!.View,
_gpuOffsets.View, _gpuOffsets.View.SubView(0, offsetCount * 2),
_gpuResults!.View, _gpuResults!.View.SubView(0, offsetCount),
_gpuDirs.View, _gpuDirs.View.SubView(0, offsetCount),
stationaryCount, stationaryCount,
movingCount movingCount
); );
_accelerator.Synchronize(); _accelerator.Synchronize();
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results); var witnesses = new ContactWitness[offsetCount];
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(witnesses);
ResolveContacts(witnesses, offsets, results, default, directions);
} }
return results; return results;
} }
public void InvalidateStationary() => _lastStationaryData = null; public void InvalidateStationary()
{
lock (_lock)
_lastStationaryData = null;
}
public void InvalidateMoving() => _lastMovingData = null; public void InvalidateMoving()
{
lock (_lock)
_lastMovingData = null;
}
private void ResolveContacts(
ContactWitness[] witnesses,
double[] offsets,
double[] results,
PushDirection direction,
int[]? directions
)
{
var moving = default(List<Line>);
var stationary = default(List<Line>);
var contacts = default(SlideContactClassifier);
for (var i = 0; i < witnesses.Length; i++)
{
var witness = witnesses[i];
results[i] = witness.Distance;
if (witness.Distance == double.MaxValue)
continue;
// GPU finds the nearest event. Prepare the complete boundaries only
// once per batch, and share their material-side topology at each offset.
if (contacts == null)
{
moving = ToLines(_lastMovingData!);
stationary = ToLines(_lastStationaryData!);
contacts = SlideContactClassifier.FromLines(
moving, Vector.Zero, stationary, Vector.Zero).Prepare();
}
var offset = new Vector(offsets[i * 2], offsets[i * 2 + 1]);
var push = directions == null ? direction : (PushDirection)directions[i];
var unit = SpatialQuery.DirectionToOffset(push, 1);
var placed = contacts.At(offset, Vector.Zero);
if (placed.Blocks(
new Vector(witness.MovingX, witness.MovingY),
new Vector(witness.StationaryX, witness.StationaryY), unit.X, unit.Y))
continue;
// A departing or grazing event does not discard the obstacle: replay
// all events through the shared resolver to find the next blocking one,
// including another contact tied at the same distance.
results[i] = SpatialQuery.DirectionalDistance(
moving!, offset.X, offset.Y, stationary!, push, placed);
}
}
private static List<Line> ToLines(double[] segments)
{
var lines = new List<Line>(segments.Length / 4);
for (var i = 0; i < segments.Length; i += 4)
lines.Add(new Line(segments[i], segments[i + 1], segments[i + 2], segments[i + 3]));
return lines;
}
private void EnsureStationary(double[] data, int count) private void EnsureStationary(double[] data, int count)
{ {
// Fast check: if same object or content is identical, skip upload // Cache the active prefix by value: callers may reuse an array with a
if ( // different segment count or mutate its coordinates between batches.
_gpuStationaryPrep != null var active = data.AsSpan(0, count * 4);
&& _lastStationaryData != null if (_gpuStationaryPrep != null && _lastStationaryData != null
&& _lastStationaryData.Length == data.Length && active.SequenceEqual(_lastStationaryData))
)
{
// Reference equality or content equality
if (
_lastStationaryData == data
|| new ReadOnlySpan<double>(_lastStationaryData).SequenceEqual(
new ReadOnlySpan<double>(data)
)
)
{
return; return;
}
}
_gpuStationaryRaw?.Dispose(); _gpuStationaryRaw?.Dispose();
_gpuStationaryPrep?.Dispose(); _gpuStationaryPrep?.Dispose();
_gpuStationaryRaw = _accelerator.Allocate1D(data); var snapshot = active.ToArray();
_gpuStationaryRaw = _accelerator.Allocate1D(snapshot);
_gpuStationaryPrep = _accelerator.Allocate1D<double>(count * 10); _gpuStationaryPrep = _accelerator.Allocate1D<double>(count * 10);
_prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count); _prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count);
_accelerator.Synchronize(); _accelerator.Synchronize();
_lastStationaryData = data; // store reference for next comparison _lastStationaryData = snapshot;
} }
private void EnsureMoving(double[] data, int count) private void EnsureMoving(double[] data, int count)
{ {
if ( // Cache the active prefix by value: callers may reuse an array with a
_gpuMovingPrep != null // different segment count or mutate its coordinates between batches.
&& _lastMovingData != null var active = data.AsSpan(0, count * 4);
&& _lastMovingData.Length == data.Length if (_gpuMovingPrep != null && _lastMovingData != null
) && active.SequenceEqual(_lastMovingData))
{
if (
_lastMovingData == data
|| new ReadOnlySpan<double>(_lastMovingData).SequenceEqual(
new ReadOnlySpan<double>(data)
)
)
{
return; return;
}
}
_gpuMovingRaw?.Dispose(); _gpuMovingRaw?.Dispose();
_gpuMovingPrep?.Dispose(); _gpuMovingPrep?.Dispose();
_gpuMovingRaw = _accelerator.Allocate1D(data); var snapshot = active.ToArray();
_gpuMovingRaw = _accelerator.Allocate1D(snapshot);
_gpuMovingPrep = _accelerator.Allocate1D<double>(count * 10); _gpuMovingPrep = _accelerator.Allocate1D<double>(count * 10);
_prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count); _prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count);
_accelerator.Synchronize(); _accelerator.Synchronize();
_lastMovingData = data; _lastMovingData = snapshot;
} }
private void EnsureOffsetBuffers(int offsetCount) private void EnsureOffsetBuffers(int offsetCount)
@@ -262,7 +317,7 @@ namespace OpenNest.Gpu
_gpuDirs?.Dispose(); _gpuDirs?.Dispose();
_gpuOffsets = _accelerator.Allocate1D<double>(newCapacity * 2); _gpuOffsets = _accelerator.Allocate1D<double>(newCapacity * 2);
_gpuResults = _accelerator.Allocate1D<double>(newCapacity); _gpuResults = _accelerator.Allocate1D<ContactWitness>(newCapacity);
_gpuDirs = _accelerator.Allocate1D<int>(newCapacity); _gpuDirs = _accelerator.Allocate1D<int>(newCapacity);
_offsetCapacity = newCapacity; _offsetCapacity = newCapacity;
@@ -293,8 +348,8 @@ namespace OpenNest.Gpu
var dy = y2 - y1; var dy = y2 - y1;
// invD is used for parameter 't'. We use a small epsilon for stability. // invD is used for parameter 't'. We use a small epsilon for stability.
prepared[index * 10 + 4] = (XMath.Abs(dx) < 1e-9) ? 0 : 1.0 / dx; prepared[index * 10 + 4] = (XMath.Abs(dx) < Tolerance.Epsilon) ? 0 : 1.0 / dx;
prepared[index * 10 + 5] = (XMath.Abs(dy) < 1e-9) ? 0 : 1.0 / dy; prepared[index * 10 + 5] = (XMath.Abs(dy) < Tolerance.Epsilon) ? 0 : 1.0 / dy;
prepared[index * 10 + 6] = XMath.Min(x1, x2); prepared[index * 10 + 6] = XMath.Min(x1, x2);
prepared[index * 10 + 7] = XMath.Max(x1, x2); prepared[index * 10 + 7] = XMath.Max(x1, x2);
@@ -304,12 +359,48 @@ namespace OpenNest.Gpu
// ── Main Slide Kernels ─────────────────────────────────────── // ── Main Slide Kernels ───────────────────────────────────────
// Public because ILGPU's CPU backend emits kernel argument types in a separate assembly.
public struct ContactWitness
{
public double Distance;
public double MovingX;
public double MovingY;
public double StationaryX;
public double StationaryY;
}
private static void Consider(
ref ContactWitness nearest,
double distance,
double vx,
double vy,
int rayDirection,
bool vertexMoves
)
{
var snapped = distance > Tolerance.Epsilon ? distance : 0;
if (snapped >= nearest.Distance)
return;
// Use the unsnapped hit for incidence; snapping a tiny gap to zero must
// not move the witness off the other boundary.
var dirX = rayDirection == 2 ? -1 : rayDirection == 3 ? 1 : 0;
var dirY = rayDirection == 0 ? 1 : rayDirection == 1 ? -1 : 0;
var hx = vx + distance * dirX;
var hy = vy + distance * dirY;
nearest.Distance = snapped;
nearest.MovingX = vertexMoves ? vx : hx;
nearest.MovingY = vertexMoves ? vy : hy;
nearest.StationaryX = vertexMoves ? hx : vx;
nearest.StationaryY = vertexMoves ? hy : vy;
}
private static void SlideKernel( private static void SlideKernel(
Index1D index, Index1D index,
ArrayView1D<double, Stride1D.Dense> stationaryPrep, ArrayView1D<double, Stride1D.Dense> stationaryPrep,
ArrayView1D<double, Stride1D.Dense> movingPrep, ArrayView1D<double, Stride1D.Dense> movingPrep,
ArrayView1D<double, Stride1D.Dense> offsets, ArrayView1D<double, Stride1D.Dense> offsets,
ArrayView1D<double, Stride1D.Dense> results, ArrayView1D<ContactWitness, Stride1D.Dense> results,
int sCount, int sCount,
int mCount, int mCount,
int direction int direction
@@ -337,7 +428,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense> stationaryPrep, ArrayView1D<double, Stride1D.Dense> stationaryPrep,
ArrayView1D<double, Stride1D.Dense> movingPrep, ArrayView1D<double, Stride1D.Dense> movingPrep,
ArrayView1D<double, Stride1D.Dense> offsets, ArrayView1D<double, Stride1D.Dense> offsets,
ArrayView1D<double, Stride1D.Dense> results, ArrayView1D<ContactWitness, Stride1D.Dense> results,
ArrayView1D<int, Stride1D.Dense> directions, ArrayView1D<int, Stride1D.Dense> directions,
int sCount, int sCount,
int mCount int mCount
@@ -361,7 +452,7 @@ namespace OpenNest.Gpu
); );
} }
private static double ComputeSlideLean( private static ContactWitness ComputeSlideLean(
ArrayView1D<double, Stride1D.Dense> sPrep, ArrayView1D<double, Stride1D.Dense> sPrep,
ArrayView1D<double, Stride1D.Dense> mPrep, ArrayView1D<double, Stride1D.Dense> mPrep,
double dx, double dx,
@@ -371,20 +462,20 @@ namespace OpenNest.Gpu
int direction int direction
) )
{ {
const double eps = 0.00001; const double eps = Tolerance.Epsilon;
var minDist = double.MaxValue; var nearest = new ContactWitness { Distance = double.MaxValue };
var horizontal = direction >= 2; var horizontal = direction >= 2;
var oppDir = direction ^ 1; var oppDir = direction ^ 1;
// ── Forward Pass: moving vertices vs stationary edges ───── // ── Forward Pass: moving vertices vs stationary edges ─────
for (int i = 0; i < mCount; i++) for (var i = 0; i < mCount; i++)
{ {
var m1x = mPrep[i * 10 + 0] + dx; var m1x = mPrep[i * 10 + 0] + dx;
var m1y = mPrep[i * 10 + 1] + dy; var m1y = mPrep[i * 10 + 1] + dy;
var m2x = mPrep[i * 10 + 2] + dx; var m2x = mPrep[i * 10 + 2] + dx;
var m2y = mPrep[i * 10 + 3] + dy; var m2y = mPrep[i * 10 + 3] + dy;
for (int j = 0; j < sCount; j++) for (var j = 0; j < sCount; j++)
{ {
var sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6]; var sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6];
var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7]; var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7];
@@ -394,8 +485,8 @@ namespace OpenNest.Gpu
if (mv1 >= sMin - eps && mv1 <= sMax + eps) if (mv1 >= sMin - eps && mv1 <= sMax + eps)
{ {
var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps); var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps);
if (d < minDist) Consider(ref nearest, d, m1x, m1y,
minDist = d; direction, vertexMoves: true);
} }
// Test moving vertex 2 against stationary edge j // Test moving vertex 2 against stationary edge j
@@ -403,21 +494,21 @@ namespace OpenNest.Gpu
if (mv2 >= sMin - eps && mv2 <= sMax + eps) if (mv2 >= sMin - eps && mv2 <= sMax + eps)
{ {
var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps); var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps);
if (d < minDist) Consider(ref nearest, d, m2x, m2y,
minDist = d; direction, vertexMoves: true);
} }
} }
} }
// ── Reverse Pass: stationary vertices vs moving edges ───── // ── Reverse Pass: stationary vertices vs moving edges ─────
for (int i = 0; i < sCount; i++) for (var i = 0; i < sCount; i++)
{ {
var s1x = sPrep[i * 10 + 0]; var s1x = sPrep[i * 10 + 0];
var s1y = sPrep[i * 10 + 1]; var s1y = sPrep[i * 10 + 1];
var s2x = sPrep[i * 10 + 2]; var s2x = sPrep[i * 10 + 2];
var s2y = sPrep[i * 10 + 3]; var s2y = sPrep[i * 10 + 3];
for (int j = 0; j < mCount; j++) for (var j = 0; j < mCount; j++)
{ {
var mMin = horizontal ? (mPrep[j * 10 + 8] + dy) : (mPrep[j * 10 + 6] + dx); var mMin = horizontal ? (mPrep[j * 10 + 8] + dy) : (mPrep[j * 10 + 6] + dx);
var mMax = horizontal ? (mPrep[j * 10 + 9] + dy) : (mPrep[j * 10 + 7] + dx); var mMax = horizontal ? (mPrep[j * 10 + 9] + dy) : (mPrep[j * 10 + 7] + dx);
@@ -427,8 +518,8 @@ namespace OpenNest.Gpu
if (sv1 >= mMin - eps && sv1 <= mMax + eps) if (sv1 >= mMin - eps && sv1 <= mMax + eps)
{ {
var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps); var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps);
if (d < minDist) Consider(ref nearest, d, s1x, s1y,
minDist = d; oppDir, vertexMoves: false);
} }
// Test stationary vertex 2 against moving edge j // Test stationary vertex 2 against moving edge j
@@ -436,13 +527,13 @@ namespace OpenNest.Gpu
if (sv2 >= mMin - eps && sv2 <= mMax + eps) if (sv2 >= mMin - eps && sv2 <= mMax + eps)
{ {
var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps); var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps);
if (d < minDist) Consider(ref nearest, d, s2x, s2y,
minDist = d; oppDir, vertexMoves: false);
} }
} }
} }
return minDist; return nearest;
} }
private static double RayEdgeLean( private static double RayEdgeLean(
@@ -472,9 +563,7 @@ namespace OpenNest.Gpu
var ix = p1x + t * (p2x - p1x); var ix = p1x + t * (p2x - p1x);
var dist = (direction == 2) ? (vx - ix) : (ix - vx); var dist = (direction == 2) ? (vx - ix) : (ix - vx);
if (dist > eps) return dist >= -eps ? dist : double.MaxValue;
return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
} }
else // Vertical (Up=0, Down=1) else // Vertical (Up=0, Down=1)
{ {
@@ -489,9 +578,7 @@ namespace OpenNest.Gpu
var iy = p1y + t * (p2y - p1y); var iy = p1y + t * (p2y - p1y);
var dist = (direction == 1) ? (vy - iy) : (iy - vy); var dist = (direction == 1) ? (vy - iy) : (iy - vy);
if (dist > eps) return dist >= -eps ? dist : double.MaxValue;
return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
} }
} }
@@ -524,9 +611,7 @@ namespace OpenNest.Gpu
var ix = p1x + t * (p2x - p1x); var ix = p1x + t * (p2x - p1x);
var dist = (direction == 2) ? (vx - ix) : (ix - vx); var dist = (direction == 2) ? (vx - ix) : (ix - vx);
if (dist > eps) return dist >= -eps ? dist : double.MaxValue;
return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
} }
else // Vertical else // Vertical
{ {
@@ -541,9 +626,7 @@ namespace OpenNest.Gpu
var iy = p1y + t * (p2y - p1y); var iy = p1y + t * (p2y - p1y);
var dist = (direction == 1) ? (vy - iy) : (iy - vy); var dist = (direction == 1) ? (vy - iy) : (iy - vy);
if (dist > eps) return dist >= -eps ? dist : double.MaxValue;
return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
} }
} }
@@ -0,0 +1,408 @@
using System.Text.Json;
using OpenNest.CNC.CuttingStrategy;
using CuttingParametersSerializer = OpenNest.IO.CuttingParametersSerializer;
namespace OpenNest.IO.Tests;
public class CuttingParametersSerializerTests
{
[Theory]
[InlineData("None")]
[InlineData("Line")]
[InlineData("Arc")]
[InlineData("LineArc")]
[InlineData("CleanHole")]
[InlineData("LineLine")]
public void SettingsRoundTrip_PreservesEveryLeadInType(string type)
{
var leadIn = CreateLeadIn(type);
var original = new CuttingParameters
{
ExternalLeadIn = leadIn,
InternalLeadIn = leadIn,
ArcCircleLeadIn = leadIn,
TabConfig = new NormalTab { Size = 0.42, TabLeadIn = leadIn },
};
var restored = RoundTrip(original);
AssertEquivalent(leadIn, restored.ExternalLeadIn);
AssertEquivalent(leadIn, restored.InternalLeadIn);
AssertEquivalent(leadIn, restored.ArcCircleLeadIn);
AssertEquivalent(leadIn, restored.TabConfig.TabLeadIn);
}
[Theory]
[InlineData("None")]
[InlineData("Line")]
[InlineData("Arc")]
public void SettingsRoundTrip_PreservesEveryLeadOutType(string type)
{
var leadOut = CreateLeadOut(type);
var original = new CuttingParameters
{
ExternalLeadOut = leadOut,
InternalLeadOut = leadOut,
ArcCircleLeadOut = leadOut,
TabConfig = new NormalTab { Size = 0.42, TabLeadOut = leadOut },
};
var restored = RoundTrip(original);
AssertEquivalent(leadOut, restored.ExternalLeadOut);
AssertEquivalent(leadOut, restored.InternalLeadOut);
AssertEquivalent(leadOut, restored.ArcCircleLeadOut);
AssertEquivalent(leadOut, restored.TabConfig.TabLeadOut);
}
[Theory]
[InlineData("Normal")]
[InlineData("Machine")]
[InlineData("Breaker")]
public void SettingsRoundTrip_PreservesEveryTabType(string type)
{
var original = new CuttingParameters
{
TabsEnabled = true,
TabConfig = CreateTab(type),
};
AssertEquivalent(original, RoundTrip(original));
}
[Theory]
[InlineData(SequenceMethod.RightSide)]
[InlineData(SequenceMethod.LeastCode)]
[InlineData(SequenceMethod.Advanced)]
[InlineData(SequenceMethod.BottomSide)]
[InlineData(SequenceMethod.EdgeStart)]
[InlineData(SequenceMethod.LeftSide)]
[InlineData(SequenceMethod.RightSideAlt)]
public void SettingsRoundTrip_PreservesAllFieldsAndSequenceMethods(SequenceMethod method)
{
var original = CreateParameters();
original.Assignment.Method = method;
original.Sequencing.Method = method;
AssertEquivalent(original, RoundTrip(original));
}
[Fact]
public void Deserialize_LegacySettings_PreservesValuesAndUsesMissingFieldDefaults()
{
const string json = """
{
"externalLeadIn": { "type": "LineLine", "length1": 0.2, "angle1": 31,
"length2": 0.4, "angle2": 62 },
"externalLeadOut": { "type": "Line", "length": 0.5, "approachAngle": 43 },
"internalLeadIn": { "type": "Arc", "radius": 0.12 },
"internalLeadOut": { "type": "Arc", "radius": 0.09, "gapSize": 0 },
"arcCircleLeadIn": { "type": "CleanHole", "lineLength": 0.8,
"arcRadius": 0.3, "kerf": 0.04 },
"arcCircleLeadOut": { "type": "None" },
"tabsEnabled": true,
"tabWidth": 0.375,
"pierceClearance": 0.0625
}
""";
var restored = CuttingParametersSerializer.Deserialize(json);
var lineLine = Assert.IsType<LineLineLeadIn>(restored.ExternalLeadIn);
Assert.Equal(0.2, lineLine.Length1);
Assert.Equal(31, lineLine.ApproachAngle1);
Assert.Equal(0.4, lineLine.Length2);
Assert.Equal(62, lineLine.ApproachAngle2);
var lineOut = Assert.IsType<LineLeadOut>(restored.ExternalLeadOut);
Assert.Equal(0.5, lineOut.Length);
Assert.Equal(43, lineOut.ApproachAngle);
Assert.Equal(0.12, Assert.IsType<ArcLeadIn>(restored.InternalLeadIn).Radius);
Assert.Equal(0.09, Assert.IsType<ArcLeadOut>(restored.InternalLeadOut).Radius);
var cleanHole = Assert.IsType<CleanHoleLeadIn>(restored.ArcCircleLeadIn);
Assert.Equal(0.8, cleanHole.LineLength);
Assert.Equal(0.3, cleanHole.ArcRadius);
Assert.Equal(0.04, cleanHole.Kerf);
Assert.IsType<NoLeadOut>(restored.ArcCircleLeadOut);
Assert.True(restored.TabsEnabled);
Assert.Equal(0.375, Assert.IsType<NormalTab>(restored.TabConfig).Size);
Assert.Equal(0.0625, restored.PierceClearance);
Assert.False(restored.RoundLeadInAngles);
Assert.Equal(5, restored.LeadInAngleIncrement);
Assert.Equal(0, restored.AutoTabMinSize);
Assert.Equal(0, restored.AutoTabMaxSize);
AssertEquivalent(new AssignmentParameters(), restored.Assignment);
AssertEquivalent(new SequenceParameters(), restored.Sequencing);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Deserialize_LegacyNonpositiveAngleIncrement_UsesFiveDegrees(double increment)
{
var json = JsonSerializer.Serialize(new { leadInAngleIncrement = increment });
Assert.Equal(5, CuttingParametersSerializer.Deserialize(json).LeadInAngleIncrement);
}
[Fact]
public void Deserialize_EmptyObject_PreservesLegacyDefaults()
{
var restored = CuttingParametersSerializer.Deserialize("{}");
Assert.IsType<NoLeadIn>(restored.ExternalLeadIn);
Assert.IsType<NoLeadIn>(restored.InternalLeadIn);
Assert.IsType<NoLeadIn>(restored.ArcCircleLeadIn);
Assert.IsType<NoLeadOut>(restored.ExternalLeadOut);
Assert.IsType<NoLeadOut>(restored.InternalLeadOut);
Assert.IsType<NoLeadOut>(restored.ArcCircleLeadOut);
Assert.Equal(0, Assert.IsType<NormalTab>(restored.TabConfig).Size);
Assert.Equal(0, restored.PierceClearance);
Assert.Equal(5, restored.LeadInAngleIncrement);
}
[Fact]
public void Deserialize_JsonNull_ReturnsDomainDefaults()
{
AssertEquivalent(new CuttingParameters(), CuttingParametersSerializer.Deserialize("null"));
}
[Theory]
[InlineData("not-json")]
[InlineData("{\"externalLeadIn\":17}")]
public void Deserialize_MalformedSettings_ThrowsJsonException(string json)
{
Assert.Throws<JsonException>(() => CuttingParametersSerializer.Deserialize(json));
}
[Fact]
public void Deserialize_UnknownLeadTypes_UsesNoLead()
{
var restored = CuttingParametersSerializer.Deserialize("""
{ "externalLeadIn": { "type": "FutureLead" },
"externalLeadOut": { "type": "FutureLead" } }
""");
Assert.IsType<NoLeadIn>(restored.ExternalLeadIn);
Assert.IsType<NoLeadOut>(restored.ExternalLeadOut);
}
[Fact]
public void SettingsRoundTrip_NullTab_PreservesLegacyWidthFallback()
{
var original = new CuttingParameters { TabConfig = null };
var json = CuttingParametersSerializer.Serialize(original);
using var document = JsonDocument.Parse(json);
Assert.Equal(0.25, document.RootElement.GetProperty("tabWidth").GetDouble());
Assert.DoesNotContain('\n', json);
Assert.Equal(0.25, Assert.IsType<NormalTab>(
CuttingParametersSerializer.Deserialize(json).TabConfig).Size);
}
[Theory]
[InlineData("Normal")]
[InlineData("Machine")]
[InlineData("Breaker")]
public void DtoRoundTrip_UsesNestJsonOptionsAndPreservesAllFields(string tabType)
{
var original = CreateParameters();
original.TabConfig = CreateTab(tabType);
original.LeadInAngleIncrement = 0;
var dto = CuttingParametersSerializer.ToDto(original);
var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
var parsed = JsonSerializer.Deserialize<CuttingParametersDto>(json, NestFormat.JsonOptions);
var restored = CuttingParametersSerializer.FromDto(parsed);
AssertEquivalent(original, restored);
}
[Fact]
public void DtoRoundTrip_NullParameters_RemainNull()
{
Assert.Null(CuttingParametersSerializer.ToDto(null));
Assert.Null(CuttingParametersSerializer.FromDto(null));
}
[Fact]
public void DtoRoundTrip_DefaultParameters_PreservesNullTab()
{
var original = new CuttingParameters();
var dto = CuttingParametersSerializer.ToDto(original);
var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
var parsed = JsonSerializer.Deserialize<CuttingParametersDto>(json, NestFormat.JsonOptions);
AssertEquivalent(original, CuttingParametersSerializer.FromDto(parsed));
}
[Fact]
public void DtoRoundTrip_NullAssignmentAndSequencing_RemainNull()
{
var original = new CuttingParameters { Assignment = null, Sequencing = null };
var dto = CuttingParametersSerializer.ToDto(original);
var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
var parsed = JsonSerializer.Deserialize<CuttingParametersDto>(json, NestFormat.JsonOptions);
AssertEquivalent(original, CuttingParametersSerializer.FromDto(parsed));
}
[Fact]
public void DtoMapping_SourceSnapshotAndRestoredParameters_AreIndependent()
{
var original = CreateParameters();
var dto = CuttingParametersSerializer.ToDto(original);
var restored = CuttingParametersSerializer.FromDto(dto);
AssertEquivalent(original, restored);
original.Assignment.Preference = "changed source";
original.Sequencing.SmallCutoutWidth = 101;
((LineLineLeadIn)original.ExternalLeadIn).Length1 = 102;
((BreakerTab)original.TabConfig).BreakerDepth = 103;
((LineArcLeadIn)original.TabConfig.TabLeadIn).ArcRadius = 104;
((ArcLeadOut)original.TabConfig.TabLeadOut).Radius = 105;
AssertEquivalent(CreateParameters(), CuttingParametersSerializer.FromDto(dto));
dto.Assignment.Preference = "changed snapshot";
dto.Sequencing.SmallCutoutWidth = 201;
dto.ExternalLeadIn.Length1 = 202;
dto.TabConfig.BreakerDepth = 203;
dto.TabConfig.TabLeadIn.ArcRadius = 204;
dto.TabConfig.TabLeadOut.Radius = 205;
AssertEquivalent(CreateParameters(), restored);
}
[Fact]
public void Deserialize_UnknownTabType_DoesNotInventACutStrategy()
{
var restored = CuttingParametersSerializer.Deserialize("""
{ "tabConfig": { "type": "FutureTab" }, "tabWidth": 0.7 }
""");
Assert.Null(restored.TabConfig);
}
private static CuttingParameters RoundTrip(CuttingParameters original) =>
CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(original));
private static CuttingParameters CreateParameters() => new()
{
Id = 47,
MachineName = "Laser A",
MaterialName = "Steel",
Grade = "A36",
Thickness = 0.1875,
Kerf = 0.018,
PartSpacing = 0.23,
ExternalLeadIn = CreateLeadIn("LineLine"),
ExternalLeadOut = CreateLeadOut("Line"),
InternalLeadIn = CreateLeadIn("CleanHole"),
InternalLeadOut = CreateLeadOut("Arc"),
ArcCircleLeadIn = CreateLeadIn("LineArc"),
ArcCircleLeadOut = CreateLeadOut("None"),
PierceClearance = 0.17,
RoundLeadInAngles = true,
LeadInAngleIncrement = 13,
AutoTabMinSize = 0.62,
AutoTabMaxSize = 4.7,
TabConfig = CreateTab("Breaker"),
TabsEnabled = true,
Assignment = new AssignmentParameters
{
Method = SequenceMethod.EdgeStart,
Preference = "TAIL",
MinGeometryLength = 0.37,
},
Sequencing = new SequenceParameters
{
Method = SequenceMethod.LeftSide,
SmallCutoutWidth = 2.3,
SmallCutoutHeight = 3.4,
MediumCutoutWidth = 9.5,
MediumCutoutHeight = 10.6,
DistanceMediumSmall = 1.7,
AlternateRowsColumns = false,
AlternateCutoutsWithinRowColumn = false,
MinDistanceBetweenRowsColumns = 0.8,
},
};
private static LeadIn CreateLeadIn(string type) => type switch
{
"Line" => new LineLeadIn { Length = 0.37, ApproachAngle = 47 },
"Arc" => new ArcLeadIn { Radius = 0.53 },
"LineArc" => new LineArcLeadIn
{
LineLength = 0.43,
ArcRadius = 0.29,
ApproachAngle = 118,
},
"CleanHole" => new CleanHoleLeadIn { LineLength = 0.38, ArcRadius = 0.16, Kerf = 0.021 },
"LineLine" => new LineLineLeadIn
{
Length1 = 0.24,
ApproachAngle1 = 72,
Length2 = 0.48,
ApproachAngle2 = 36,
},
"None" => new NoLeadIn(),
_ => throw new ArgumentOutOfRangeException(nameof(type)),
};
private static LeadOut CreateLeadOut(string type) => type switch
{
"Line" => new LineLeadOut { Length = 0.21, ApproachAngle = 58 },
"Arc" => new ArcLeadOut { Radius = 0.19 },
"None" => new NoLeadOut(),
_ => throw new ArgumentOutOfRangeException(nameof(type)),
};
private static Tab CreateTab(string type)
{
var tab = type switch
{
"Normal" => (Tab)new NormalTab
{
CutoutMinWidth = 0.31,
CutoutMinHeight = 0.52,
CutoutMaxWidth = 3.7,
CutoutMaxHeight = 4.6,
},
"Machine" => new MachineTab { MachineTabId = 29 },
"Breaker" => new BreakerTab
{
BreakerDepth = 0.013,
BreakerLeadInLength = 0.09,
BreakerAngle = 26,
},
_ => throw new ArgumentOutOfRangeException(nameof(type)),
};
tab.Size = 0.41;
tab.TabLeadIn = CreateLeadIn("LineArc");
tab.TabLeadOut = CreateLeadOut("Arc");
return tab;
}
// Compare concrete runtime properties, including nested subtype fields, rather than
// using the serializer under test as the equality oracle. All model objects must be owned.
private static void AssertEquivalent(object? expected, object? actual)
{
if (expected == null)
{
Assert.Null(actual);
return;
}
Assert.NotNull(actual);
var type = expected.GetType();
Assert.Equal(type, actual.GetType());
if (type.IsValueType || expected is string)
{
Assert.Equal(expected, actual);
return;
}
Assert.NotSame(expected, actual);
foreach (var property in type.GetProperties())
AssertEquivalent(property.GetValue(expected), property.GetValue(actual));
}
}
+97
View File
@@ -0,0 +1,97 @@
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.IO;
/// <summary>
/// JSON-safe cutting parameters shared by nest files and desktop settings.
/// Lead and tab discriminators avoid serializing abstract domain types.
/// </summary>
public class CuttingParametersDto
{
public int Id { get; set; }
public string MachineName { get; set; }
public string MaterialName { get; set; }
public string Grade { get; set; }
public double Thickness { get; set; }
public double Kerf { get; set; }
public double PartSpacing { get; set; }
public LeadInDto ExternalLeadIn { get; set; }
public LeadOutDto ExternalLeadOut { get; set; }
public LeadInDto InternalLeadIn { get; set; }
public LeadOutDto InternalLeadOut { get; set; }
public LeadInDto ArcCircleLeadIn { get; set; }
public LeadOutDto ArcCircleLeadOut { get; set; }
public bool TabsEnabled { get; set; }
// Legacy settings only stored tabWidth. A missing TabConfig uses that width;
// Type = "None" explicitly records a null tab in a nest parameter snapshot.
public double TabWidth { get; set; }
public TabDto TabConfig { get; set; }
public double PierceClearance { get; set; }
public bool RoundLeadInAngles { get; set; }
public double LeadInAngleIncrement { get; set; }
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
public SequenceDto Sequencing { get; set; } = new();
public AssignmentDto Assignment { get; set; } = new();
public class LeadInDto
{
public string Type { get; set; } = "None";
public double Length { get; set; }
public double ApproachAngle { get; set; }
public double Radius { get; set; }
public double LineLength { get; set; }
public double ArcRadius { get; set; }
public double Kerf { get; set; }
public double Length1 { get; set; }
public double Angle1 { get; set; }
public double Length2 { get; set; }
public double Angle2 { get; set; }
}
public class LeadOutDto
{
public string Type { get; set; } = "None";
public double Length { get; set; }
public double ApproachAngle { get; set; }
public double Radius { get; set; }
public double GapSize { get; set; }
}
public class TabDto
{
public string Type { get; set; } = "None";
public double Size { get; set; }
public LeadInDto TabLeadIn { get; set; }
public LeadOutDto TabLeadOut { get; set; }
public double CutoutMinWidth { get; set; }
public double CutoutMinHeight { get; set; }
public double CutoutMaxWidth { get; set; }
public double CutoutMaxHeight { get; set; }
public int MachineTabId { get; set; }
public double BreakerDepth { get; set; }
public double BreakerLeadInLength { get; set; }
public double BreakerAngle { get; set; }
}
public class SequenceDto
{
public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
public double SmallCutoutWidth { get; set; } = 1.5;
public double SmallCutoutHeight { get; set; } = 1.5;
public double MediumCutoutWidth { get; set; } = 8.0;
public double MediumCutoutHeight { get; set; } = 8.0;
public double DistanceMediumSmall { get; set; }
public bool AlternateRowsColumns { get; set; } = true;
public bool AlternateCutoutsWithinRowColumn { get; set; } = true;
public double MinDistanceBetweenRowsColumns { get; set; } = 0.25;
}
public class AssignmentDto
{
public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
public string Preference { get; set; } = "ILAT";
public double MinGeometryLength { get; set; } = 0.01;
}
}
+291
View File
@@ -0,0 +1,291 @@
using System;
using System.Text.Json;
using OpenNest.CNC.CuttingStrategy;
using static OpenNest.IO.CuttingParametersDto;
namespace OpenNest.IO;
public static class CuttingParametersSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>Writes desktop settings, retaining the legacy null-tab width fallback.</summary>
public static string Serialize(CuttingParameters parameters)
{
ArgumentNullException.ThrowIfNull(parameters);
var dto = ToDto(parameters);
if (parameters.TabConfig == null)
dto.TabConfig = null;
return JsonSerializer.Serialize(dto, JsonOptions);
}
/// <summary>Reads desktop settings with their historical defaults and angle fallback.</summary>
public static CuttingParameters Deserialize(string json)
{
var dto = JsonSerializer.Deserialize<CuttingParametersDto>(json, JsonOptions);
var parameters = FromDto(dto) ?? new CuttingParameters();
if (parameters.LeadInAngleIncrement <= 0)
parameters.LeadInAngleIncrement = 5.0;
return parameters;
}
/// <summary>Captures an owned snapshot. Null stays null for absent plate parameters.</summary>
public static CuttingParametersDto ToDto(CuttingParameters parameters)
{
if (parameters == null)
return null;
return new CuttingParametersDto
{
Id = parameters.Id,
MachineName = parameters.MachineName,
MaterialName = parameters.MaterialName,
Grade = parameters.Grade,
Thickness = parameters.Thickness,
Kerf = parameters.Kerf,
PartSpacing = parameters.PartSpacing,
ExternalLeadIn = ToLeadInDto(parameters.ExternalLeadIn),
ExternalLeadOut = ToLeadOutDto(parameters.ExternalLeadOut),
InternalLeadIn = ToLeadInDto(parameters.InternalLeadIn),
InternalLeadOut = ToLeadOutDto(parameters.InternalLeadOut),
ArcCircleLeadIn = ToLeadInDto(parameters.ArcCircleLeadIn),
ArcCircleLeadOut = ToLeadOutDto(parameters.ArcCircleLeadOut),
TabsEnabled = parameters.TabsEnabled,
TabWidth = parameters.TabConfig?.Size ?? 0.25,
TabConfig = ToTabDto(parameters.TabConfig),
PierceClearance = parameters.PierceClearance,
RoundLeadInAngles = parameters.RoundLeadInAngles,
LeadInAngleIncrement = parameters.LeadInAngleIncrement,
AutoTabMinSize = parameters.AutoTabMinSize,
AutoTabMaxSize = parameters.AutoTabMaxSize,
Sequencing = ToSequenceDto(parameters.Sequencing),
Assignment = ToAssignmentDto(parameters.Assignment),
};
}
/// <summary>Restores an owned snapshot without the desktop settings' value normalization.</summary>
public static CuttingParameters FromDto(CuttingParametersDto dto)
{
if (dto == null)
return null;
return new CuttingParameters
{
Id = dto.Id,
MachineName = dto.MachineName,
MaterialName = dto.MaterialName,
Grade = dto.Grade,
Thickness = dto.Thickness,
Kerf = dto.Kerf,
PartSpacing = dto.PartSpacing,
ExternalLeadIn = FromLeadInDto(dto.ExternalLeadIn),
ExternalLeadOut = FromLeadOutDto(dto.ExternalLeadOut),
InternalLeadIn = FromLeadInDto(dto.InternalLeadIn),
InternalLeadOut = FromLeadOutDto(dto.InternalLeadOut),
ArcCircleLeadIn = FromLeadInDto(dto.ArcCircleLeadIn),
ArcCircleLeadOut = FromLeadOutDto(dto.ArcCircleLeadOut),
TabsEnabled = dto.TabsEnabled,
TabConfig = dto.TabConfig == null
? new NormalTab { Size = dto.TabWidth }
: FromTabDto(dto.TabConfig),
PierceClearance = dto.PierceClearance,
RoundLeadInAngles = dto.RoundLeadInAngles,
LeadInAngleIncrement = dto.LeadInAngleIncrement,
AutoTabMinSize = dto.AutoTabMinSize,
AutoTabMaxSize = dto.AutoTabMaxSize,
Sequencing = FromSequenceDto(dto.Sequencing),
Assignment = FromAssignmentDto(dto.Assignment),
};
}
private static LeadInDto ToLeadInDto(LeadIn leadIn) => leadIn switch
{
LineLeadIn line => new LeadInDto
{
Type = "Line",
Length = line.Length,
ApproachAngle = line.ApproachAngle,
},
ArcLeadIn arc => new LeadInDto { Type = "Arc", Radius = arc.Radius },
LineArcLeadIn lineArc => new LeadInDto
{
Type = "LineArc",
LineLength = lineArc.LineLength,
ArcRadius = lineArc.ArcRadius,
ApproachAngle = lineArc.ApproachAngle,
},
CleanHoleLeadIn cleanHole => new LeadInDto
{
Type = "CleanHole",
LineLength = cleanHole.LineLength,
ArcRadius = cleanHole.ArcRadius,
Kerf = cleanHole.Kerf,
},
LineLineLeadIn lineLine => new LeadInDto
{
Type = "LineLine",
Length1 = lineLine.Length1,
Angle1 = lineLine.ApproachAngle1,
Length2 = lineLine.Length2,
Angle2 = lineLine.ApproachAngle2,
},
_ => new LeadInDto { Type = "None" },
};
private static LeadIn FromLeadInDto(LeadInDto dto) => dto?.Type switch
{
"Line" => new LineLeadIn { Length = dto.Length, ApproachAngle = dto.ApproachAngle },
"Arc" => new ArcLeadIn { Radius = dto.Radius },
"LineArc" => new LineArcLeadIn
{
LineLength = dto.LineLength,
ArcRadius = dto.ArcRadius,
ApproachAngle = dto.ApproachAngle,
},
"CleanHole" => new CleanHoleLeadIn
{
LineLength = dto.LineLength,
ArcRadius = dto.ArcRadius,
Kerf = dto.Kerf,
},
"LineLine" => new LineLineLeadIn
{
Length1 = dto.Length1,
ApproachAngle1 = dto.Angle1,
Length2 = dto.Length2,
ApproachAngle2 = dto.Angle2,
},
_ => new NoLeadIn(),
};
private static LeadOutDto ToLeadOutDto(LeadOut leadOut) => leadOut switch
{
LineLeadOut line => new LeadOutDto
{
Type = "Line",
Length = line.Length,
ApproachAngle = line.ApproachAngle,
},
ArcLeadOut arc => new LeadOutDto { Type = "Arc", Radius = arc.Radius },
_ => new LeadOutDto { Type = "None" },
};
private static LeadOut FromLeadOutDto(LeadOutDto dto) => dto?.Type switch
{
"Line" => new LineLeadOut { Length = dto.Length, ApproachAngle = dto.ApproachAngle },
"Arc" => new ArcLeadOut { Radius = dto.Radius },
_ => new NoLeadOut(),
};
private static TabDto ToTabDto(Tab tab)
{
var dto = tab switch
{
NormalTab normal => new TabDto
{
Type = "Normal",
CutoutMinWidth = normal.CutoutMinWidth,
CutoutMinHeight = normal.CutoutMinHeight,
CutoutMaxWidth = normal.CutoutMaxWidth,
CutoutMaxHeight = normal.CutoutMaxHeight,
},
MachineTab machine => new TabDto { Type = "Machine", MachineTabId = machine.MachineTabId },
BreakerTab breaker => new TabDto
{
Type = "Breaker",
BreakerDepth = breaker.BreakerDepth,
BreakerLeadInLength = breaker.BreakerLeadInLength,
BreakerAngle = breaker.BreakerAngle,
},
_ => new TabDto { Type = "None" },
};
if (tab != null)
{
dto.Size = tab.Size;
dto.TabLeadIn = tab.TabLeadIn == null ? null : ToLeadInDto(tab.TabLeadIn);
dto.TabLeadOut = tab.TabLeadOut == null ? null : ToLeadOutDto(tab.TabLeadOut);
}
return dto;
}
private static Tab FromTabDto(TabDto dto)
{
var tab = dto.Type switch
{
"Normal" => (Tab)new NormalTab
{
CutoutMinWidth = dto.CutoutMinWidth,
CutoutMinHeight = dto.CutoutMinHeight,
CutoutMaxWidth = dto.CutoutMaxWidth,
CutoutMaxHeight = dto.CutoutMaxHeight,
},
"Machine" => new MachineTab { MachineTabId = dto.MachineTabId },
"Breaker" => new BreakerTab
{
BreakerDepth = dto.BreakerDepth,
BreakerLeadInLength = dto.BreakerLeadInLength,
BreakerAngle = dto.BreakerAngle,
},
_ => null,
};
if (tab != null)
{
tab.Size = dto.Size;
tab.TabLeadIn = dto.TabLeadIn == null ? null : FromLeadInDto(dto.TabLeadIn);
tab.TabLeadOut = dto.TabLeadOut == null ? null : FromLeadOutDto(dto.TabLeadOut);
}
return tab;
}
private static SequenceDto ToSequenceDto(SequenceParameters parameters) => parameters == null
? null
: new SequenceDto
{
Method = parameters.Method,
SmallCutoutWidth = parameters.SmallCutoutWidth,
SmallCutoutHeight = parameters.SmallCutoutHeight,
MediumCutoutWidth = parameters.MediumCutoutWidth,
MediumCutoutHeight = parameters.MediumCutoutHeight,
DistanceMediumSmall = parameters.DistanceMediumSmall,
AlternateRowsColumns = parameters.AlternateRowsColumns,
AlternateCutoutsWithinRowColumn = parameters.AlternateCutoutsWithinRowColumn,
MinDistanceBetweenRowsColumns = parameters.MinDistanceBetweenRowsColumns,
};
private static SequenceParameters FromSequenceDto(SequenceDto dto) => dto == null
? null
: new SequenceParameters
{
Method = dto.Method,
SmallCutoutWidth = dto.SmallCutoutWidth,
SmallCutoutHeight = dto.SmallCutoutHeight,
MediumCutoutWidth = dto.MediumCutoutWidth,
MediumCutoutHeight = dto.MediumCutoutHeight,
DistanceMediumSmall = dto.DistanceMediumSmall,
AlternateRowsColumns = dto.AlternateRowsColumns,
AlternateCutoutsWithinRowColumn = dto.AlternateCutoutsWithinRowColumn,
MinDistanceBetweenRowsColumns = dto.MinDistanceBetweenRowsColumns,
};
private static AssignmentDto ToAssignmentDto(AssignmentParameters parameters) => parameters == null
? null
: new AssignmentDto
{
Method = parameters.Method,
Preference = parameters.Preference,
MinGeometryLength = parameters.MinGeometryLength,
};
private static AssignmentParameters FromAssignmentDto(AssignmentDto dto) => dto == null
? null
: new AssignmentParameters
{
Method = dto.Method,
Preference = dto.Preference,
MinGeometryLength = dto.MinGeometryLength,
};
}
+9
View File
@@ -66,6 +66,7 @@ namespace OpenNest.IO
public double PartSpacing { get; init; } public double PartSpacing { get; init; }
public SpacingDto EdgeSpacing { get; init; } = new(); public SpacingDto EdgeSpacing { get; init; } = new();
public double GrainAngle { get; init; } public double GrainAngle { get; init; }
public CuttingParametersDto CuttingParameters { get; init; }
public List<PartDto> Parts { get; init; } = new(); public List<PartDto> Parts { get; init; } = new();
public List<CutOffDto> CutOffs { get; init; } = new(); public List<CutOffDto> CutOffs { get; init; } = new();
} }
@@ -78,6 +79,8 @@ namespace OpenNest.IO
public double Rotation { get; init; } public double Rotation { get; init; }
public bool HasManualLeadIns { get; init; } public bool HasManualLeadIns { get; init; }
public bool LeadInsLocked { get; init; } public bool LeadInsLocked { get; init; }
public string Program { get; init; }
public string DrawingHash { get; init; }
} }
public record CutOffDto public record CutOffDto
@@ -87,6 +90,12 @@ namespace OpenNest.IO
public string Axis { get; init; } = "vertical"; public string Axis { get; init; } = "vertical";
public double? StartLimit { get; init; } public double? StartLimit { get; init; }
public double? EndLimit { get; init; } public double? EndLimit { get; init; }
/// <summary>
/// Zero-based place in the plate's cut sequence (<c>Plate.Parts</c> order,
/// cut-offs included). Null in older files, which load it at the end.
/// </summary>
public int? Sequence { get; init; }
} }
public record SizeDto public record SizeDto
+69 -26
View File
@@ -18,6 +18,11 @@ namespace OpenNest.IO
{ {
private readonly Stream stream; private readonly Stream stream;
private readonly ZipArchive zipArchive; private readonly ZipArchive zipArchive;
private readonly Dictionary<int, string> drawingHashes = new();
private readonly List<string> warnings = new();
/// <summary>Part programs that could not be restored; other parts still load.</summary>
public IReadOnlyList<string> Warnings => warnings.AsReadOnly();
public NestReader(string file) public NestReader(string file)
{ {
@@ -63,33 +68,63 @@ namespace OpenNest.IO
var programs = new Dictionary<int, Program>(); var programs = new Dictionary<int, Program>();
for (var i = 1; i <= count; i++) for (var i = 1; i <= count; i++)
{ {
var entry = zipArchive.GetEntry($"programs/program-{i}"); var name = $"programs/program-{i}";
if (entry == null) if (zipArchive.GetEntry(name) == null)
continue; continue;
programs[i] = ReadProgram(name, out var hash);
using var entryStream = entry.Open(); drawingHashes[i] = hash;
var memStream = new MemoryStream();
entryStream.CopyTo(memStream);
memStream.Position = 0;
var reader = new ProgramReader(memStream);
programs[i] = reader.Read();
// Read sub-programs if present
var subsEntry = zipArchive.GetEntry($"programs/program-{i}-subs");
if (subsEntry != null)
{
using var subsStream = subsEntry.Open();
ReadSubPrograms(programs[i], subsStream);
}
} }
return programs; return programs;
} }
private Program ReadProgram(string name, out string hash)
{
var text = ReadEntry(name);
var subs = zipArchive.GetEntry(name + "-subs") == null ? "" : ReadEntry(name + "-subs");
hash = NestWriter.GetDrawingHash(text, subs);
using var programStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
var program = new ProgramReader(programStream).Read();
if (subs.Length > 0)
{
using var subsStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(subs));
ReadSubPrograms(program, subsStream);
}
return program;
}
private void RestorePartProgram(Part part, PartDto dto, int plateId, int partIndex)
{
// Old files had only transient flags. They still load clean and silently.
if (string.IsNullOrEmpty(dto.Program))
return;
// A missing hash cannot establish that this program belongs to the current drawing.
if (!drawingHashes.TryGetValue(dto.DrawingId, out var hash)
|| !string.Equals(dto.DrawingHash, hash, StringComparison.Ordinal))
return;
try
{
var program = ReadProgram(dto.Program, out _);
foreach (var call in program.Codes.OfType<SubProgramCall>())
if (call.Program == null || !call.Program.Codes.Any(c => c is Motion))
throw new InvalidDataException($"Missing or empty hole sub-program {call.Id}.");
if (!part.RestoreLeadInProgram(program, dto.LeadInsLocked))
throw new InvalidDataException("The saved part program has no motion.");
}
catch (Exception ex) when (ex is IOException || ex is InvalidDataException || ex is FormatException
|| ex is OverflowException || ex is ArgumentException || ex is InvalidOperationException)
{
warnings.Add($"Plate {plateId}, part {partIndex} ('{part.BaseDrawing.Name}'): "
+ $"could not restore '{dto.Program}'; loaded the clean drawing without lead-ins or tabs. {ex.Message}");
}
}
private static void ReadSubPrograms(Program parent, Stream stream) private static void ReadSubPrograms(Program parent, Stream stream)
{ {
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
var currentId = -1; var currentId = (int?)null;
var lines = new List<string>(); var lines = new List<string>();
string line; string line;
@@ -100,18 +135,18 @@ namespace OpenNest.IO
if (trimmed.StartsWith(":") && int.TryParse(trimmed.Substring(1), out var id)) if (trimmed.StartsWith(":") && int.TryParse(trimmed.Substring(1), out var id))
{ {
// Flush previous sub-program // Flush previous sub-program
if (currentId >= 0 && lines.Count > 0) if (currentId.HasValue && lines.Count > 0)
parent.SubPrograms[currentId] = ParseSubProgram(lines); parent.SubPrograms[currentId.Value] = ParseSubProgram(lines);
currentId = id; currentId = id;
lines.Clear(); lines.Clear();
} }
else if (trimmed == "M99") else if (trimmed == "M99")
{ {
if (currentId >= 0 && lines.Count > 0) if (currentId.HasValue && lines.Count > 0)
parent.SubPrograms[currentId] = ParseSubProgram(lines); parent.SubPrograms[currentId.Value] = ParseSubProgram(lines);
currentId = -1; currentId = null;
lines.Clear(); lines.Clear();
} }
else else
@@ -357,21 +392,26 @@ namespace OpenNest.IO
p.EdgeSpacing.Top p.EdgeSpacing.Top
); );
plate.GrainAngle = p.GrainAngle; plate.GrainAngle = p.GrainAngle;
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
foreach (var partDto in p.Parts) for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++)
{ {
var partDto = p.Parts[partIndex];
if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg)) if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg))
continue; continue;
var part = new Part(dwg); var part = new Part(dwg);
part.Rotate(partDto.Rotation); part.Rotate(partDto.Rotation);
part.Offset(new Vector(partDto.X, partDto.Y)); part.Offset(new Vector(partDto.X, partDto.Y));
RestorePartProgram(part, partDto, p.Id, partIndex);
plate.Parts.Add(part); plate.Parts.Add(part);
} }
// Cut-offs // Cut-offs
if (p.CutOffs != null) if (p.CutOffs != null)
{ {
var sequence = new Dictionary<CutOff, int>();
foreach (var cutoffDto in p.CutOffs) foreach (var cutoffDto in p.CutOffs)
{ {
var axis = var axis =
@@ -384,9 +424,12 @@ namespace OpenNest.IO
EndLimit = cutoffDto.EndLimit, EndLimit = cutoffDto.EndLimit,
}; };
plate.CutOffs.Add(cutoff); plate.CutOffs.Add(cutoff);
if (cutoffDto.Sequence is int index)
sequence[cutoff] = index;
} }
plate.RegenerateCutOffs(new CutOffSettings()); plate.RegenerateCutOffs(new CutOffSettings(), sequence);
} }
nest.Plates.Add(plate); nest.Plates.Add(plate);
+73 -44
View File
@@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using OpenNest.CNC; using OpenNest.CNC;
@@ -18,6 +20,8 @@ namespace OpenNest.IO
private readonly Nest nest; private readonly Nest nest;
private Dictionary<int, Drawing> drawingDict; private Dictionary<int, Drawing> drawingDict;
private readonly Dictionary<int, string> drawingHashes = new();
private readonly Dictionary<string, Program> partPrograms = new();
public NestWriter(Nest nest) public NestWriter(Nest nest)
{ {
@@ -38,8 +42,12 @@ namespace OpenNest.IO
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true); using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
WriteNestJson(zipArchive); drawingHashes.Clear();
partPrograms.Clear();
WritePrograms(zipArchive); WritePrograms(zipArchive);
WriteNestJson(zipArchive);
foreach (var entry in partPrograms)
WriteProgramEntry(zipArchive, entry.Key, entry.Value);
WriteEntities(zipArchive); WriteEntities(zipArchive);
WriteBestFits(zipArchive); WriteBestFits(zipArchive);
@@ -48,6 +56,7 @@ namespace OpenNest.IO
private void SetDrawingIds() private void SetDrawingIds()
{ {
drawingDict.Clear();
var id = 1; var id = 1;
foreach (var drawing in nest.Drawings) foreach (var drawing in nest.Drawings)
{ {
@@ -207,6 +216,11 @@ namespace OpenNest.IO
var match = drawingDict var match = drawingDict
.Where(dwg => dwg.Value == part.BaseDrawing) .Where(dwg => dwg.Value == part.BaseDrawing)
.FirstOrDefault(); .FirstOrDefault();
var programName = part.HasManualLeadIns
? $"parts/plate-{id}/part-{parts.Count}"
: null;
if (programName != null)
partPrograms.Add(programName, part.Program);
parts.Add( parts.Add(
new PartDto new PartDto
{ {
@@ -216,6 +230,8 @@ namespace OpenNest.IO
Rotation = part.Rotation, Rotation = part.Rotation,
HasManualLeadIns = part.HasManualLeadIns, HasManualLeadIns = part.HasManualLeadIns,
LeadInsLocked = part.LeadInsLocked, LeadInsLocked = part.LeadInsLocked,
Program = programName,
DrawingHash = programName == null ? null : drawingHashes[match.Key],
} }
); );
} }
@@ -223,6 +239,16 @@ namespace OpenNest.IO
var cutoffs = new List<CutOffDto>(); var cutoffs = new List<CutOffDto>();
foreach (var cutoff in plate.CutOffs) foreach (var cutoff in plate.CutOffs)
{ {
var sequence = -1;
for (var j = 0; j < plate.Parts.Count; j++)
{
if (ReferenceEquals(plate.Parts[j].BaseDrawing, cutoff.Drawing))
{
sequence = j;
break;
}
}
cutoffs.Add( cutoffs.Add(
new CutOffDto new CutOffDto
{ {
@@ -231,6 +257,7 @@ namespace OpenNest.IO
Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal", Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal",
StartLimit = cutoff.StartLimit, StartLimit = cutoff.StartLimit,
EndLimit = cutoff.EndLimit, EndLimit = cutoff.EndLimit,
Sequence = sequence >= 0 ? sequence : null,
} }
); );
} }
@@ -253,6 +280,7 @@ namespace OpenNest.IO
Parts = parts, Parts = parts,
CutOffs = cutoffs, CutOffs = cutoffs,
GrainAngle = plate.GrainAngle, GrainAngle = plate.GrainAngle,
CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters),
} }
); );
} }
@@ -331,43 +359,44 @@ namespace OpenNest.IO
private void WritePrograms(ZipArchive zipArchive) private void WritePrograms(ZipArchive zipArchive)
{ {
foreach (var kvp in drawingDict.OrderBy(k => k.Key)) foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{ drawingHashes[kvp.Key] = WriteProgramEntry(
var name = $"programs/program-{kvp.Key}"; zipArchive, $"programs/program-{kvp.Key}", kvp.Value.Program);
var stream = new MemoryStream(); }
WriteDrawing(stream, kvp.Value);
private static string WriteProgramEntry(ZipArchive zipArchive, string name, Program program)
{
var text = GetProgramText(program);
var subs = GetSubProgramsText(program);
WriteTextEntry(zipArchive, name, text);
if (subs.Length > 0)
WriteTextEntry(zipArchive, name + "-subs", subs);
return GetDrawingHash(text, subs);
}
private static void WriteTextEntry(ZipArchive zipArchive, string name, string text)
{
var entry = zipArchive.CreateEntry(name); var entry = zipArchive.CreateEntry(name);
using (var entryStream = entry.Open()) using var stream = entry.Open();
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
writer.Write(text);
}
// Hash the exact saved text, including holes: a change to either invalidates the part.
internal static string GetDrawingHash(string text, string subs) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
text.Length.ToString(CultureInfo.InvariantCulture) + ":" + text + subs)));
/// <summary>Serializes the hole programs using the same text as the nest archive.</summary>
public static string GetSubProgramsText(Program program)
{ {
stream.CopyTo(entryStream); using var writer = new StringWriter(CultureInfo.InvariantCulture) { NewLine = "\n" };
} foreach (var kvp in program.SubPrograms.OrderBy(k => k.Key))
// Write sub-programs if present
if (kvp.Value.Program.SubPrograms.Count > 0)
WriteSubPrograms(zipArchive, kvp.Key, kvp.Value.Program.SubPrograms);
}
}
private void WriteSubPrograms(
ZipArchive zipArchive,
int drawingId,
Dictionary<int, Program> subPrograms
)
{
var entry = zipArchive.CreateEntry($"programs/program-{drawingId}-subs");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
foreach (var kvp in subPrograms.OrderBy(k => k.Key))
{ {
writer.WriteLine($":{kvp.Key}"); writer.WriteLine($":{kvp.Key}");
writer.WriteLine(kvp.Value.Mode == Mode.Absolute ? "G90" : "G91"); WriteProgram(writer, kvp.Value);
foreach (var code in kvp.Value.Codes)
writer.WriteLine(GetCodeString(code));
writer.WriteLine("M99"); writer.WriteLine("M99");
} }
return writer.ToString();
} }
private void WriteEntities(ZipArchive zipArchive) private void WriteEntities(ZipArchive zipArchive)
@@ -391,11 +420,16 @@ namespace OpenNest.IO
} }
} }
private void WriteDrawing(Stream stream, Drawing drawing) /// <summary>Serializes a program in its current local frame, without transforming it.</summary>
public static string GetProgramText(Program program)
{
using var writer = new StringWriter(CultureInfo.InvariantCulture) { NewLine = "\n" };
WriteProgram(writer, program);
return writer.ToString();
}
private static void WriteProgram(TextWriter writer, Program program)
{ {
var program = drawing.Program;
var writer = new StreamWriter(stream);
writer.AutoFlush = true;
// Emit variable definitions before G-code // Emit variable definitions before G-code
foreach (var v in program.Variables.Values) foreach (var v in program.Variables.Values)
@@ -410,16 +444,11 @@ namespace OpenNest.IO
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91"); writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
for (var i = 0; i < drawing.Program.Length; ++i) foreach (var code in program.Codes)
{
var code = drawing.Program[i];
writer.WriteLine(GetCodeString(code)); writer.WriteLine(GetCodeString(code));
} }
stream.Position = 0; private static string FormatCoord(
}
private string FormatCoord(
double value, double value,
string axis, string axis,
Dictionary<string, string> variableRefs Dictionary<string, string> variableRefs
@@ -430,7 +459,7 @@ namespace OpenNest.IO
return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat); return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat);
} }
private string GetCodeString(ICode code) private static string GetCodeString(ICode code)
{ {
switch (code.Type) switch (code.Type)
{ {
@@ -534,7 +563,7 @@ namespace OpenNest.IO
return string.Empty; return string.Empty;
} }
private string GetLayerString(LayerType layer) private static string GetLayerString(LayerType layer)
{ {
switch (layer) switch (layer)
{ {
+7
View File
@@ -1,16 +1,23 @@
using System;
using System.IO;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using OpenNest.Engine.Jobs;
using OpenNest.Mcp; using OpenNest.Mcp;
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
// stdout carries the JSON-RPC stream; console logs must go to stderr or they corrupt it. // stdout carries the JSON-RPC stream; console logs must go to stderr or they corrupt it.
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
using var toolGate = new SessionToolGate();
builder.Services.AddSingleton<NestSession>(); builder.Services.AddSingleton<NestSession>();
builder builder
.Services.AddMcpServer() .Services.AddMcpServer()
.WithRequestFilters(filters => filters.AddCallToolFilter(
next => (context, token) => toolGate.RunAsync(ct => next(context, ct), token)))
.WithStdioServerTransport() .WithStdioServerTransport()
.WithToolsFromAssembly(typeof(Program).Assembly); .WithToolsFromAssembly(typeof(Program).Assembly);
+26
View File
@@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace OpenNest.Mcp;
/// <summary>Serializes tool operations sharing a mutable nesting session.</summary>
public sealed class SessionToolGate : IDisposable
{
private readonly SemaphoreSlim gate = new(1, 1);
public async ValueTask<T> RunAsync<T>(Func<CancellationToken, ValueTask<T>> action, CancellationToken token)
{
await gate.WaitAsync(token).ConfigureAwait(false);
try
{
return await action(token).ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
public void Dispose() => gate.Dispose();
}
+39 -56
View File
@@ -5,12 +5,11 @@ using System.Linq;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using OpenNest.Engine;
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement; using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Engine;
namespace OpenNest.Mcp.Tools namespace OpenNest.Mcp.Tools
{ {
@@ -251,91 +250,75 @@ namespace OpenNest.Mcp.Tools
} }
[McpServerTool(Name = "autonest_plate")] [McpServerTool(Name = "autonest_plate")]
[Description( [Description("Validated whole-job nesting onto an empty single sheet. Invalid results are discarded unless allow_invalid is explicitly true; unrepresentable or multiple-sheet results are always rejected.")]
"Mixed-part autonesting. Solves the drawings as one whole job against the plate using the named jobs engine and commits the resulting placements onto the plate."
)]
public string AutoNestPlate( public string AutoNestPlate(
[Description("Index of the plate")] int plateIndex, [Description("Index of the empty plate")] int plateIndex,
[Description("Comma-separated drawing names")] string drawingNames, [Description("Comma-separated drawing names")] string drawingNames,
[Description("Comma-separated quantities for each drawing")] string quantities, [Description("Comma-separated positive quantities")] string quantities,
[Description("Jobs engine name (Default, Strip, Vertical Remnant, Horizontal Remnant, StockLadder)")] [Description("Registered jobs engine name, including loaded plug-ins")] string engine = null,
string engine = null [Description("Explicitly keep representable layouts despite validation violations")] bool allow_invalid = false,
CancellationToken cancellationToken = default
) )
{ {
var plate = _session.GetPlate(plateIndex); var plate = _session.GetPlate(plateIndex);
if (plate == null) if (plate == null)
return $"Error: plate {plateIndex} not found"; return $"Error: plate {plateIndex} not found";
if (plate.Parts.Count > 0)
return "Error: autonest cannot use an occupied plate. Use fill_area or fill_remnants for existing obstacles.";
if (string.IsNullOrWhiteSpace(drawingNames)) if (string.IsNullOrWhiteSpace(drawingNames))
return "Error: drawingNames is required"; return "Error: drawingNames is required";
if (string.IsNullOrWhiteSpace(quantities)) if (string.IsNullOrWhiteSpace(quantities))
return "Error: quantities is required"; return "Error: quantities is required";
var engineName = string.IsNullOrWhiteSpace(engine) var engineName = string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim();
? _session.DefaultEngineName
: engine.Trim();
var parsed = ParseItems(drawingNames, quantities); var parsed = ParseItems(drawingNames, quantities);
if (parsed.error != null) if (parsed.error != null)
return parsed.error; return parsed.error;
if (parsed.items.Any(item => item.Quantity <= 0)) if (parsed.items.Any(item => item.Quantity <= 0))
return "Error: autonest quantities must be positive"; return "Error: autonest quantities must be positive";
INestingEngine nestingEngine; NestPipelineResult result;
try try
{ {
nestingEngine = NestingEngineRegistry.Create(engineName); result = NestPipeline.Run(new NestPipelineRequest(
engineName, parsed.items, NestStockBuilder.SinglePlate(plate)), token: cancellationToken);
} }
catch (NotSupportedException) catch (NotSupportedException)
{ {
return UnknownEngineMessage(engineName); return UnknownEngineMessage(engineName);
} }
var jobParts = new List<NestJobPart>(parsed.items.Count);
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
for (var i = 0; i < parsed.items.Count; i++)
{
var partId = $"part-{i}";
jobParts.Add(DrawingJobMapper.FromItem(partId, parsed.items[i]));
drawingsByPartId[partId] = parsed.items[i].Drawing;
}
// One physical sheet: this plate, this solve — the runner owns stock accounting.
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
var job = new NestJob(jobParts, [stock]);
var result = nestingEngine.Solve(job, null, CancellationToken.None);
var totalPlaced = 0;
foreach (var plateResult in result.Plates)
{
foreach (var pose in plateResult.Placements)
{
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
continue;
var part = new Part(drawing);
part.Rotate(pose.Rotation);
part.Location = new Vector(pose.X, pose.Y);
part.UpdateBounds();
plate.Parts.Add(part);
totalPlaced++;
}
}
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine( foreach (var violation in result.Violations)
$"AutoNest plate {plateIndex} ({engineName} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}" sb.AppendLine($"Violation: {violation}");
); if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allow_invalid))
{
sb.AppendLine(result.Plates.Count > 1
? "Error: multiple result sheets cannot be merged onto one target, even with allow_invalid. Nothing committed."
: !result.CanKeep
? "Error: result cannot be represented faithfully, even with allow_invalid. Nothing committed."
: "Error: invalid result discarded. Set allow_invalid to explicitly keep its violations. Nothing committed.");
return sb.ToString();
}
cancellationToken.ThrowIfCancellationRequested();
var proposed = result.Plates.SingleOrDefault();
var totalPlaced = proposed?.Parts.Count ?? 0;
if (totalPlaced > 0)
{
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);
}
sb.AppendLine($"AutoNest plate {plateIndex} ({engineName} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}");
sb.AppendLine($" Parts placed: {totalPlaced}"); sb.AppendLine($" Parts placed: {totalPlaced}");
sb.AppendLine($" Total parts: {plate.Parts.Count}"); sb.AppendLine($" Total parts: {plate.Parts.Count}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}");
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing.Name))
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
foreach (var group in groups)
sb.AppendLine($" {group.Key}: {group.Count()}"); sb.AppendLine($" {group.Key}: {group.Count()}");
return sb.ToString(); return sb.ToString();
} }
+27
View File
@@ -0,0 +1,27 @@
namespace OpenNest.Reporting;
internal static class AtomicReportFile
{
internal static void Write(string destination, Action<Stream> render)
{
ArgumentException.ThrowIfNullOrWhiteSpace(destination);
ArgumentNullException.ThrowIfNull(render);
var target = Path.GetFullPath(destination);
var temporary = Path.Combine(Path.GetDirectoryName(target)!,
"." + Path.GetFileName(target) + "." + Guid.NewGuid().ToString("N") + ".tmp");
try
{
using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
render(stream);
stream.Flush(flushToDisk: true);
}
// The sibling stays on the same filesystem. Never truncate/delete the target first.
File.Move(temporary, target, overwrite: true);
}
finally
{
File.Delete(temporary);
}
}
}
Binary file not shown.
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
DejaVu Sans 2.37 (regular and bold)
Source: https://dejavu-fonts.github.io/
Distributed unmodified from Ubuntu fonts-dejavu-core 2.37-8.
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
License: bitstream-vera
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
+440
View File
@@ -0,0 +1,440 @@
using System.Globalization;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using PdfSharp.Drawing;
using PdfSharp.Pdf;
namespace OpenNest.Reporting;
/// <summary>
/// Renders a detached <see cref="NestReportSnapshot"/> to PDF: MigraDoc owns text, tables and
/// pagination; PDFsharp draws vector thumbnails and the sheet diagram into reserved layout areas.
/// </summary>
public static class NestPdfWriter
{
// Slice 1 layout limits. Anything beyond them fails before the destination is touched.
private const double Margin = 36;
private const double BottomMargin = 50;
private const double ThumbnailWidth = 54;
private const double ThumbnailHeight = 36;
private const double DiagramHeight = 320;
private const double DiagramInset = 5;
private const double LabelFontSize = 7;
private const double LabelPadding = 1;
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
public static void Write(NestReportSnapshot snapshot, string destination) =>
Write(snapshot, destination, null);
/// <param name="wrapOutput">Test seam for injecting late write failures.</param>
internal static void Write(NestReportSnapshot snapshot, string destination, Func<Stream, Stream>? wrapOutput)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentException.ThrowIfNullOrWhiteSpace(destination);
ValidateText(snapshot);
if (snapshot.Plates.Length > 1)
throw new NotSupportedException($"A report with {snapshot.Plates.Length} plate layouts is not supported by this report slice; it supports at most one layout.");
ReportFonts.Initialize();
var document = new Document();
document.Info.Title = snapshot.Name;
var normal = document.Styles[StyleNames.Normal]
?? throw new InvalidOperationException("MigraDoc normal style is unavailable.");
normal.Font.Name = ReportFonts.Family;
normal.Font.Size = 9;
var diagrams = new List<Table>();
AddSummary(document, snapshot);
foreach (var plate in snapshot.Plates)
diagrams.Add(AddPlate(document, snapshot, plate));
var renderer = new PdfDocumentRenderer { Document = document };
renderer.RenderDocument();
using var pdf = renderer.PdfDocument;
// PDFsharp 6.2.4 disallows reading the page count after Save.
var pageCount = pdf.PageCount;
var areas = LocateDiagrams(renderer, pageCount, diagrams);
for (var index = 0; index < areas.Count; index++)
{
var (page, area) = areas[index];
if (page != index + 2)
throw new NotSupportedException("A summary longer than one page is not supported by this report slice.");
using var gfx = XGraphics.FromPdfPage(pdf.Pages[page - 1]);
DrawPlate(gfx, snapshot, snapshot.Plates[index], area);
}
if (pageCount != 1 + snapshot.Plates.Length)
throw new NotSupportedException(snapshot.Plates.Length == 0
? "A summary longer than one page is not supported by this report slice."
: "A plate page longer than one page is not supported by this report slice.");
pdf.Info.Title = snapshot.Name;
pdf.Info.Creator = "OpenNest";
pdf.Info.CreationDate = snapshot.GeneratedAt.UtcDateTime;
AtomicReportFile.Write(destination, stream => pdf.Save(wrapOutput?.Invoke(stream) ?? stream, false));
}
private static void ValidateText(NestReportSnapshot snapshot)
{
ReportFonts.ValidateText(snapshot.Name, "Nest name");
ReportFonts.ValidateText(snapshot.Customer, "Customer");
ReportFonts.ValidateText(snapshot.Notes, "Notes");
ReportFonts.ValidateText(snapshot.Material, "Material");
ReportFonts.ValidateText(snapshot.Grade, "Material grade");
foreach (var drawing in snapshot.Drawings)
ReportFonts.ValidateText(drawing.Name, $"Drawing {drawing.Id} name");
}
private static void AddSummary(Document document, NestReportSnapshot snapshot)
{
var section = AddSection(document, Orientation.Portrait);
var title = section.AddParagraph("Nest Report");
title.Format.Font.Size = 14;
title.Format.Font.Bold = true;
title.Format.SpaceAfter = Unit.FromPoint(6);
var info = AddTable(section, 130, 410);
info.Borders.Visible = false;
AddInfoRow(info, "Nest:", snapshot.Name);
if (snapshot.Customer.Length > 0)
AddInfoRow(info, "Customer:", snapshot.Customer);
if (snapshot.Notes.Length > 0)
AddInfoRow(info, "Notes:", snapshot.Notes);
AddInfoRow(info, "Material:", Material(snapshot));
AddInfoRow(info, "Thickness:", Thickness(snapshot));
AddInfoRow(info, "Units:", snapshot.Units == "mm" ? "millimeters (mm)" : "inches (in)");
AddInfoRow(info, "Generated:", snapshot.GeneratedAt.ToString("yyyy-MM-dd HH:mm zzz", Invariant));
AddInfoRow(info, "Distinct layouts:", snapshot.Plates.Length.ToString(Invariant));
AddInfoRow(info, "Total physical sheets:", snapshot.TotalSheets.ToString(Invariant));
AddHeading(section, "Plates");
if (snapshot.Plates.Length == 0)
section.AddParagraph("No plates in this job.");
else
{
var plates = AddTable(section, 50, 150, 70, 80, 80);
AddHeader(plates, "Plate", "Stock size", "Copies", "Parts/sheet", "Utilization");
foreach (var plate in snapshot.Plates)
AddRow(plates, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization));
}
AddHeading(section, "Parts");
if (snapshot.Drawings.Length == 0)
{
section.AddParagraph("No parts in this job.");
return;
}
var parts = AddTable(section, 36, 62, 170, 50, 50, 52, 40, 80);
AddHeader(parts, "ID", "Part", "Drawing", "Required", "Nested", "Shortage", "Extra", "Plates");
foreach (var drawing in snapshot.Drawings)
{
var row = AddRow(parts, drawing.Id, null, drawing.Name, drawing.Required.ToString(Invariant),
drawing.Nested.ToString(Invariant), drawing.Shortage.ToString(Invariant),
drawing.Extra.ToString(Invariant), drawing.Plates.Length == 0 ? "-" : string.Join(", ", drawing.Plates));
var image = row.Cells[1].AddParagraph().AddImage(Thumbnail(drawing.Geometry));
image.Width = Unit.FromPoint(ThumbnailWidth);
image.Height = Unit.FromPoint(ThumbnailHeight);
}
}
private static Table AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate)
{
var section = AddSection(document, Orientation.Landscape);
var title = section.AddParagraph($"Plate {plate.Number} of {snapshot.Plates.Length}");
title.Format.Font.Size = 13;
title.Format.Font.Bold = true;
var info = AddTable(section, 80, 280, 90, 270);
info.Borders.Visible = false;
AddInfoRow(info, "Nest:", snapshot.Name, "Sheet size:", SheetSize(snapshot, plate));
AddInfoRow(info, "Material:", Material(snapshot), "Copies:", plate.Copies.ToString(Invariant));
AddInfoRow(info, "Thickness:", Thickness(snapshot), "Part spacing:",
$"{Number(plate.PartSpacing)} {snapshot.Units}");
AddInfoRow(info, "Parts/sheet:", plate.Parts.Length.ToString(Invariant), "Utilization:",
Percent(plate.Utilization) + " (net part area / full sheet)");
// An empty fixed-height row reserves the vector diagram area in MigraDoc's flow.
var diagram = AddTable(section, 720);
diagram.Borders.Visible = false;
diagram.TopPadding = diagram.BottomPadding = Unit.Zero;
var reserved = diagram.AddRow();
reserved.HeightRule = RowHeightRule.Exactly;
reserved.Height = Unit.FromPoint(DiagramHeight);
diagram.Format.SpaceBefore = Unit.FromPoint(4);
var legend = section.AddParagraph("Filled outlines are closed parts; open material paths such as tab gaps are shown without fill. Dashed lines are scrap cutoffs.");
legend.Format.Font.Size = 7.5;
legend.Format.SpaceBefore = Unit.FromPoint(4);
var approval = section.AddParagraph("This report is not a geometry or CNC approval.");
approval.Format.Font.Size = 7.5;
approval.Format.SpaceAfter = Unit.FromPoint(4);
var names = snapshot.Drawings.ToDictionary(drawing => drawing.Id, drawing => drawing.Name, StringComparer.Ordinal);
var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal);
var parts = AddTable(section, 50, 330, 100, 140);
AddHeader(parts, "ID", "Drawing", "Qty/sheet", $"Total ({plate.Copies} copies)");
foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id)))
{
var count = perSheet[drawing.Id];
AddRow(parts, drawing.Id, names[drawing.Id], count.ToString(Invariant),
checked(count * plate.Copies).ToString(Invariant));
}
return diagram;
}
private static List<(int Page, XRect Area)> LocateDiagrams(PdfDocumentRenderer renderer, int pageCount, List<Table> diagrams)
{
var found = new (int Page, XRect Area)?[diagrams.Count];
for (var page = 1; page <= pageCount; page++)
{
var renderInfos = renderer.DocumentRenderer?.GetRenderInfoFromPage(page)
?? throw new InvalidOperationException("MigraDoc render information is unavailable.");
foreach (var info in renderInfos)
{
var index = diagrams.FindIndex(table => ReferenceEquals(table, info.DocumentObject));
if (index < 0)
continue;
if (found[index] != null)
throw new NotSupportedException($"A diagram for plate {index + 1} split across pages is not supported by this report slice.");
var area = info.LayoutInfo.ContentArea;
found[index] = (page, new XRect(area.X.Point, area.Y.Point, area.Width.Point, area.Height.Point));
}
}
return found.Select((item, index) => item
?? throw new NotSupportedException($"Plate {index + 1}: a missing diagram area is not supported by this report slice.")).ToList();
}
private static void DrawPlate(XGraphics gfx, NestReportSnapshot snapshot, ReportPlate plate, XRect area)
{
var inner = new XRect(area.X + DiagramInset, area.Y + DiagramInset,
area.Width - 2 * DiagramInset, DiagramHeight - 2 * DiagramInset);
var fit = Fit.Create(plate.Bounds, inner);
var font = new XFont(ReportFonts.Family, LabelFontSize, XFontStyleEx.Bold);
// Validate every label before drawing: dense-label callouts are a later slice.
var labels = new List<(string Id, XRect Box)>();
for (var index = 0; index < plate.Parts.Length; index++)
{
var part = plate.Parts[index];
var bounds = fit.Rect(part.Geometry.Bounds);
var size = gfx.MeasureString(part.ReportId, font);
var width = size.Width + 2 * LabelPadding;
var height = size.Height + 2 * LabelPadding;
if (width > bounds.Width || height > bounds.Height)
throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({part.ReportId}): a label that does not fit inside its part at {LabelFontSize} pt is not supported by this report slice.");
var box = new XRect(bounds.X + (bounds.Width - width) / 2, bounds.Y + (bounds.Height - height) / 2, width, height);
foreach (var other in labels)
{
if (box.IntersectsWith(other.Box))
throw new NotSupportedException($"Plate {plate.Number}: overlapping labels {other.Id} and {part.ReportId} are not supported by this report slice.");
}
labels.Add((part.ReportId, box));
}
gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds));
var outline = new XPen(XColors.Black, 0.6);
foreach (var part in plate.Parts)
DrawGeometry(gfx, part.Geometry, fit, outline);
var cutoff = new XPen(XColors.Black, 0.75) { DashStyle = XDashStyle.Dash };
foreach (var geometry in plate.Cutoffs)
StrokeContours(gfx, geometry, fit, cutoff);
foreach (var (id, box) in labels)
{
gfx.DrawRectangle(XBrushes.White, box);
gfx.DrawString(id, font, XBrushes.Black, box, XStringFormats.Center);
}
}
private static string Thumbnail(ReportGeometry geometry)
{
using var document = new PdfDocument();
var page = document.AddPage();
page.Width = XUnit.FromPoint(ThumbnailWidth);
page.Height = XUnit.FromPoint(ThumbnailHeight);
using (var gfx = XGraphics.FromPdfPage(page))
{
var fit = Fit.Create(geometry.Bounds, new XRect(2, 2, ThumbnailWidth - 4, ThumbnailHeight - 4));
DrawGeometry(gfx, geometry, fit, new XPen(XColors.Black, 0.5));
}
using var stream = new MemoryStream();
document.Save(stream, false);
// MigraDoc embeds a PDF image source as a vector form XObject, not a raster image.
return "base64:" + Convert.ToBase64String(stream.ToArray());
}
private static void DrawGeometry(XGraphics gfx, ReportGeometry geometry, Fit fit, XPen pen)
{
if (geometry.StrokeOnly)
{
StrokeContours(gfx, geometry, fit, pen);
return;
}
// Every contour is closed here, so CloseFigure separates the subpaths.
var path = new XGraphicsPath();
foreach (var contour in geometry.Contours)
{
AddContour(path, contour, fit);
path.CloseFigure();
}
path.FillMode = XFillMode.Alternate; // Even-odd: holes stay unfilled.
gfx.DrawPath(pen, new XSolidBrush(Fill), path);
}
/// <summary>
/// PDFsharp's StartFigure does not emit a move-to after an open figure, which would draw a
/// false segment across a tab gap to the next contour. Stroke each contour as its own path.
/// </summary>
private static void StrokeContours(XGraphics gfx, ReportGeometry geometry, Fit fit, XPen pen)
{
foreach (var contour in geometry.Contours)
{
var path = new XGraphicsPath();
AddContour(path, contour, fit);
if (contour.Closed)
path.CloseFigure();
gfx.DrawPath(pen, path);
}
}
private static void AddContour(XGraphicsPath path, ReportContour contour, Fit fit)
{
foreach (var segment in contour.Segments)
{
if (segment.Center == null)
{
path.AddLine(fit.Point(segment.Start), fit.Point(segment.End));
continue;
}
var center = fit.Point(segment.Center);
var radius = segment.Radius * fit.Scale;
var box = new XRect(center.X - radius, center.Y - radius, 2 * radius, 2 * radius);
// Page Y points down, so model angles and sweeps change sign.
path.AddArc(box, -Degrees(segment.StartAngle), -Degrees(segment.SweepAngle));
}
}
private static double Degrees(double radians) => radians * 180 / System.Math.PI;
private static Section AddSection(Document document, Orientation orientation)
{
var section = document.AddSection();
var setup = section.PageSetup;
setup.PageFormat = PageFormat.Letter;
setup.Orientation = orientation;
setup.TopMargin = setup.LeftMargin = setup.RightMargin = Unit.FromPoint(Margin);
setup.BottomMargin = Unit.FromPoint(BottomMargin);
setup.FooterDistance = Unit.FromPoint(20);
var footer = section.Footers.Primary.AddParagraph("Page ");
footer.AddPageField();
footer.AddText(" of ");
footer.AddNumPagesField();
footer.AddText(" | Diagram fitted to page; not a dimensioned cutting drawing.");
footer.Format.Font.Size = 7.5;
return section;
}
private static void AddHeading(Section section, string text)
{
var heading = section.AddParagraph(text);
heading.Format.Font.Bold = true;
heading.Format.Font.Size = 11;
heading.Format.SpaceBefore = Unit.FromPoint(10);
heading.Format.SpaceAfter = Unit.FromPoint(4);
}
private static Table AddTable(Section section, params double[] widths)
{
var table = section.AddTable();
table.Borders.Width = 0.5;
table.Borders.Color = Colors.Gray;
table.LeftPadding = table.RightPadding = Unit.FromPoint(3);
table.TopPadding = table.BottomPadding = Unit.FromPoint(1.5);
foreach (var width in widths)
table.AddColumn(Unit.FromPoint(width));
return table;
}
private static void AddHeader(Table table, params string[] cells)
{
var row = AddRow(table, cells);
row.HeadingFormat = true;
row.Format.Font.Bold = true;
row.Shading.Color = Colors.LightGray;
}
private static void AddInfoRow(Table table, params string[] cells)
{
var row = AddRow(table, cells);
for (var index = 0; index < cells.Length; index += 2)
row.Cells[index].Format.Font.Bold = true;
}
/// <summary>Adds text cells; a null cell is left empty for non-text content.</summary>
private static Row AddRow(Table table, params string?[] cells)
{
var row = table.AddRow();
row.VerticalAlignment = VerticalAlignment.Center;
for (var index = 0; index < cells.Length; index++)
{
if (cells[index] == null)
continue;
var paragraph = row.Cells[index].AddParagraph();
var lines = cells[index]!.Replace("\r\n", "\n").Replace('\r', '\n').Replace('\t', ' ').Split('\n');
for (var line = 0; line < lines.Length; line++)
{
if (line > 0)
paragraph.AddLineBreak();
paragraph.AddText(lines[line]);
}
}
return row;
}
private static string Material(NestReportSnapshot snapshot)
{
var material = $"{snapshot.Material} {snapshot.Grade}".Trim();
return material.Length == 0 ? "Not specified" : material;
}
// Zero thickness means "unset" in a new nest; never present it as a genuine value.
private static string Thickness(NestReportSnapshot snapshot) =>
snapshot.Thickness > 0 ? $"{Number(snapshot.Thickness)} {snapshot.Units}" : "Not specified";
private static string SheetSize(NestReportSnapshot snapshot, ReportPlate plate) =>
$"{Number(plate.Bounds.Length)} x {Number(plate.Bounds.Width)} {snapshot.Units}";
private static string Number(double value) => value.ToString("0.####", Invariant);
private static string Percent(double value) => (value * 100).ToString("0.0", Invariant) + "%";
/// <summary>One aspect-preserving model-to-page transform, centered, with the Y axis flipped.</summary>
private readonly record struct Fit(ReportBounds Bounds, double Scale, double X, double Y)
{
public static Fit Create(ReportBounds bounds, XRect area)
{
var scales = new List<double>(2);
if (bounds.Length > 0)
scales.Add(area.Width / bounds.Length);
if (bounds.Width > 0)
scales.Add(area.Height / bounds.Width);
var scale = scales.Count == 0 ? 1 : scales.Min();
if (!double.IsFinite(scale) || scale <= 0)
throw new InvalidOperationException("Report geometry cannot be fitted to the page.");
return new Fit(bounds, scale,
area.X + (area.Width - bounds.Length * scale) / 2,
area.Y + (area.Height - bounds.Width * scale) / 2);
}
public XPoint Point(ReportPoint point) =>
new(X + (point.X - Bounds.Left) * Scale, Y + (Bounds.Top - point.Y) * Scale);
public XRect Rect(ReportBounds box)
{
var low = Point(new ReportPoint(box.Left, box.Top));
return new XRect(low.X, low.Y, box.Length * Scale, box.Width * Scale);
}
}
}
+131
View File
@@ -0,0 +1,131 @@
using System.Collections.Immutable;
using System.Globalization;
using OpenNest.Geometry;
namespace OpenNest.Reporting;
public static class NestReportBuilder
{
/// <summary>
/// Capture synchronously while the caller keeps the nest stable. No quantity refresh,
/// domain cloning, or event-wired temporary plates are used.
/// </summary>
public static NestReportSnapshot Capture(Nest nest, DateTimeOffset generatedAt)
{
ArgumentNullException.ThrowIfNull(nest);
var ordered = new List<DrawingCount>();
var byReference = new Dictionary<Drawing, DrawingCount>(ReferenceEqualityComparer.Instance);
var plates = ImmutableArray.CreateBuilder<ReportPlate>();
DrawingCount Register(Drawing drawing)
{
if (byReference.TryGetValue(drawing, out var existing))
return existing;
var item = new DrawingCount(drawing, "R" + (ordered.Count + 1).ToString("D3", CultureInfo.InvariantCulture));
byReference.Add(drawing, item);
ordered.Add(item);
return item;
}
for (var index = 0; index < nest.Plates.Count; index++)
{
var number = index + 1;
var plate = nest.Plates[index];
if (plate == null)
throw new InvalidOperationException($"Plate {number}: missing plate.");
if (!PositiveFinite(plate.Size.Length) || !PositiveFinite(plate.Size.Width)
|| !PositiveFinite(plate.Area()))
throw new InvalidOperationException($"Plate {number}: sheet dimensions must be finite and positive.");
if (plate.Quantity <= 0)
throw new InvalidOperationException($"Plate {number}: copies must be a positive integer.");
if (!double.IsFinite(plate.PartSpacing) || plate.PartSpacing < 0)
throw new InvalidOperationException($"Plate {number}: part spacing must be finite and nonnegative.");
var box = plate.BoundingBox(false);
var bounds = new ReportBounds(box.Left, box.Bottom, box.Right, box.Top);
if (!double.IsFinite(bounds.Left) || !double.IsFinite(bounds.Bottom)
|| !double.IsFinite(bounds.Right) || !double.IsFinite(bounds.Top))
throw new InvalidOperationException($"Plate {number}: sheet bounds are not finite.");
var parts = ImmutableArray.CreateBuilder<ReportPlacement>();
var cutoffs = ImmutableArray.CreateBuilder<ReportGeometry>();
var perSheet = new Dictionary<DrawingCount, long>();
var area = 0.0;
for (var partIndex = 0; partIndex < plate.Parts.Count; partIndex++)
{
var part = plate.Parts[partIndex];
var context = $"Plate {number}, part {partIndex + 1} ('{part?.BaseDrawing?.Name}')";
if (part?.BaseDrawing == null)
throw new InvalidOperationException($"{context}: missing part or drawing reference.");
var geometry = NestReportGeometry.Capture(part.Program, part.Location, context);
if (part.BaseDrawing.IsCutOff)
{
cutoffs.Add(geometry);
continue;
}
var item = Register(part.BaseDrawing);
parts.Add(new ReportPlacement(item.Id, geometry));
perSheet.TryGetValue(item, out var count);
perSheet[item] = checked(count + 1);
var partArea = part.BaseDrawing.Area;
if (!double.IsFinite(partArea) || partArea < 0)
throw new InvalidOperationException($"{context}: net part area is invalid.");
area += partArea;
}
foreach (var (item, count) in perSheet)
{
item.Nested = checked(item.Nested + checked(count * plate.Quantity));
item.Plates.Add(number);
}
var utilization = area / plate.Area();
if (!double.IsFinite(utilization))
throw new InvalidOperationException($"Plate {number}: utilization is not finite.");
plates.Add(new ReportPlate(number, bounds, plate.Quantity, plate.PartSpacing,
utilization, parts.ToImmutable(), cutoffs.ToImmutable()));
}
// OrderBy is stable, so the original enumeration breaks equal name/path ties.
foreach (var drawing in nest.Drawings
.Where(drawing => !drawing.IsCutOff)
.OrderBy(drawing => drawing.Name ?? "", StringComparer.Ordinal)
.ThenBy(drawing => drawing.Source?.Path ?? "", StringComparer.Ordinal))
Register(drawing);
var drawings = ImmutableArray.CreateBuilder<ReportDrawing>();
foreach (var item in ordered)
{
var drawing = item.Drawing;
var context = $"Drawing {item.Id} ('{drawing.Name}')";
var required = (long)drawing.Quantity.Required;
if (required < 0)
throw new InvalidOperationException($"{context}: required quantity cannot be negative.");
var geometry = NestReportGeometry.Capture(drawing.Program, new Vector(), context);
drawings.Add(new ReportDrawing(item.Id, drawing.Name ?? "", required, item.Nested,
System.Math.Max(checked(required - item.Nested), 0),
System.Math.Max(checked(item.Nested - required), 0), item.Plates.ToImmutableArray(), geometry));
}
var units = nest.Units switch
{
Units.Inches => "in",
Units.Millimeters => "mm",
_ => throw new InvalidOperationException("Nest: unsupported units."),
};
if (!double.IsFinite(nest.Thickness) || nest.Thickness < 0)
throw new InvalidOperationException("Nest: thickness must be finite and nonnegative.");
return new NestReportSnapshot(nest.Name ?? "", nest.Customer ?? "", nest.Notes ?? "",
nest.Material?.Name ?? "", nest.Material?.Grade ?? "", nest.Thickness, units, generatedAt,
drawings.ToImmutable(), plates.ToImmutable());
}
private static bool PositiveFinite(double value) => double.IsFinite(value) && value > 0;
private sealed class DrawingCount(Drawing drawing, string id)
{
public Drawing Drawing { get; } = drawing;
public string Id { get; } = id;
public long Nested { get; set; }
public List<int> Plates { get; } = [];
}
}
+174
View File
@@ -0,0 +1,174 @@
using System.Collections.Immutable;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Reporting;
internal static class NestReportGeometry
{
internal static ReportGeometry Capture(Program? program, Vector location, string context)
{
ValidatePoint(location, context);
var current = new Vector();
// ConvertProgram recurses without cycle/null guards and silently skips unknown codes.
// Validate first; never clone through SubProgramCall's rotating setters.
ValidateProgram(program, ref current, new HashSet<Program>(ReferenceEqualityComparer.Instance), context);
var entities = ConvertProgram.ToGeometry(program!);
var contours = ImmutableArray.CreateBuilder<ReportContour>();
var segments = ImmutableArray.CreateBuilder<ReportSegment>();
var left = double.PositiveInfinity;
var bottom = double.PositiveInfinity;
var right = double.NegativeInfinity;
var top = double.NegativeInfinity;
void Flush()
{
if (segments.Count == 0)
return;
var closed = SamePoint(segments[0].Start, segments[^1].End);
contours.Add(new ReportContour(segments.ToImmutable(), closed));
segments.Clear();
}
foreach (var entity in entities)
{
if (!SpecialLayers.IsMaterial(entity.Layer)
|| entity.Layer == SpecialLayers.Leadin || entity.Layer == SpecialLayers.Leadout)
{
Flush();
continue;
}
ReportSegment segment;
switch (entity)
{
case Line line:
if (line.StartPoint.X == line.EndPoint.X && line.StartPoint.Y == line.EndPoint.Y)
continue;
segment = new ReportSegment(Point(line.StartPoint, location, context),
Point(line.EndPoint, location, context), null, 0, 0, 0);
break;
case Arc arc:
segment = new ReportSegment(Point(arc.StartPoint(), location, context),
Point(arc.EndPoint(), location, context), Point(arc.Center, location, context),
arc.Radius, arc.StartAngle, arc.SweepAngle() * (arc.IsReversed ? -1 : 1));
break;
case Circle circle:
Flush();
var start = Point(new Vector(circle.Center.X + circle.Radius, circle.Center.Y), location, context);
segment = new ReportSegment(start, start, Point(circle.Center, location, context),
circle.Radius, 0, circle.Rotation == RotationType.CW ? -2 * System.Math.PI : 2 * System.Math.PI);
break;
default:
throw Error(context, $"unsupported geometry '{entity.GetType().Name}'.");
}
if (segment.Center != null && (!double.IsFinite(segment.Radius) || segment.Radius <= 0
|| !double.IsFinite(segment.StartAngle) || !double.IsFinite(segment.SweepAngle)
|| segment.SweepAngle == 0 || System.Math.Abs(segment.SweepAngle) > 2 * System.Math.PI))
throw Error(context, "invalid arc/circle data.");
if (segments.Count > 0 && !SamePoint(segments[^1].End, segment.Start))
Flush();
segments.Add(segment);
if (SamePoint(segments[0].Start, segment.End))
Flush();
var box = entity.BoundingBox;
var low = Point(new Vector(box.Left, box.Bottom), location, context);
var high = Point(new Vector(box.Right, box.Top), location, context);
left = System.Math.Min(left, low.X);
bottom = System.Math.Min(bottom, low.Y);
right = System.Math.Max(right, high.X);
top = System.Math.Max(top, high.Y);
}
Flush();
if (contours.Count == 0)
throw Error(context, "missing drawable material geometry.");
if (!double.IsFinite(right - left) || !double.IsFinite(top - bottom))
throw Error(context, "geometry bounds are not finite.");
return new ReportGeometry(contours.ToImmutable(), new ReportBounds(left, bottom, right, top));
}
private static void ValidateProgram(Program? program, ref Vector current,
HashSet<Program> active, string context)
{
if (program?.Codes == null)
throw Error(context, "missing program or subprogram reference.");
if (!active.Add(program))
throw Error(context, "cyclic subprogram reference.");
if (active.Count > 256)
throw Error(context, "unsupported subprogram nesting depth (maximum 256).");
if (!double.IsFinite(program.Rotation) || !Enum.IsDefined(program.Mode))
throw Error(context, "invalid program transform or coordinate mode.");
var origin = current;
for (var index = 0; index < program.Codes.Count; index++)
{
var code = program.Codes[index];
var codeContext = $"{context}, code {index + 1}";
switch (code)
{
case SubProgramCall call when code.Type == CodeType.SubProgramCall:
ValidatePoint(call.Offset, codeContext);
if (!double.IsFinite(call.Rotation))
throw Error(codeContext, "invalid subprogram rotation.");
current = origin + call.Offset;
ValidatePoint(current, codeContext);
ValidateProgram(call.Program, ref current, active, $"{codeContext}, subprogram {call.Id}");
break;
case Motion motion when code is LinearMove && code.Type == CodeType.LinearMove
|| code is RapidMove && code.Type == CodeType.RapidMove
|| code is ArcMove && code.Type == CodeType.ArcMove:
ValidatePoint(motion.EndPoint, codeContext);
var end = program.Mode == Mode.Incremental ? current + motion.EndPoint : motion.EndPoint;
ValidatePoint(end, codeContext);
if (motion is LinearMove linear && !Enum.IsDefined(linear.Layer))
throw Error(codeContext, "unsupported material layer.");
if (motion is ArcMove arc)
{
ValidatePoint(arc.CenterPoint, codeContext);
if (!Enum.IsDefined(arc.Rotation) || !Enum.IsDefined(arc.Layer))
throw Error(codeContext, "invalid arc rotation or layer.");
var center = program.Mode == Mode.Incremental ? current + arc.CenterPoint : arc.CenterPoint;
ValidatePoint(center, codeContext);
var startRadius = current.DistanceTo(center);
var endRadius = end.DistanceTo(center);
if (!double.IsFinite(startRadius) || !double.IsFinite(endRadius)
|| startRadius <= 0 || endRadius <= 0
|| System.Math.Abs(startRadius - endRadius) > Tolerance.Epsilon)
throw Error(codeContext, "invalid arc/circle radius or inconsistent endpoints.");
}
current = end;
break;
case Comment when code.Type == CodeType.Comment:
case Feedrate when code.Type == CodeType.SetFeedrate:
case Kerf when code.Type == CodeType.SetKerf:
break;
default:
throw Error(codeContext, code == null ? "missing code." : $"unsupported code '{code.GetType().Name}'.");
}
}
active.Remove(program);
}
private static ReportPoint Point(Vector point, Vector location, string context)
{
var translated = point + location;
ValidatePoint(translated, context);
return new ReportPoint(translated.X, translated.Y);
}
private static void ValidatePoint(Vector point, string context)
{
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y))
throw Error(context, "nonfinite geometry coordinate.");
}
// Only absorb floating-point trig roundoff, never ShapeBuilder's welding tolerance.
// A deliberate tab/rapid interruption is never joined by contour construction.
private static bool SamePoint(ReportPoint first, ReportPoint second) =>
System.Math.Abs(first.X - second.X) <= 1e-12 && System.Math.Abs(first.Y - second.Y) <= 1e-12;
private static InvalidOperationException Error(string context, string message) => new($"{context}: {message}");
}
+38
View File
@@ -0,0 +1,38 @@
using System.Collections.Immutable;
namespace OpenNest.Reporting;
public sealed record ReportPoint(double X, double Y);
public sealed record ReportBounds(double Left, double Bottom, double Right, double Top)
{
public double Length => Right - Left;
public double Width => Top - Bottom;
}
/// <summary>Native line or arc; angles are radians and a null center denotes a line.</summary>
public sealed record ReportSegment(ReportPoint Start, ReportPoint End, ReportPoint? Center,
double Radius, double StartAngle, double SweepAngle);
public sealed record ReportContour(ImmutableArray<ReportSegment> Segments, bool Closed);
public sealed record ReportGeometry(ImmutableArray<ReportContour> Contours, ReportBounds Bounds)
{
public bool StrokeOnly => Contours.Any(contour => !contour.Closed);
}
public sealed record ReportDrawing(string Id, string Name, long Required, long Nested,
long Shortage, long Extra, ImmutableArray<int> Plates, ReportGeometry Geometry);
public sealed record ReportPlacement(string ReportId, ReportGeometry Geometry);
public sealed record ReportPlate(int Number, ReportBounds Bounds, int Copies, double PartSpacing,
double Utilization, ImmutableArray<ReportPlacement> Parts, ImmutableArray<ReportGeometry> Cutoffs);
/// <summary>A detached value snapshot. No domain objects or mutable geometry are retained.</summary>
public sealed record NestReportSnapshot(string Name, string Customer, string Notes, string Material,
string Grade, double Thickness, string Units, DateTimeOffset GeneratedAt,
ImmutableArray<ReportDrawing> Drawings, ImmutableArray<ReportPlate> Plates)
{
public long TotalSheets => Plates.Aggregate(0L, (total, plate) => checked(total + plate.Copies));
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
<EmbeddedResource Include="Fonts\*.ttf" />
<None Update="Fonts\LICENSE.txt;THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="ReportingNotices\%(Filename)%(Extension)" />
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
</Project>
+59
View File
@@ -0,0 +1,59 @@
using PdfSharp.Fonts;
namespace OpenNest.Reporting;
/// <summary>Process-wide, embedded fonts; never consult platform font directories.</summary>
internal static class ReportFonts
{
internal const string Family = "OpenNest Report Sans";
private static readonly object Sync = new();
private static readonly BundledResolver Resolver = new();
internal static void Initialize()
{
lock (Sync)
{
if (GlobalFontSettings.FontResolver == null)
GlobalFontSettings.FontResolver = Resolver;
else if (!ReferenceEquals(GlobalFontSettings.FontResolver, Resolver))
throw new InvalidOperationException("Nest reports require their bundled font resolver before PDF fonts are created. Another resolver is already installed.");
// MigraDoc otherwise asks the resolver for a platform "Courier New" error font.
MigraDoc.PredefinedFontsAndChars.ErrorFontName = Family;
}
}
internal static void ValidateText(string text, string field)
{
// Slice 1 deliberately supports printable Latin-1, not general Unicode shaping.
// Reject unavailable text explicitly instead of substituting .notdef boxes or hiding it.
foreach (var rune in text.EnumerateRunes())
{
var value = rune.Value;
if (value is '\r' or '\n' or '\t' || value is >= 0x20 and <= 0x7e
|| value is >= 0xa0 and <= 0xff && value != 0xad)
continue;
throw new InvalidOperationException($"{field}: unsupported report character U+{value:X4}. This report slice supports printable Latin-1 text only.");
}
}
private sealed class BundledResolver : IFontResolver
{
public FontResolverInfo? ResolveTypeface(string familyName, bool isBold, bool isItalic)
{
if (!string.Equals(familyName, Family, StringComparison.OrdinalIgnoreCase) || isItalic)
return null;
return new FontResolverInfo(isBold ? "DejaVuSans-Bold" : "DejaVuSans");
}
public byte[] GetFont(string faceName)
{
if (faceName is not ("DejaVuSans" or "DejaVuSans-Bold"))
throw new InvalidOperationException($"Unknown report font face: {faceName}.");
using var source = typeof(ReportFonts).Assembly.GetManifestResourceStream($"OpenNest.Reporting.Fonts.{faceName}.ttf")
?? throw new InvalidOperationException($"Bundled report font missing: {faceName}.");
using var bytes = new MemoryStream();
source.CopyTo(bytes);
return bytes.ToArray();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CNC;
public class PlateRapidEnumeratorTests
{
[Theory]
[InlineData(1, CutDirection.AwayFromOrigin)]
[InlineData(2, CutDirection.AwayFromOrigin)]
[InlineData(3, CutDirection.AwayFromOrigin)]
[InlineData(4, CutDirection.AwayFromOrigin)]
[InlineData(1, CutDirection.TowardOrigin)]
[InlineData(2, CutDirection.TowardOrigin)]
[InlineData(3, CutDirection.TowardOrigin)]
[InlineData(4, CutDirection.TowardOrigin)]
public void Enumerate_AppliedAutomaticCutoffs_ConnectsFromFinalCut(int quadrant, CutDirection direction)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(70, 0));
program.Codes.Add(new LinearMove(70, 20));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("rectangle", program);
var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5 };
plate.Parts.Add(new Part(drawing,
new Vector(quadrant is 2 or 3 ? -80 : 10, quadrant is 3 or 4 ? -40 : 20)));
var settings = new CutOffSettings { CutDirection = direction, Overtravel = 2 };
var plan = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
Assert.False(plan.HasBlockingDiagnostics);
Assert.Equal(3, plan.Definitions.Count);
foreach (var definition in plan.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
// Two interrupted skeleton cuts followed by an uninterrupted tail separator.
var cutoffs = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray();
Assert.Equal(new[] { 2, 2, 1 }, cutoffs.Select(p => p.Program.Codes.OfType<LinearMove>().Count()));
var before = plate.Parts.Select(p => NestWriter.GetProgramText(p.Program)).ToArray();
var expected = new List<RapidEnumerator.Segment>();
var position = Vector.Zero;
foreach (var part in plate.Parts)
{
Assert.Equal(Mode.Absolute, part.Program.Mode);
foreach (var motion in part.Program.Codes.Cast<Motion>())
{
var destination = part.Location + motion.EndPoint;
if (motion is RapidMove)
expected.Add(new RapidEnumerator.Segment(position, destination));
position = destination;
}
}
var actual = RapidEnumerator.Enumerate(plate.Parts);
Assert.Equal(expected, actual);
Assert.Equal(before, plate.Parts.Select(p => NestWriter.GetProgramText(p.Program)));
plate.RegenerateCutOffs(settings);
Assert.Equal(expected, RapidEnumerator.Enumerate(plate.Parts));
}
[Theory]
[InlineData(Mode.Absolute, false)]
[InlineData(Mode.Incremental, false)]
[InlineData(Mode.Absolute, true)]
[InlineData(Mode.Incremental, true)]
public void Enumerate_OpenProgram_AdvancesPastLastPierce(Mode mode, bool endWithArc)
{
var program = new Program();
program.Codes.Add(new RapidMove(2, 3));
program.Codes.Add(new LinearMove(7, 3));
if (endWithArc)
program.Codes.Add(new ArcMove(8, 4, 7, 4));
program.Mode = mode;
var first = new Part(new Drawing("open path", program), new Vector(100, 200));
var next = NextPart();
var before = NestWriter.GetProgramText(first.Program);
var segments = RapidEnumerator.Enumerate(new[] { first, next });
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(102, 203), segments[0].To);
Assert.Equal(endWithArc ? new Vector(108, 204) : new Vector(107, 203), segments[1].From);
Assert.Equal(new Vector(12, 23), segments[1].To);
Assert.Equal(before, NestWriter.GetProgramText(first.Program));
}
[Fact]
public void Enumerate_FinalSubprogram_UsesItsCutEndpointForNextPart()
{
var hole = new Program(Mode.Incremental);
hole.Codes.Add(new RapidMove(0.5, 0));
hole.Codes.Add(new LinearMove(0, 0.1));
var program = new Program();
program.Codes.Add(new RapidMove(1, 0));
program.Codes.Add(new LinearMove(2, 0));
program.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(2, 2) });
var first = new Part(new Drawing("hole last", program), new Vector(100, 200));
var segments = RapidEnumerator.Enumerate(new[] { first, NextPart() });
Assert.Equal(3, segments.Count);
Assert.Equal(new Vector(102, 200), segments[1].From);
Assert.Equal(new Vector(102.5, 202), segments[1].To);
Assert.Equal(new Vector(102.5, 202.1), segments[2].From);
Assert.Equal(new Vector(12, 23), segments[2].To);
}
[Fact]
public void Enumerate_TrailingRapid_RemainsTheNextPartsStartPosition()
{
var program = new Program();
program.Codes.Add(new RapidMove(1, 0));
program.Codes.Add(new LinearMove(2, 0));
program.Codes.Add(new RapidMove(3, 4));
var first = new Part(new Drawing("park after cut", program), new Vector(100, 200));
var segments = RapidEnumerator.Enumerate(new[] { first, NextPart() });
Assert.Equal(3, segments.Count);
Assert.Equal(new Vector(102, 200), segments[1].From);
Assert.Equal(new Vector(103, 204), segments[1].To);
Assert.Equal(new Vector(103, 204), segments[2].From);
}
[Fact]
public void Enumerate_EmptyPlate_HasNoRapids()
{
Assert.Empty(RapidEnumerator.Enumerate(Array.Empty<Part>()));
}
private static Part NextPart()
{
var program = new Program();
program.Codes.Add(new RapidMove(2, 3));
program.Codes.Add(new LinearMove(4, 3));
return new Part(new Drawing("next", program), new Vector(10, 20));
}
}
@@ -0,0 +1,111 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.CNC;
/// <summary>
/// Hole sub-programs are shared by reference: identical holes call one sub-program,
/// and program copies must not reach back into the source's sub-programs.
/// </summary>
public class ProgramSubProgramTransformTests
{
private const double QuarterTurn = System.Math.PI / 2;
/// <summary>Incremental hole sub-program: a lead-in from the hole centre to
/// (1, 0), then a full radius-1 circle.</summary>
private static Program MakeHoleSub()
{
var sub = new Program(Mode.Absolute);
sub.Codes.Add(new LinearMove(new Vector(1, 0)) { Layer = LayerType.Leadin });
sub.Codes.Add(new ArcMove(new Vector(1, 0), new Vector(0, 0), RotationType.CW));
sub.Mode = Mode.Incremental;
return sub;
}
/// <summary>Program with two identical holes, at (5, 5) and (15, 5), calling one
/// shared sub-program, as <c>ContourCuttingStrategy</c> emits them.</summary>
private static Program MakeTwoHoleProgram()
{
var sub = MakeHoleSub();
var pgm = new Program(Mode.Absolute);
pgm.SubPrograms[7] = sub;
pgm.Codes.Add(new SubProgramCall { Id = 7, Program = sub, Offset = new Vector(5, 5) });
pgm.Codes.Add(new SubProgramCall { Id = 7, Program = sub, Offset = new Vector(15, 5) });
return pgm;
}
/// <summary>End points of the lead-in lines in the program's own frame.</summary>
private static List<Vector> LeadInEnds(Program pgm) =>
ConvertProgram
.ToGeometry(pgm)
.OfType<Line>()
.Where(l => l.Layer == SpecialLayers.Leadin)
.Select(l => l.EndPoint)
.ToList();
private static void AssertPoint(double x, double y, Vector actual)
{
Assert.Equal(x, actual.X, 6);
Assert.Equal(y, actual.Y, 6);
}
[Fact]
public void Rotate_SharedSubProgram_TurnsEachHoleOnce()
{
var pgm = MakeTwoHoleProgram();
pgm.Rotate(QuarterTurn);
// (5, 5) -> (-5, 5) and (15, 5) -> (-5, 15); each lead-in ends one unit along
// the rotated +X axis, i.e. at +Y from its centre.
var ends = LeadInEnds(pgm);
Assert.Equal(2, ends.Count);
AssertPoint(-5, 6, ends[0]);
AssertPoint(-5, 16, ends[1]);
}
[Fact]
public void Clone_DoesNotRealignSourceSubProgram()
{
var pgm = MakeTwoHoleProgram();
pgm.Rotate(QuarterTurn);
var before = LeadInEnds(pgm);
_ = pgm.Clone();
var after = LeadInEnds(pgm);
AssertPoint(before[0].X, before[0].Y, after[0]);
AssertPoint(before[1].X, before[1].Y, after[1]);
}
[Fact]
public void Clone_OwnsItsSubPrograms()
{
var pgm = MakeTwoHoleProgram();
var copy = (Program)pgm.Clone();
copy.Rotate(QuarterTurn);
var sourceEnds = LeadInEnds(pgm);
AssertPoint(6, 5, sourceEnds[0]);
AssertPoint(16, 5, sourceEnds[1]);
var copyEnds = LeadInEnds(copy);
AssertPoint(-5, 6, copyEnds[0]);
AssertPoint(-5, 16, copyEnds[1]);
}
[Fact]
public void Clone_KeepsCallsAndDictionaryOnOneSharedCopy()
{
var pgm = MakeTwoHoleProgram();
var copy = (Program)pgm.Clone();
var calls = copy.Codes.OfType<SubProgramCall>().ToList();
Assert.NotSame(pgm.SubPrograms[7], copy.SubPrograms[7]);
Assert.Same(copy.SubPrograms[7], calls[0].Program);
Assert.Same(copy.SubPrograms[7], calls[1].Program);
}
}
@@ -54,6 +54,28 @@ namespace OpenNest.Tests.CNC
Assert.Equal(new Vector(106, 206), segments[1].To); Assert.Equal(new Vector(106, 206), segments[1].To);
} }
[Fact]
public void Enumerate_IncrementalProgramWithNonZeroFirstRapid_DoesNotDoubleCountIt()
{
// Lead-in programs (ContourCuttingStrategy output) are Incremental and
// start with a nonzero rapid from the part origin to the first pierce.
var pgm = new Program(Mode.Incremental);
pgm.Codes.Add(new RapidMove(2, 3));
pgm.Codes.Add(new LinearMove(5, 0));
pgm.Codes.Add(new RapidMove(1, 1));
var segments = RapidEnumerator.Enumerate(
pgm,
basePos: new Vector(100, 200),
startPos: new Vector(0, 0)
);
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(102, 203), segments[0].To);
Assert.Equal(new Vector(107, 203), segments[1].From);
Assert.Equal(new Vector(108, 204), segments[1].To);
}
[Fact] [Fact]
public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce() public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce()
{ {
@@ -222,6 +222,58 @@ public class CIFiberPostProcessorTests
Assert.Contains("13.2.4.1", ex.Message); Assert.Contains("13.2.4.1", ex.Message);
} }
[Fact]
public void Post_CutOff_PostsWithoutCompensationOrLeadIn()
{
var nest = MakeSquareWithHoleNest();
var plate = nest.Plates[0];
// Vertical cut-off at X=20: a bare rapid + line, no lead-in.
var cutOff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutOff.Regenerate(plate, new CutOffSettings());
plate.Parts.Add(new Part(cutOff.Drawing, Vector.Zero));
var lines = Lines(Post(nest));
var n3 = lines.IndexOf("N3:");
Assert.True(n3 > lines.IndexOf("( PART:CutOff-V-20.00 )"));
Assert.Equal("/L \"L0\"", lines[n3 + 1]);
Assert.Equal("V.E.R3=3", lines[n3 + 2]);
Assert.StartsWith("G0X20Y", lines[n3 + 3]);
Assert.Equal("/L \"L4\"", lines[n3 + 4]);
Assert.Equal("/L \"L6\"", lines[n3 + 5]);
Assert.StartsWith("G1X20Y", lines[n3 + 6]);
Assert.Equal("/L \"ZHSOFF\"", lines[n3 + 7]);
}
[Fact]
public void Post_CutOff_FollowsPlateSequence()
{
// Plate order is the cut sequence: part, cut-off, part. The cut-off is
// sequence 2, so it posts second, not after every part.
var nest = MakeSquareWithHoleNest();
var plate = nest.Plates[0];
plate.Parts.Add(new Part(plate.Parts[0].BaseDrawing, new Vector(40, 2)));
var cutOff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutOff.Regenerate(plate, new CutOffSettings());
plate.Parts.Insert(1, new Part(cutOff.Drawing, Vector.Zero));
var lines = Lines(Post(nest));
Assert.Equal(
new[] { "( PART:square-hole )", "( PART:CutOff-V-20.00 )", "( PART:square-hole )" },
lines.Where(l => l.StartsWith("( PART:")).ToArray()
);
// Part number and restart label run in the same order: the first
// part's hole and perimeter are N1/N2, so the cut-off is N3.
var cutOffBlock = lines.IndexOf("( PART:CutOff-V-20.00 )");
Assert.Equal("( Part #2 )", lines[cutOffBlock - 1]);
Assert.Equal("V.E.R4=2", lines[cutOffBlock + 1]);
Assert.Equal("N3:", lines[cutOffBlock + 2]);
}
[Fact] [Fact]
public void Post_ValidatesTableSize() public void Post_ValidatesTableSize()
{ {
@@ -280,4 +332,240 @@ public class CIFiberPostProcessorTests
Assert.Contains("G0X50Y50", output); Assert.Contains("G0X50Y50", output);
Assert.Contains("V.E.R3=1", output); Assert.Contains("V.E.R3=1", output);
} }
[Fact]
public void MaterialCodes_MatchIgnoringCase_AfterJsonReload()
{
var options = new System.Text.Json.JsonSerializerOptions();
var json = System.Text.Json.JsonSerializer.Serialize(
new CIFiberPostConfig
{
MaterialCodes = new() { ["Stainless"] = "SSN" },
DefaultMaterialCode = "MSN",
},
options
);
var config = System.Text.Json.JsonSerializer.Deserialize<CIFiberPostConfig>(json, options)!;
Assert.Equal("SSN", config.ResolveMaterialCode("stainless"));
Assert.Equal("SSN", config.ResolveMaterialCode(" STAINLESS "));
Assert.Equal("MSN", config.ResolveMaterialCode("Aluminum"));
}
[Fact]
public void SavedConfigWithRetiredPartComment_StillLoads()
{
const string json = """
{ "PartComment": "OLD", "MaxTableX": 120.0, "DefaultMaterialCode": "SSN" }
""";
var config = System.Text.Json.JsonSerializer.Deserialize<CIFiberPostConfig>(json)!;
Assert.Equal(120.0, config.MaxTableX);
Assert.Equal("SSN", config.DefaultMaterialCode);
}
private static Nest MakeMultiSheetNest(params (double Width, double Length)[] extraSheets)
{
var nest = MakeSquareWithHoleNest();
var drawing = nest.Plates[0].Parts[0].BaseDrawing;
foreach (var (width, length) in extraSheets)
{
var plate = new Plate(width, length);
plate.Parts.Add(new Part(drawing, new Vector(3, 4)));
nest.Plates.Add(plate);
}
return nest;
}
private static string NewTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), $"cifiber-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
return dir;
}
[Fact]
public void Post_OneProgramPerSheet_WritesNumberedFiles_EachAFullProgram()
{
var nest = MakeMultiSheetNest((48, 96));
nest.Plates.Add(new Plate(60, 120)); // empty sheet: not posted
var dir = NewTempDir();
try
{
var chosen = Path.Combine(dir, "JOB.cnc");
var post = new CIFiberPostProcessor(MakeConfig());
var expected = post.GetOutputFiles(nest, chosen);
post.Post(nest, chosen);
Assert.Equal(
new[] { Path.Combine(dir, "JOB-1.cnc"), Path.Combine(dir, "JOB-2.cnc") },
expected
);
Assert.Equal(
expected.OrderBy(f => f),
Directory.GetFiles(dir).OrderBy(f => f)
);
var first = Lines(File.ReadAllText(expected[0]));
var second = Lines(File.ReadAllText(expected[1]));
Assert.Contains("V.E.X_SIZE = 120.000", first);
Assert.Contains("V.E.X_SIZE = 96.000", second);
Assert.Contains("V.E.Y_SIZE = 48.000", second);
Assert.Contains("( Sheet number - 1 )", first);
Assert.Contains("( Sheet number - 2 )", second);
foreach (var program in new[] { first, second })
{
// Restart labels start over in every program.
Assert.Contains("N1:", program);
Assert.DoesNotContain("N3:", program);
Assert.Single(program, "M50");
Assert.Equal(
new[] { "L PROGRAMEND.NC", "M50", "M30", "%", "" },
program.TakeLast(5)
);
}
Assert.Contains("G0X3Y3.5", second); // hole pierce at the sheet-2 part location
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_SingleProgram_ChangesPalletBetweenSheets()
{
var nest = MakeMultiSheetNest((60, 120), (60, 120));
var config = MakeConfig();
config.OneProgramPerSheet = false;
var dir = NewTempDir();
try
{
var chosen = Path.Combine(dir, "JOB.cnc");
var post = new CIFiberPostProcessor(config);
Assert.Equal(new[] { chosen }, post.GetOutputFiles(nest, chosen));
post.Post(nest, chosen);
Assert.Equal(new[] { chosen }, Directory.GetFiles(dir));
var lines = Lines(File.ReadAllText(chosen));
Assert.Equal(3, lines.Count(l => l == "M50"));
Assert.Single(lines, "M30");
var sheet2 = lines.IndexOf("( Sheet number - 2 )");
Assert.Equal(
new[] { "( PART END )", "/L \"L0\"", "M50", "( Sheet number - 2 )" },
lines.Skip(sheet2 - 3).Take(4)
);
Assert.True(lines.IndexOf("( Sheet number - 3 )") > sheet2);
// Restart labels continue across sheets within one program.
Assert.Contains("N6:", lines);
Assert.Contains("V.E.R3=6", lines);
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_SingleProgram_RejectsMixedSheetSizes_WritingNothing()
{
var nest = MakeMultiSheetNest((48, 96));
var config = MakeConfig();
config.OneProgramPerSheet = false;
var dir = NewTempDir();
try
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new CIFiberPostProcessor(config).Post(nest, Path.Combine(dir, "JOB.cnc"))
);
Assert.Contains("One program per sheet", ex.Message);
Assert.Empty(Directory.GetFiles(dir));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_OneProgramPerSheet_OversizeSheet_WritesNoFiles()
{
var nest = MakeMultiSheetNest((60, 200)); // second sheet 200 long > 160.25 table
var dir = NewTempDir();
try
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new CIFiberPostProcessor(MakeConfig()).Post(nest, Path.Combine(dir, "JOB.cnc"))
);
Assert.Contains("exceeds maximum table X", ex.Message);
Assert.Empty(Directory.GetFiles(dir));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_OneProgramPerSheet_SingleSheet_UsesChosenName()
{
var nest = MakeSquareWithHoleNest();
var dir = NewTempDir();
try
{
var chosen = Path.Combine(dir, "JOB.cnc");
var post = new CIFiberPostProcessor(MakeConfig());
Assert.Equal(new[] { chosen }, post.GetOutputFiles(nest, chosen));
post.Post(nest, chosen);
Assert.Equal(Post(nest), File.ReadAllText(chosen));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_PalletChangeCode_IsConfigurable()
{
var custom = MakeConfig();
custom.PalletChangeCode = " M51 ";
var lines = Lines(Post(MakeSquareWithHoleNest(), custom));
Assert.Equal(new[] { "L PROGRAMEND.NC", "M51", "M30", "%", "" }, lines.TakeLast(5));
Assert.DoesNotContain("M50", lines);
var none = MakeConfig();
none.PalletChangeCode = "";
lines = Lines(Post(MakeSquareWithHoleNest(), none));
Assert.Equal(new[] { "L PROGRAMEND.NC", "M30", "%", "" }, lines.TakeLast(4));
}
[Fact]
public void PostToStream_MultiSheetPerSheetMode_Throws()
{
var nest = MakeMultiSheetNest((60, 120));
var ex = Assert.Throws<InvalidOperationException>(() => Post(nest));
Assert.Contains("file path", ex.Message);
}
[Fact]
public void SavedConfigWithoutSheetSettings_DefaultsToPerSheetAndM50()
{
var config = System.Text.Json.JsonSerializer.Deserialize<CIFiberPostConfig>(
"""{ "MaxTableX": 120.0 }"""
)!;
Assert.True(config.OneProgramPerSheet);
Assert.Equal("M50", config.PalletChangeCode);
}
} }
@@ -96,6 +96,24 @@ public class CIFiberSampleRegressionTests
return result; return result;
} }
[SkippableFact]
public void Post_ReconstructedNest_IsIdenticalAfterSavedLeadInsReload()
{
var fixture = ResolveFixture();
Skip.If(fixture == null, "CI Fiber fixtures not configured in test-config.json");
var nest = LoadAndLeadIn(fixture.Value.Nest);
var expected = PostNest(nest);
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
stream.Position = 0;
var reader = new NestReader(stream);
var restored = reader.Read();
Assert.Empty(reader.Warnings);
Assert.All(restored.Plates.SelectMany(plate => plate.Parts), part => Assert.True(part.HasManualLeadIns));
Assert.Equal(expected, PostNest(restored));
}
[SkippableFact] [SkippableFact]
public void Post_ReconstructedNest_MatchesSampleCounts() public void Post_ReconstructedNest_MatchesSampleCounts()
{ {
@@ -0,0 +1,216 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CutOffs;
public class AutomaticCutOffBatchTests
{
private static readonly AutomaticCutOffOptions Options = new() { Spacing = 35 };
[Fact]
public void Apply_AllPlates_UsesEachSheetAndPreservesRealPartsAndManualSequence()
{
var settings = new CutOffSettings();
var plates = new[] { CreatePlate(1, 36, 120), CreatePlate(3, 50, 100), new Plate(60, 120) };
var realParts = plates.SelectMany(p => p.Parts).ToArray();
var realPrograms = realParts.Select(p => p.Program).ToArray();
var quantities = realParts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var manual = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
plates[0].CutOffs.Add(manual);
plates[0].RegenerateCutOffs(settings);
var manualPart = plates[0].Parts[^1];
plates[0].Parts.Remove(manualPart);
plates[0].Parts.Insert(0, manualPart);
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
Assert.Same(manual, Assert.Single(plates[0].CutOffs));
Assert.Empty(plates[1].CutOffs);
Assert.Empty(preview[2].Definitions);
var plans = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.All(plans, p => Assert.False(p.HasBlockingDiagnostics));
Assert.Equal(3, plans[0].Definitions.Count);
Assert.Equal(3, plans[1].Definitions.Count);
Assert.Same(manual, plates[0].CutOffs[0]);
Assert.Same(manual.Drawing, plates[0].Parts[0].BaseDrawing);
Assert.Same(realParts[0], plates[0].Parts[1]);
Assert.Same(realParts[1], plates[1].Parts[0]);
Assert.All(plates.Take(2), p => Assert.Equal(p.CutOffs.Count, p.Parts.Count(x => x.BaseDrawing.IsCutOff)));
Assert.All(plates[1].CutOffs, c => Assert.True(c.Position.X < 0));
var tailProgram = plates[1].Parts[^1].Program;
Assert.Equal(50, tailProgram.BoundingBox().Width, 6);
Assert.Empty(plates[2].Parts);
Assert.Empty(plates[2].CutOffs);
Assert.Equal(realPrograms, realParts.Select(p => p.Program));
Assert.Equal(quantities, realParts.Select(p => p.BaseDrawing.Quantity.Nested));
Assert.All(preview.SelectMany(p => p.PreviewParts), p =>
Assert.DoesNotContain(p, plates.SelectMany(plate => plate.Parts)));
var originalParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var repeated = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.All(repeated, p => Assert.Empty(p.Definitions));
for (var i = 0; i < plates.Length; i++)
Assert.Equal(originalParts[i], plates[i].Parts.ToArray());
var nest = new Nest("batch-cutoffs");
foreach (var part in realParts)
nest.Drawings.Add(part.BaseDrawing);
nest.Plates.AddRange(plates);
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var loaded = new NestReader(stream).Read();
// The existing writer omits empty plates.
Assert.Equal(2, loaded.Plates.Count);
for (var i = 0; i < loaded.Plates.Count; i++)
{
Assert.Equal(plates[i].CutOffs.Select(c => (c.Position.X, c.Axis, c.StartLimit, c.EndLimit)),
loaded.Plates[i].CutOffs.Select(c => (c.Position.X, c.Axis, c.StartLimit, c.EndLimit)));
Assert.Equal(plates[i].Parts.Select(p => p.BaseDrawing.IsCutOff),
loaded.Plates[i].Parts.Select(p => p.BaseDrawing.IsCutOff));
}
}
[Fact]
public void Apply_ConflictOnLaterPlate_ChangesNothingOnAnyPlate()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
Assert.All(preview, p => Assert.NotEmpty(p.Definitions));
var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 };
plates[1].CutOffs.Add(limited);
var beforeParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var result = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.True(result[1].HasBlockingDiagnostics);
Assert.Empty(plates[0].CutOffs);
Assert.Same(limited, Assert.Single(plates[1].CutOffs));
for (var i = 0; i < plates.Length; i++)
Assert.Equal(beforeParts[i], plates[i].Parts.ToArray());
}
[Fact]
public void Apply_InvalidLaterPlate_ReportsPlateNumberBeforeChangingAnything()
{
var plates = new[] { CreatePlate(), CreatePlate() };
plates[1].Parts[0].Offset(500, 0);
var error = Assert.Throws<ArgumentException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, new CutOffSettings()));
Assert.Contains("Plate 2:", error.Message);
Assert.All(plates, p => Assert.Empty(p.CutOffs));
Assert.All(plates, p => Assert.Single(p.Parts));
}
[Fact]
public void Apply_ReplansCurrentPartsAndSettingsInsteadOfAcceptingPreview()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
plates[1].Parts[0].Offset(5, 0);
settings.PartClearance = 2;
var expected = AutomaticCutOffPlanner.Create(plates[1], Options, settings);
var result = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.NotEqual(preview[1].TailSeparatorX, result[1].TailSeparatorX);
Assert.Equal(expected.Definitions.Select(c => c.Position.X), plates[1].CutOffs.Select(c => c.Position.X));
}
[Fact]
public void Apply_RegenerationFailureOnLaterPlate_RestoresEveryTouchedPlate()
{
var plates = new[] { CreatePlate(), CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
foreach (var plate in plates)
{
plate.CutOffs.Add(new CutOff(new Vector(5, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(settings);
var cutoffPart = plate.Parts[^1];
plate.Parts.Remove(cutoffPart);
plate.Parts.Insert(0, cutoffPart);
}
var beforeParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var beforeDefinitions = plates.Select(p => p.CutOffs.ToArray()).ToArray();
var beforePrograms = plates.Select(p => p.CutOffs[0].Drawing.Program).ToArray();
var quantities = plates.Select(p => p.Parts[1].BaseDrawing.Quantity.Nested).ToArray();
var failOnce = true;
plates[1].PartAdded += (_, e) =>
{
if (failOnce && e.Item.BaseDrawing.IsCutOff)
{
failOnce = false;
throw new InvalidOperationException("Injected regeneration failure");
}
};
var error = Assert.Throws<InvalidOperationException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, settings));
Assert.False(failOnce);
Assert.Contains("original cut-offs were restored", error.Message);
for (var i = 0; i < plates.Length; i++)
{
Assert.Equal(beforeParts[i], plates[i].Parts.ToArray());
Assert.Equal(beforeDefinitions[i], plates[i].CutOffs.ToArray());
Assert.Same(beforePrograms[i], plates[i].CutOffs[0].Drawing.Program);
Assert.Equal(quantities[i], plates[i].Parts[1].BaseDrawing.Quantity.Nested);
}
}
[Fact]
public void Apply_MinimumTailIsEvaluatedIndependentlyForEachPlate()
{
var plates = new[] { CreatePlate(length: 90), CreatePlate(), new Plate(36, 90) };
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 };
var result = AutomaticCutOffBatch.Apply(plates, options, new CutOffSettings());
Assert.False(result[0].HasSeparatedTail);
Assert.Equal(new[] { 35.0, 70.0 }, plates[0].CutOffs.Select(c => c.Position.X));
Assert.True(result[1].HasSeparatedTail);
Assert.Equal(3, plates[1].CutOffs.Count);
Assert.Empty(plates[2].CutOffs);
Assert.Empty(AutomaticCutOffBatch.Apply(plates, options, new CutOffSettings()).SelectMany(p => p.Definitions));
}
[Fact]
public void Apply_RollbackFailureIsExplicitAndDoesNotStopRecoveryOfOtherPlates()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var originals = plates.Select(p => p.Parts.ToArray()).ToArray();
plates[1].PartAdded += (_, _) => throw new InvalidOperationException("Apply observer failed");
plates[1].CutOffs.ItemRemoved += (_, _) => throw new InvalidOperationException("Rollback observer failed");
var error = Assert.Throws<InvalidOperationException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, new CutOffSettings()));
Assert.Contains("Restoring cut-offs also failed", error.Message);
Assert.Contains("Plate 2", error.Message);
Assert.Contains("may be incomplete", error.Message);
Assert.IsType<AggregateException>(error.InnerException);
Assert.Empty(plates[0].CutOffs);
Assert.Equal(originals[0], plates[0].Parts.ToArray());
}
private static Plate CreatePlate(int quadrant = 1, double width = 36, double length = 120)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(70, 0));
program.Codes.Add(new LinearMove(70, 20));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
var plate = new Plate(width, length) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 };
var drawing = new Drawing($"rectangle-q{quadrant}", program);
var location = new Vector(quadrant is 2 or 3 ? -80 : 10, quadrant is 3 or 4 ? -28 : 8);
plate.Parts.Add(new Part(drawing, location));
return plate;
}
}
@@ -0,0 +1,353 @@
using System.IO.Compression;
using System.Text.Json;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
namespace OpenNest.Tests.CutOffs;
public class AutomaticCutOffLifecycleTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void Apply_AppendsPlannedDefinitions_AfterExistingMixedSequence(int quadrant)
{
var settings = new CutOffSettings { PartClearance = 0.75, Overtravel = 2 };
var nest = MakeNest(quadrant, settings);
var plate = Assert.Single(nest.Plates);
var realParts = CaptureRealParts(plate);
var manual = Assert.Single(plate.CutOffs);
var manualDefinition = Definition(manual);
var originalSequence = plate.Parts.ToArray();
var plan = CreatePlan(plate, settings);
Assert.Equal(originalSequence, plate.Parts.ToArray());
Assert.Same(manual, Assert.Single(plate.CutOffs));
Assert.Equal(3, plan.PreviewParts.Count);
Assert.All(plan.PreviewParts, preview => Assert.DoesNotContain(preview, plate.Parts));
var previewPrograms = plan.PreviewParts.Select(p => NestWriter.GetProgramText(p.Program)).ToArray();
Apply(plate, plan, settings);
Assert.Equal(new[] { manual }.Concat(plan.Definitions), plate.CutOffs);
Assert.Equal(manualDefinition, Definition(manual));
AssertSequence(plate, realParts[0].Part, realParts[1].Part);
AssertOnePartPerDefinition(plate);
AssertRealPartsUnchanged(plate, realParts);
for (var i = 0; i < plan.Definitions.Count; i++)
{
var materialized = plate.Parts[i + 3];
Assert.Same(plan.Definitions[i].Drawing, materialized.BaseDrawing);
Assert.NotSame(plan.PreviewParts[i], materialized);
Assert.Equal(previewPrograms[i], NestWriter.GetProgramText(materialized.Program));
}
var repeated = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
Assert.Empty(repeated.Definitions);
Assert.Empty(repeated.PreviewParts);
Assert.False(repeated.HasBlockingDiagnostics);
Assert.True(repeated.HasSeparatedTail);
Assert.Equal(plan.TailSeparatorX, repeated.TailSeparatorX);
Assert.Equal(plan.UsedSpan, repeated.UsedSpan);
Assert.Equal(plan.TailLength, repeated.TailLength);
Assert.Equal(3, repeated.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff));
// Accepting the empty repeat and regenerating must not duplicate or resequence anything.
Apply(plate, repeated, settings);
Assert.Equal(new[] { manual }.Concat(plan.Definitions), plate.CutOffs);
AssertSequence(plate, realParts[0].Part, realParts[1].Part);
AssertOnePartPerDefinition(plate);
AssertRealPartsUnchanged(plate, realParts);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void SaveAndReload_PreservesDefinitionsLimitsAndExactMixedSequence(int quadrant)
{
// NestReader uses default CutOffSettings; nondefault toolpath fidelity is not this contract.
var settings = new CutOffSettings();
var nest = MakeNest(quadrant, settings);
var plate = Assert.Single(nest.Plates);
var realParts = CaptureRealParts(plate);
Apply(plate, CreatePlan(plate, settings), settings);
var definitions = plate.CutOffs.Select(Definition).ToArray();
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
AssertArchiveContainsOnlyRealParts(stream, plate);
stream.Position = 0;
var loaded = new NestReader(stream).Read();
var loadedPlate = Assert.Single(loaded.Plates);
var loadedRealParts = loadedPlate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray();
Assert.Equal(quadrant, loadedPlate.Quadrant);
Assert.Equal(plate.Size, loadedPlate.Size);
Assert.Equal(plate.PartSpacing, loadedPlate.PartSpacing);
Assert.Equal(plate.Quantity, loadedPlate.Quantity);
Assert.Equal(definitions, loadedPlate.CutOffs.Select(Definition));
Assert.Equal(realParts.Select(p => p.Location), loadedRealParts.Select(p => p.Location));
Assert.Equal(realParts.Select(p => p.ProgramText), loadedRealParts.Select(p => NestWriter.GetProgramText(p.Program)));
Assert.Equal(realParts.Select(p => p.Rotation), loadedRealParts.Select(p => p.Rotation));
AssertSequence(loadedPlate, loadedRealParts[0], loadedRealParts[1]);
AssertOnePartPerDefinition(loadedPlate);
var loadedDrawing = Assert.Single(loaded.Drawings);
Assert.False(loadedDrawing.IsCutOff);
Assert.All(loadedRealParts, p => Assert.Same(loadedDrawing, p.BaseDrawing));
Assert.Equal(realParts[0].Required, loadedDrawing.Quantity.Required);
Assert.Equal(realParts[0].Nested, loadedDrawing.Quantity.Nested);
AssertRealPartsUnchanged(plate, realParts);
var loadedState = CaptureRealParts(loadedPlate);
loadedPlate.RegenerateCutOffs(settings);
Assert.Equal(definitions, loadedPlate.CutOffs.Select(Definition));
AssertSequence(loadedPlate, loadedRealParts[0], loadedRealParts[1]);
AssertOnePartPerDefinition(loadedPlate);
AssertRealPartsUnchanged(loadedPlate, loadedState);
}
[Theory]
[InlineData(1, 5)]
[InlineData(2, 5)]
[InlineData(3, 5)]
[InlineData(4, 5)]
[InlineData(1, 10)]
[InlineData(2, 10)]
[InlineData(3, 10)]
[InlineData(4, 10)]
public void MovePartAfterReload_RegeneratesSafeSegments_WithoutMovingDefinitions(int quadrant, double offsetX)
{
var settings = new CutOffSettings();
var nest = MakeNest(quadrant, settings);
var source = Assert.Single(nest.Plates);
Apply(source, CreatePlan(source, settings), settings);
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var loaded = new NestReader(stream).Read();
var plate = Assert.Single(loaded.Plates);
var definitions = plate.CutOffs.ToArray();
var definitionValues = definitions.Select(Definition).ToArray();
var realParts = CaptureRealParts(plate);
var moved = realParts[1].Part;
var separator = definitions[^1];
var separatorPart = plate.Parts[^1];
var before = Assert.Single(Segments(separatorPart));
var bounds = plate.BoundingBox(false);
Assert.Equal(bounds.Bottom, before.From.Y);
Assert.Equal(bounds.Top, before.To.Y);
Assert.Equal(separator.Position.X, before.From.X);
Assert.Equal(separator.Position.X, before.To.X);
AssertCutsAvoidRectangles(plate, settings.PartClearance);
// Move the outer part across the old separator: its fixed line must now be trimmed,
// not shifted to a new tail or left as a stale full-width cut through the part.
var offset = new Vector(quadrant is 2 or 3 ? -offsetX : offsetX, quadrant is 3 or 4 ? -10 : 10);
moved.Offset(offset);
Assert.Equal(realParts[1].Location + offset, moved.Location);
var movedState = CaptureRealParts(plate);
plate.RegenerateCutOffs(settings);
Assert.Equal(definitions, plate.CutOffs.ToArray());
Assert.Equal(definitionValues, plate.CutOffs.Select(Definition));
AssertSequence(plate, realParts[0].Part, moved);
AssertOnePartPerDefinition(plate);
AssertRealPartsUnchanged(plate, movedState);
AssertRealPartsUnchanged(plate, new[] { realParts[0], realParts[1] with { Location = moved.Location } });
Assert.NotSame(separatorPart, plate.Parts[^1]);
Assert.DoesNotContain(separatorPart, plate.Parts);
var after = Segments(plate.Parts[^1]);
Assert.Equal(2, after.Length);
Assert.All(after, s =>
{
Assert.Equal(separator.Position.X, s.From.X);
Assert.Equal(separator.Position.X, s.To.X);
});
AssertCutsAvoidRectangles(plate, settings.PartClearance);
var regeneratedPrograms = plate.Parts.Where(p => p.BaseDrawing.IsCutOff)
.Select(p => NestWriter.GetProgramText(p.Program)).ToArray();
plate.RegenerateCutOffs(settings);
Assert.Equal(definitionValues, plate.CutOffs.Select(Definition));
Assert.Equal(regeneratedPrograms, plate.Parts.Where(p => p.BaseDrawing.IsCutOff)
.Select(p => NestWriter.GetProgramText(p.Program)));
AssertSequence(plate, realParts[0].Part, moved);
AssertOnePartPerDefinition(plate);
AssertRealPartsUnchanged(plate, movedState);
AssertCutsAvoidRectangles(plate, settings.PartClearance);
}
private static Nest MakeNest(int quadrant, CutOffSettings settings)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(20, 0));
program.Codes.Add(new LinearMove(20, 20));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
Assert.True(Assert.Single(ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(program))).IsClosed());
var drawing = new Drawing("square", program);
Assert.Equal(400, drawing.Area);
drawing.Quantity.Required = 10;
var nest = new Nest("automatic-cutoff-lifecycle");
nest.Drawings.Add(drawing);
var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 };
var negativeX = quadrant is 2 or 3;
var negativeY = quadrant is 3 or 4;
var first = new Part(drawing, new Vector(negativeX ? -30 : 10, negativeY ? -40 : 20));
var second = new Part(drawing, new Vector(negativeX ? -80 : 60, negativeY ? -40 : 20));
plate.Parts.Add(first);
plate.Parts.Add(second);
var manual = new CutOff(new Vector(negativeX ? -15 : 15, negativeY ? -10 : 10), CutOffAxis.Horizontal)
{
StartLimit = negativeX ? -25 : 5,
EndLimit = negativeX ? -5 : 25,
};
plate.CutOffs.Add(manual);
plate.RegenerateCutOffs(settings);
var manualPart = plate.Parts[^1];
plate.Parts.Remove(manualPart);
plate.Parts.Insert(1, manualPart);
Assert.Equal(new[] { first, manualPart, second }, plate.Parts);
Assert.Equal(4, drawing.Quantity.Nested);
nest.Plates.Add(plate);
return nest;
}
private static AutomaticCutOffPlan CreatePlan(Plate plate, CutOffSettings settings)
{
var plan = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
var separator = 80 + System.Math.Max(plate.PartSpacing, settings.PartClearance) + Tolerance.Epsilon;
Assert.False(plan.HasBlockingDiagnostics);
Assert.True(plan.HasSeparatedTail);
Assert.Equal(80, plan.OccupiedSpan);
Assert.Equal(separator, plan.UsedSpan);
Assert.Equal(120 - separator, plan.TailLength);
Assert.Equal(sign * separator, plan.TailSeparatorX);
Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * separator }, plan.Definitions.Select(c => c.Position.X));
Assert.All(plan.Definitions, c =>
{
Assert.Equal(CutOffAxis.Vertical, c.Axis);
Assert.Null(c.StartLimit);
Assert.Null(c.EndLimit);
});
return plan;
}
private static void Apply(Plate plate, AutomaticCutOffPlan plan, CutOffSettings settings)
{
Assert.False(plan.HasBlockingDiagnostics);
foreach (var definition in plan.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
}
private static void AssertSequence(Plate plate, Part first, Part second) =>
Assert.Collection(plate.Parts,
p => Assert.Same(first, p),
p => Assert.Same(plate.CutOffs[0].Drawing, p.BaseDrawing),
p => Assert.Same(second, p),
p => Assert.Same(plate.CutOffs[1].Drawing, p.BaseDrawing),
p => Assert.Same(plate.CutOffs[2].Drawing, p.BaseDrawing),
p => Assert.Same(plate.CutOffs[3].Drawing, p.BaseDrawing));
private static void AssertOnePartPerDefinition(Plate plate)
{
Assert.Equal(4, plate.CutOffs.Count);
Assert.Equal(4, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
Assert.All(plate.CutOffs, c =>
{
Assert.True(c.Drawing.IsCutOff);
Assert.Single(plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, c.Drawing)));
});
}
private static (double X, double Y, CutOffAxis Axis, double? Start, double? End) Definition(CutOff cut) =>
(cut.Position.X, cut.Position.Y, cut.Axis, cut.StartLimit, cut.EndLimit);
private sealed record RealPartState(Part Part, Program Program, string ProgramText, Vector Location,
double Rotation, Program DrawingProgram, string DrawingText, int Required, int Nested);
private static RealPartState[] CaptureRealParts(Plate plate) => plate.Parts
.Where(p => !p.BaseDrawing.IsCutOff)
.Select(p => new RealPartState(p, p.Program, NestWriter.GetProgramText(p.Program), p.Location,
p.Rotation, p.BaseDrawing.Program, NestWriter.GetProgramText(p.BaseDrawing.Program),
p.BaseDrawing.Quantity.Required, p.BaseDrawing.Quantity.Nested)).ToArray();
private static void AssertRealPartsUnchanged(Plate plate, RealPartState[] expected)
{
Assert.Equal(expected.Select(s => s.Part), plate.Parts.Where(p => !p.BaseDrawing.IsCutOff));
Assert.All(expected, s =>
{
Assert.Same(s.Program, s.Part.Program);
Assert.Equal(s.ProgramText, NestWriter.GetProgramText(s.Part.Program));
Assert.Equal(s.Location, s.Part.Location);
Assert.Equal(s.Rotation, s.Part.Rotation);
Assert.Same(s.DrawingProgram, s.Part.BaseDrawing.Program);
Assert.Equal(s.DrawingText, NestWriter.GetProgramText(s.Part.BaseDrawing.Program));
Assert.Equal(s.Required, s.Part.BaseDrawing.Quantity.Required);
Assert.Equal(s.Nested, s.Part.BaseDrawing.Quantity.Nested);
});
}
private static void AssertArchiveContainsOnlyRealParts(MemoryStream stream, Plate plate)
{
stream.Position = 0;
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open());
using var json = JsonDocument.Parse(reader.ReadToEnd());
var savedPlate = Assert.Single(json.RootElement.GetProperty("plates").EnumerateArray());
var savedParts = savedPlate.GetProperty("parts").EnumerateArray().ToArray();
Assert.Equal(2, savedParts.Length);
Assert.Single(json.RootElement.GetProperty("drawings").EnumerateArray());
Assert.Single(archive.Entries.Where(e => e.FullName.StartsWith("programs/", StringComparison.Ordinal)));
Assert.DoesNotContain(archive.Entries, e => e.FullName.StartsWith("parts/", StringComparison.Ordinal));
var realParts = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray();
for (var i = 0; i < realParts.Length; i++)
{
Assert.Equal(realParts[i].Location.X, savedParts[i].GetProperty("x").GetDouble());
Assert.Equal(realParts[i].Location.Y, savedParts[i].GetProperty("y").GetDouble());
}
var cuts = savedPlate.GetProperty("cutOffs").EnumerateArray().ToArray();
Assert.Equal(new[] { 1, 3, 4, 5 }, cuts.Select(c => c.GetProperty("sequence").GetInt32()));
}
private static (Vector From, Vector To)[] Segments(Part part)
{
Assert.NotEmpty(part.Program.Codes);
Assert.Equal(0, part.Program.Codes.Count % 2);
return Enumerable.Range(0, part.Program.Codes.Count / 2).Select(i => (
Assert.IsType<RapidMove>(part.Program.Codes[2 * i]).EndPoint + part.Location,
Assert.IsType<LinearMove>(part.Program.Codes[2 * i + 1]).EndPoint + part.Location)).ToArray();
}
private static void AssertCutsAvoidRectangles(Plate plate, double clearance)
{
// Independent axis-aligned rectangle oracle: inspect every emitted cutting move,
// not rapids, counts, or the same CutOff.ComputeSegments algorithm under test.
var rectangles = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).Select(p => p.BoundingBox).ToArray();
foreach (var cutPart in plate.Parts.Where(p => p.BaseDrawing.IsCutOff))
foreach (var segment in Segments(cutPart))
{
var minX = System.Math.Min(segment.From.X, segment.To.X);
var maxX = System.Math.Max(segment.From.X, segment.To.X);
var minY = System.Math.Min(segment.From.Y, segment.To.Y);
var maxY = System.Math.Max(segment.From.Y, segment.To.Y);
Assert.True(minX == maxX || minY == maxY);
Assert.True(minX < maxX || minY < maxY);
Assert.All(rectangles, box => Assert.True(
maxX <= box.Left - clearance || minX >= box.Right + clearance ||
maxY <= box.Bottom - clearance || minY >= box.Top + clearance,
$"Cut {segment.From} -> {segment.To} crosses a rectangle or its clearance."));
}
}
}
@@ -0,0 +1,770 @@
using System.Reflection;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.CutOffs;
public class AutomaticCutOffPlannerTests
{
private static Plate MakePlate(int quadrant = 1, double occupied = 80)
{
var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 };
var x = quadrant is 2 or 3 ? -occupied : 10;
var y = quadrant is 3 or 4 ? -40 : 20;
plate.Parts.Add(Rectangle(occupied - 10, 20, new Vector(x, y)));
return plate;
}
private static Part Rectangle(double length, double width, Vector location)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(length, 0));
program.Codes.Add(new LinearMove(length, width));
program.Codes.Add(new LinearMove(0, width));
program.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("rectangle", program);
Assert.Equal(length * width, drawing.Area, 8);
return new Part(drawing, location);
}
private static AutomaticCutOffPlan Plan(Plate plate, double spacing = 35,
CutOffSettings? settings = null) => AutomaticCutOffPlanner.Create(
plate, new AutomaticCutOffOptions { Spacing = spacing }, settings ?? new CutOffSettings());
private static double[] Positions(AutomaticCutOffPlan plan) =>
plan.Definitions.Select(c => c.Position.X).ToArray();
private static (Vector From, Vector To)[] Segments(Program program) =>
Enumerable.Range(0, program.Codes.Count / 2).Select(i => (
Assert.IsType<RapidMove>(program.Codes[2 * i]).EndPoint,
Assert.IsType<LinearMove>(program.Codes[2 * i + 1]).EndPoint)).ToArray();
[Fact]
public void UsedEnvelope_StopsBeforeUnusedTail_AndPreviewsAreDetached()
{
var plate = MakePlate();
var plan = Plan(plate);
var separator = 80 + 0.5 + Tolerance.Epsilon;
Assert.Equal(new[] { 35, 70, separator }, Positions(plan));
Assert.All(plan.Definitions, c => Assert.Equal(CutOffAxis.Vertical, c.Axis));
Assert.Equal(80, plan.OccupiedSpan);
Assert.Equal(separator, plan.UsedSpan);
Assert.Equal(120 - separator, plan.TailLength);
Assert.Equal(separator, plan.TailSeparatorX);
Assert.True(plan.HasSeparatedTail);
Assert.False(plan.HasBlockingDiagnostics);
Assert.Equal(plan.Definitions.Count, plan.PreviewParts.Count);
for (var i = 0; i < plan.Definitions.Count; i++)
{
Assert.Same(plan.Definitions[i].Drawing, plan.PreviewParts[i].BaseDrawing);
Assert.DoesNotContain(plan.PreviewParts[i], plate.Parts);
}
var segment = Assert.Single(Segments(plan.Definitions[^1].Drawing.Program));
Assert.Equal(new Vector(separator, 0), segment.From);
Assert.Equal(new Vector(separator, 81), segment.To);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MinimumTail_SkipsOnlyFinalSeparatorWithoutExtendingSkeletonGrid(int quadrant)
{
// The proposed separator is just before X=105, with a tail shorter than 16.
// Skipping it must not add the otherwise-next nominal line at X=105.
var plate = MakePlate(quadrant, occupied: 104);
var before = Plan(plate);
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 16 };
var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
Assert.Equal(before.Definitions.Take(before.Definitions.Count - 1).Select(c => c.Position.X), Positions(plan));
Assert.False(plan.HasSeparatedTail);
Assert.Null(plan.TailSeparatorX);
Assert.Equal(0, plan.TailLength);
Assert.Equal(120, plan.UsedSpan);
Assert.Contains(plan.Diagnostics, d => d.Message.Contains("minimum tail length"));
Assert.Empty(plate.CutOffs);
Assert.Single(plate.Parts);
}
[Theory]
[InlineData(1, -0.01, false)]
[InlineData(1, 0, true)]
[InlineData(1, 0.01, true)]
[InlineData(2, -0.01, false)]
[InlineData(2, 0, true)]
[InlineData(2, 0.01, true)]
[InlineData(3, -0.01, false)]
[InlineData(3, 0, true)]
[InlineData(3, 0.01, true)]
[InlineData(4, -0.01, false)]
[InlineData(4, 0, true)]
[InlineData(4, 0.01, true)]
public void MinimumTail_TwelveInchesIsInclusiveAfterSeparatorClearance(int quadrant, double extra, bool keep)
{
var occupied = 120 - 12 - 0.5 - Tolerance.Epsilon - extra;
var plate = MakePlate(quadrant, occupied);
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 };
var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
Assert.Equal(keep, plan.HasSeparatedTail);
Assert.Equal(keep ? 4 : 3, plan.Definitions.Count);
if (keep)
Assert.Equal(12 + extra, plan.TailLength, 6);
}
[Fact]
public void MinimumTail_DoesNotRemoveExistingSeparatorWhenRerunWithHigherMinimum()
{
var plate = MakePlate(occupied: 110);
var settings = new CutOffSettings();
var original = Plan(plate);
plate.CutOffs.AddRange(original.Definitions);
plate.RegenerateCutOffs(settings);
var definitions = plate.CutOffs.ToArray();
var parts = plate.Parts.ToArray();
var programs = definitions.Select(c => c.Drawing.Program).ToArray();
var plan = AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 }, settings);
Assert.Empty(plan.Definitions);
Assert.False(plan.HasSeparatedTail);
Assert.Equal(definitions, plate.CutOffs.ToArray());
Assert.Equal(parts, plate.Parts.ToArray());
Assert.Equal(programs, plate.CutOffs.Select(c => c.Drawing.Program));
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void MinimumTail_InvalidValueIsRejected(double minimum)
{
var plate = MakePlate();
Assert.Throws<ArgumentException>(() => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = minimum }, new CutOffSettings()));
Assert.Empty(plate.CutOffs);
}
[Theory]
[InlineData(2, 0.5)]
[InlineData(0.5, 2)]
[InlineData(2, 2)]
[InlineData(0, 0)]
public void TailUsesGreaterClearance_NotSum(double partSpacing, double clearance)
{
var plate = MakePlate();
plate.PartSpacing = partSpacing;
var plan = Plan(plate, settings: new CutOffSettings { PartClearance = clearance });
Assert.Equal(80 + System.Math.Max(partSpacing, clearance) + Tolerance.Epsilon,
plan.TailSeparatorX);
}
[Theory]
[InlineData(1, 1)]
[InlineData(2, -1)]
[InlineData(3, -1)]
[InlineData(4, 1)]
public void AllQuadrants_AdvanceXFromOrigin_AcrossPhysicalWidth(int quadrant, int sign)
{
var plate = MakePlate(quadrant);
plate.EdgeSpacing = new Spacing(10, 10);
var settings = new CutOffSettings { Overtravel = 3 };
var plan = Plan(plate, settings: settings);
Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * (80.5 + Tolerance.Epsilon) },
Positions(plan));
var bounds = plate.BoundingBox(false);
var segment = Assert.Single(Segments(plan.Definitions[^1].Drawing.Program));
Assert.Equal(bounds.Bottom, segment.From.Y);
Assert.Equal(bounds.Top + 3, segment.To.Y);
Assert.Equal(80.5 + Tolerance.Epsilon, plan.UsedSpan);
}
[Fact]
public void Planning_DoesNotMutateAnyLiveObjectsOrSequence()
{
var plate = MakePlate();
var drawing = plate.Parts[0].BaseDrawing;
var nest = new Nest("test");
nest.Drawings.Add(drawing);
nest.Plates.Add(plate);
var manual = new CutOff(new Vector(25, 0), CutOffAxis.Vertical)
{ StartLimit = 5, EndLimit = 12 };
plate.CutOffs.Add(manual);
plate.RegenerateCutOffs(new CutOffSettings());
var cutoffPart = plate.Parts[^1];
plate.Parts.Remove(cutoffPart);
plate.Parts.Insert(0, cutoffPart);
var parts = plate.Parts.ToArray();
var definitions = plate.CutOffs.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var text = programs.Select(p => p.ToString()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var drawingProgram = drawing.Program;
var cutoffProgram = manual.Drawing.Program;
var nested = drawing.Quantity.Nested;
var changes = 0;
plate.PartAdded += (_, _) => changes++;
plate.PartRemoved += (_, _) => changes++;
plate.PartChanged += (_, _) => changes++;
var plan = Plan(plate);
Assert.NotEmpty(plan.Definitions);
Assert.Equal(parts, plate.Parts.ToArray());
Assert.Equal(definitions, plate.CutOffs.ToArray());
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.Equal(text, plate.Parts.Select(p => p.Program.ToString()));
Assert.Equal(locations, plate.Parts.Select(p => p.Location));
Assert.Equal(bounds, plate.Parts.Select(p => p.BoundingBox));
Assert.Same(drawingProgram, drawing.Program);
Assert.Same(cutoffProgram, manual.Drawing.Program);
Assert.Equal(nested, drawing.Quantity.Nested);
Assert.Same(drawing, Assert.Single(nest.Drawings));
Assert.Equal(5, manual.StartLimit);
Assert.Equal(12, manual.EndLimit);
Assert.Equal(0, changes);
}
[Theory]
[InlineData(CutDirection.AwayFromOrigin)]
[InlineData(CutDirection.TowardOrigin)]
public void RepeatedLines_UseCurrentManualSegmentation_WithoutIncreasingClearance(CutDirection direction)
{
var plate = MakePlate();
plate.PartSpacing = 8;
var settings = new CutOffSettings
{ PartClearance = 1, Overtravel = 2, MinSegmentLength = 0.5, CutDirection = direction };
var plan = Plan(plate, settings: settings);
var automatic = plan.Definitions[0];
var manual = new CutOff(automatic.Position, automatic.Axis);
manual.Regenerate(plate, settings, Plate.BuildPerimeterCache(plate));
Assert.Equal(manual.Drawing.Program.ToString(), automatic.Drawing.Program.ToString());
var segments = Segments(automatic.Drawing.Program);
Assert.Equal(2, segments.Length);
Assert.All(segments, s => Assert.True(
System.Math.Max(s.From.Y, s.To.Y) <= 19 ||
System.Math.Min(s.From.Y, s.To.Y) >= 41));
Assert.Contains(segments, s => System.Math.Abs(s.To.Y - 19) < 0.01 ||
System.Math.Abs(s.From.Y - 19) < 0.01);
Assert.Equal(1, settings.PartClearance);
}
[Fact]
public void EmptyPlate_AndCutOffOnlyPlate_AreNeverChopped()
{
var plate = new Plate(81, 120);
Assert.Empty(Plan(plate).Definitions);
var old = new CutOff(new Vector(110, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(old);
plate.RegenerateCutOffs(new CutOffSettings());
var plan = Plan(plate);
Assert.Empty(plan.Definitions);
Assert.Empty(plan.PreviewParts);
Assert.Equal(0, plan.OccupiedSpan);
Assert.Equal(0, plan.UsedSpan);
Assert.Equal(0, plan.TailLength);
Assert.False(plan.HasSeparatedTail);
}
[Fact]
public void OldCutOffParts_DoNotExtendTheEnvelope()
{
var plate = MakePlate();
plate.CutOffs.Add(new CutOff(new Vector(115, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Equal(new[] { 35, 70, 80.5 + Tolerance.Epsilon }, Positions(Plan(plate)));
}
[Fact]
public void UsedSpanSmallerThanPitch_OnlyGeneratesSeparator()
{
var plan = Plan(MakePlate(occupied: 20));
Assert.Equal(new[] { 20.5 + Tolerance.Epsilon }, Positions(plan));
}
[Fact]
public void ExactPitchAtSeparator_DoesNotGenerateBoundaryTwice()
{
var plate = MakePlate(occupied: 69.5 - Tolerance.Epsilon);
Assert.Equal(new[] { 35.0, 70.0 }, Positions(Plan(plate)));
}
[Theory]
[InlineData(119.5)]
[InlineData(120)]
public void NoRoomForSeparator_UsesFullSheet_NoEdgeDuplicate(double occupied)
{
var plan = Plan(MakePlate(occupied: occupied), spacing: 40);
Assert.Equal(new[] { 40.0, 80.0 }, Positions(plan));
Assert.Equal(120, plan.UsedSpan);
Assert.False(plan.HasSeparatedTail);
Assert.Null(plan.TailSeparatorX);
Assert.Equal(0, plan.TailLength);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
[InlineData(double.Epsilon)]
[InlineData(0.000001)]
[InlineData(0.00001)]
[InlineData(0.001)]
public void InvalidOrExcessivePitch_IsRejected(double pitch)
{
Assert.ThrowsAny<ArgumentException>(() => Plan(MakePlate(), pitch));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void InvalidSheetDimensions_AreRejected(double value)
{
var plate = MakePlate();
plate.Size = new Size(value, 120);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
plate.Size = new Size(81, value);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void InvalidPartSpacingAndSettings_AreRejected(double value)
{
var plate = MakePlate();
plate.PartSpacing = value;
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
plate.PartSpacing = 0;
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
settings: new CutOffSettings { PartClearance = value }));
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
settings: new CutOffSettings { Overtravel = value }));
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
settings: new CutOffSettings { MinSegmentLength = value }));
}
[Fact]
public void InvalidQuadrantOrDirection_IsRejected()
{
var plate = MakePlate();
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
settings: new CutOffSettings { CutDirection = (CutDirection)42 }));
// The public setter coerces bad quadrants; simulate damaged state explicitly.
typeof(Plate).GetField("quadrant", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(plate, 0);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Fact]
public void NullArguments_AreRejected()
{
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(null!, new(), new()));
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(MakePlate(), null!, new()));
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(MakePlate(), new(), null!));
}
[Theory]
[InlineData(-11, 0)]
[InlineData(50, 0)]
[InlineData(0, -21)]
[InlineData(0, 42)]
[InlineData(double.NaN, 0)]
[InlineData(double.PositiveInfinity, 0)]
public void InvalidOrOutOfSheetGeometry_IsRejected(double dx, double dy)
{
var plate = MakePlate();
plate.Parts[0].Offset(dx, dy);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Fact]
public void EmptyOrNonfiniteProgramGeometry_IsRejected_EvenWithOldBounds()
{
var plate = MakePlate();
plate.Parts[0].Program.Codes.Clear();
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
plate = MakePlate();
((Motion)plate.Parts[0].Program.Codes[2]).EndPoint = new Vector(double.NaN, 2);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Fact]
public void EmptyRepeatedCuts_AreDiagnostic_NotAcceptedPartitions()
{
var plate = new Plate(81, 120);
plate.Parts.Add(Rectangle(80, 81, new Vector()));
var plan = Plan(plate);
Assert.Single(plan.Definitions);
Assert.True(plan.HasSeparatedTail);
Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.EmptyCut));
}
[Fact]
public void FilteredSeparator_DoesNotClaimSeparatedTail()
{
var plan = Plan(MakePlate(), settings: new CutOffSettings { MinSegmentLength = 100 });
Assert.Empty(plan.Definitions);
Assert.False(plan.HasSeparatedTail);
Assert.Equal(0, plan.TailLength);
Assert.Null(plan.TailSeparatorX);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
}
[Fact]
public void ApplyingDefinitions_ThenRerunning_PreservesPartsProgramsQuantitiesAndSequence()
{
var plate = MakePlate();
var real = plate.Parts[0];
var secondReal = new Part(real.BaseDrawing, new Vector(0, 60));
plate.Parts.Add(secondReal);
var secondProgram = secondReal.Program;
var secondLocation = secondReal.Location;
var program = real.Program;
var text = program.ToString();
var location = real.Location;
var nested = real.BaseDrawing.Quantity.Nested;
var settings = new CutOffSettings { Overtravel = 2, PartClearance = 0.75 };
var manual = new CutOff(new Vector(15, 0), CutOffAxis.Horizontal)
{ StartLimit = 5, EndLimit = 15 };
plate.CutOffs.Add(manual);
plate.RegenerateCutOffs(settings);
var manualPart = plate.Parts[^1];
plate.Parts.Remove(manualPart);
plate.Parts.Insert(0, manualPart);
var first = Plan(plate, settings: settings);
foreach (var definition in first.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
var second = Plan(plate, settings: settings);
Assert.Empty(second.Definitions);
Assert.True(second.HasSeparatedTail);
Assert.Equal(first.UsedSpan, second.UsedSpan);
Assert.Equal(3, second.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff));
plate.RegenerateCutOffs(settings);
Assert.Equal(4, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
Assert.All(plate.CutOffs, c => Assert.Single(plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, c.Drawing))));
Assert.Same(manual.Drawing, plate.Parts[0].BaseDrawing);
Assert.Same(real, plate.Parts[1]);
Assert.Same(secondReal, plate.Parts[2]);
Assert.Same(secondProgram, secondReal.Program);
Assert.Equal(secondLocation, secondReal.Location);
Assert.Same(program, real.Program);
Assert.Equal(text, real.Program.ToString());
Assert.Equal(location, real.Location);
Assert.Equal(nested, real.BaseDrawing.Quantity.Nested);
Assert.Equal(5, manual.StartLimit);
Assert.Equal(15, manual.EndLimit);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EquivalentFullSpanLimits_AndCoordinateTolerance_SuppressDuplicates(bool explicitLimits)
{
var plate = MakePlate();
var existing = new CutOff(new Vector(35 + Tolerance.Epsilon / 2, 999), CutOffAxis.Vertical);
if (explicitLimits)
{
existing.StartLimit = 0;
existing.EndLimit = 83;
}
plate.CutOffs.Add(existing);
var plan = Plan(plate, settings: new CutOffSettings { Overtravel = 2 });
Assert.Equal(new[] { 70, 80.5 + Tolerance.Epsilon }, Positions(plan));
Assert.False(plan.HasBlockingDiagnostics);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff);
}
[Fact]
public void DifferentAxisOrDifferentCoordinate_DoesNotSuppress()
{
var plate = MakePlate();
plate.CutOffs.Add(new CutOff(new Vector(35, 35), CutOffAxis.Horizontal));
plate.CutOffs.Add(new CutOff(new Vector(35.01, 0), CutOffAxis.Vertical));
Assert.Equal(3, Plan(plate).Definitions.Count);
}
[Theory]
[InlineData(35)]
[InlineData(80.50001)]
public void SameLineLimitedManualCut_BlocksWholePlan_WithoutMutatingManual(double x)
{
var plate = MakePlate();
var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
{ StartLimit = 10, EndLimit = 60 };
plate.CutOffs.Add(manual);
var program = manual.Drawing.Program;
var plan = Plan(plate);
Assert.True(plan.HasBlockingDiagnostics);
Assert.Empty(plan.Definitions);
Assert.Empty(plan.PreviewParts);
Assert.False(plan.HasSeparatedTail);
Assert.Contains(plan.Diagnostics, d => d.IsBlocking &&
d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict);
Assert.Same(program, manual.Drawing.Program);
Assert.Equal(10, manual.StartLimit);
Assert.Equal(60, manual.EndLimit);
}
[Fact]
public void ExistingFullSpanAlongsideLimitedConflict_DoesNotHideConflict()
{
var plate = MakePlate();
plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical));
plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 40 });
Assert.True(Plan(plate).HasBlockingDiagnostics);
}
[Fact]
public void ExistingSeparator_MetadataUsesItsActualToleranceCloseCoordinate()
{
var plate = MakePlate();
var x = 80.5 + Tolerance.Epsilon * 1.5;
plate.CutOffs.Add(new CutOff(new Vector(x, 0), CutOffAxis.Vertical));
var plan = Plan(plate);
Assert.True(plan.HasSeparatedTail);
Assert.Equal(x, plan.TailSeparatorX);
Assert.Equal(x, plan.UsedSpan);
Assert.Equal(120 - x, plan.TailLength);
}
[Fact]
public void ToleranceCloseExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail()
{
var plate = MakePlate();
plate.PartSpacing = 0;
plate.CutOffs.Add(new CutOff(new Vector(80, 0), CutOffAxis.Vertical));
var plan = Plan(plate, settings: new CutOffSettings { PartClearance = 0 });
Assert.False(plan.HasSeparatedTail);
Assert.Equal(0, plan.TailLength);
Assert.DoesNotContain(plan.Definitions, c => c.Position.X > 70);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
}
[Fact]
public void FractionalPitch_UsesIntegerMultiplicationWithoutCumulativeDrift()
{
var plate = MakePlate(occupied: 11);
var plan = Plan(plate, spacing: 0.7);
var repeated = plan.Definitions.Take(plan.Definitions.Count - 1).ToArray();
Assert.NotEmpty(repeated);
for (var i = 0; i < repeated.Length; i++)
Assert.Equal((i + 1) * 0.7, repeated[i].Position.X);
}
[Fact]
public void ConcaveOrSlopedPart_UsesPerimeterNotJustItsBoundingBox()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(30, 0));
program.Codes.Add(new LinearMove(0, 30));
program.Codes.Add(new LinearMove(0, 0));
var plate = new Plate(81, 120);
plate.Parts.Add(new Part(new Drawing("triangle", program), new Vector(10, 10)));
var plan = Plan(plate, spacing: 20, settings: new CutOffSettings { PartClearance = 0 });
var segments = Segments(plan.Definitions[0].Drawing.Program);
Assert.Equal(2, segments.Length);
Assert.Equal(30, segments[1].From.Y, 8); // Bounding-box fallback would start at 40.
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.SegmentedCut);
}
[Fact]
public void ValidButStalePartBounds_AreRejectedRatherThanUsingUnsafeBroadphase()
{
var plate = MakePlate();
((Motion)plate.Parts[0].Program.Codes[2]).EndPoint = new Vector(90, 20);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
private static Plate MakeArcPlate(double centerError)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(10, 0));
program.Codes.Add(new ArcMove(10, 20, 10, 10 + centerError, RotationType.CCW));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
var material = ConvertProgram.ToGeometry(program).Where(e => SpecialLayers.IsMaterial(e.Layer));
Assert.True(Assert.Single(ShapeBuilder.GetShapes(material)).IsClosed());
var drawing = new Drawing("D shape", program);
Assert.Equal(200 + 50 * System.Math.PI, drawing.Area, 2); // Drawing area tessellates arcs.
var plate = new Plate(81, 120) { PartSpacing = 0 };
plate.Parts.Add(new Part(drawing, new Vector(10, 10)));
return plate;
}
[Theory]
[InlineData(0.0001, false)]
[InlineData(0.0000075, true)]
public void ConvertedArcOutsideCachedBounds_IsRejectedBeforeAcceptance(double centerError, bool repeatedCut)
{
var plate = MakeArcPlate(centerError);
var real = plate.Parts[0];
var cached = real.BoundingBox;
var raw = real.Program.BoundingBox().Translate(real.Location);
Assert.Equal(raw.Right, cached.Right); // Neither cache refresh nor raw-program bounds reveal the error.
Assert.Equal(30 - centerError, cached.Right, 10);
var arc = Assert.Single(ConvertProgram.ToGeometry(real.Program).OfType<Arc>());
arc.Offset(real.Location);
Assert.Equal(30, arc.BoundingBox.Right);
var protrusion = arc.BoundingBox.Right - cached.Right;
Assert.True(protrusion > 0);
Assert.Equal(repeatedCut, protrusion < AutomaticCutOffPlanner.GeometryTolerance);
// A separator crosses the larger protrusion. Even sub-tolerance protrusions are
// unsafe when a repeated line falls between the cached and converted bounds.
var unsafeX = cached.Right + (repeatedCut ? protrusion / 2 : AutomaticCutOffPlanner.GeometryTolerance);
var unsafeLine = new Line(new Vector(unsafeX, 0), new Vector(unsafeX, 81));
Assert.True(arc.Intersects(unsafeLine, out var intersections));
Assert.Equal(2, intersections.Count);
if (repeatedCut)
plate.Parts.Add(Rectangle(10, 10, new Vector(60, 50)));
var settings = new CutOffSettings { PartClearance = 0 };
var manual = new CutOff(new Vector(100, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(manual);
plate.RegenerateCutOffs(settings);
var parts = plate.Parts.ToArray();
var definitions = plate.CutOffs.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var text = programs.Select(p => p.ToString()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var manualProgram = manual.Drawing.Program;
var originalCenter = Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint;
var changes = 0;
plate.PartAdded += (_, _) => changes++;
plate.PartRemoved += (_, _) => changes++;
plate.PartChanged += (_, _) => changes++;
AutomaticCutOffPlan? plan = null;
Assert.Throws<ArgumentException>(() =>
{
plan = Plan(plate, spacing: repeatedCut ? unsafeX : 35, settings: settings);
foreach (var definition in plan.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
});
Assert.Null(plan); // No unsafe proposal can escape to the normal acceptance path.
Assert.Equal(parts, plate.Parts.ToArray());
Assert.Equal(definitions, plate.CutOffs.ToArray());
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.Equal(text, plate.Parts.Select(p => p.Program.ToString()));
Assert.Equal(locations, plate.Parts.Select(p => p.Location));
Assert.Equal(bounds, plate.Parts.Select(p => p.BoundingBox));
Assert.Same(manualProgram, manual.Drawing.Program);
Assert.Equal(originalCenter.X, Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint.X);
Assert.Equal(originalCenter.Y, Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint.Y);
Assert.Equal(0, changes);
}
[Fact]
public void ExactArcWithinCachedBounds_PlansAndMaterializesSafeCuts()
{
var plate = MakeArcPlate(0);
var real = plate.Parts[0];
var program = real.Program;
var settings = new CutOffSettings { PartClearance = 0 };
var plan = Plan(plate, spacing: 25, settings: settings);
Assert.True(plan.HasSeparatedTail);
Assert.False(plan.HasBlockingDiagnostics);
Assert.Equal(30, plan.OccupiedSpan);
Assert.Equal(new[] { 25, 30 + AutomaticCutOffPlanner.GeometryTolerance }, Positions(plan));
Assert.Same(real, Assert.Single(plate.Parts));
Assert.Empty(plate.CutOffs);
var previewSegments = plan.PreviewParts.Select(p => Segments(p.Program)).ToArray();
foreach (var definition in plan.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
var cuts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray();
Assert.Equal(plan.Definitions.Count, cuts.Length);
for (var i = 0; i < cuts.Length; i++)
Assert.Equal(previewSegments[i], Segments(cuts[i].Program));
var repeated = Segments(cuts[0].Program);
Assert.Equal(2, repeated.Length);
Assert.Equal(20 - System.Math.Sqrt(75), repeated[0].To.Y, 8);
Assert.Equal(20 + System.Math.Sqrt(75), repeated[1].From.Y, 8);
var separator = Assert.Single(Segments(cuts[1].Program));
Assert.Equal(new Vector(plan.TailSeparatorX!.Value, 0), separator.From);
Assert.Equal(new Vector(plan.TailSeparatorX.Value, 81), separator.To);
var arc = Assert.Single(ConvertProgram.ToGeometry(real.Program).OfType<Arc>());
arc.Offset(real.Location);
Assert.False(arc.Intersects(new Line(separator.From, separator.To), out _));
Assert.Same(real, plate.Parts[0]);
Assert.Same(program, real.Program);
var repeatedPlan = Plan(plate, spacing: 25, settings: settings);
Assert.Empty(repeatedPlan.Definitions);
Assert.True(repeatedPlan.HasSeparatedTail);
Assert.Equal(plan.TailSeparatorX, repeatedPlan.TailSeparatorX);
}
[Fact]
public void InvalidArcCenterAndRecursiveProgram_AreRejectedBeforeGeometryConversion()
{
var plate = MakePlate();
plate.Parts[0].Program.Codes.Add(new ArcMove(new Vector(), new Vector(double.NaN, 0)));
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
plate = MakePlate();
var program = plate.Parts[0].Program;
program.Codes.Add(new SubProgramCall { Program = program });
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void ApplyAndRepeat_IsIdempotentInEveryQuadrant(int quadrant)
{
var plate = MakePlate(quadrant);
var settings = new CutOffSettings();
var first = Plan(plate, settings: settings);
foreach (var definition in first.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
var second = Plan(plate, settings: settings);
Assert.Empty(second.Definitions);
Assert.True(second.HasSeparatedTail);
Assert.Equal(first.TailSeparatorX, second.TailSeparatorX);
Assert.Equal(first.TailLength, second.TailLength);
Assert.Equal(3, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
}
[Fact]
public void ExistingSeparator_IsVerifiedUnderCurrentSettings_NotStaleProgram()
{
var plate = MakePlate();
var existing = new CutOff(new Vector(80.5 + Tolerance.Epsilon, 0), CutOffAxis.Vertical);
existing.Regenerate(plate, new CutOffSettings());
Assert.NotEmpty(existing.Drawing.Program.Codes);
plate.CutOffs.Add(existing);
var program = existing.Drawing.Program;
var plan = Plan(plate, settings: new CutOffSettings { MinSegmentLength = 100 });
Assert.False(plan.HasSeparatedTail);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
Assert.Same(program, existing.Drawing.Program);
}
}
@@ -0,0 +1,179 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.CutOffs;
public class CutOffEdgeClearanceTests
{
public static IEnumerable<object[]> RectangleCases()
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
foreach (var cutCoordinate in new[] { 70.0, 80.0, 90.0 })
foreach (var zeroClearance in new[] { false, true })
foreach (var materialize in new[] { false, true })
yield return new object[] { quadrant, axis, cutCoordinate, zeroClearance, materialize };
}
[Theory]
[MemberData(nameof(RectangleCases))]
public void Rectangle_EdgesAndInterior_KeepClearance(
int quadrant, CutOffAxis axis, double cutCoordinate, bool zeroClearance, bool materialize)
{
// Reported case: Plate(81,120), rectangle [70,90] x [30,50], vertical X=70.
// Transpose for horizontal cuts, and reflect both the plate and part for each quadrant.
var plate = axis == CutOffAxis.Vertical ? new Plate(81, 120) : new Plate(120, 81);
plate.Quadrant = quadrant;
var negativeX = quadrant is 2 or 3;
var negativeY = quadrant is 3 or 4;
var x0 = axis == CutOffAxis.Vertical ? 70.0 : 30.0;
var y0 = axis == CutOffAxis.Vertical ? 30.0 : 70.0;
var left = negativeX ? -x0 - 20 : x0;
var bottom = negativeY ? -y0 - 20 : y0;
var right = left + 20;
var top = bottom + 20;
var part = new Part(MakeRectangle(), new Vector(left, bottom));
plate.Parts.Add(part);
Assert.Equal(left, part.BoundingBox.Left);
Assert.Equal(right, part.BoundingBox.Right);
Assert.Equal(bottom, part.BoundingBox.Bottom);
Assert.Equal(top, part.BoundingBox.Top);
var cutPosition = axis == CutOffAxis.Vertical
? new Vector(negativeX ? -cutCoordinate : cutCoordinate, 0)
: new Vector(0, negativeY ? -cutCoordinate : cutCoordinate);
var cutoff = new CutOff(cutPosition, axis);
var settings = new CutOffSettings();
if (zeroClearance)
settings.PartClearance = 0;
if (materialize)
{
plate.CutOffs.Add(cutoff);
for (var iteration = 0; iteration < 2; iteration++)
{
plate.RegenerateCutOffs(settings);
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
Assert.Same(cutoff.Drawing, cutPart.BaseDrawing);
AssertRectangleClearance(cutPart.Program, cutPart.Location, axis,
left, right, bottom, top, settings.PartClearance);
}
}
else
{
var cache = Plate.BuildPerimeterCache(plate);
Assert.NotNull(cache[part]);
cutoff.Regenerate(plate, settings, cache);
AssertRectangleClearance(cutoff.Drawing.Program, Vector.Zero, axis,
left, right, bottom, top, settings.PartClearance);
}
}
[Theory]
[InlineData(CutOffAxis.Vertical)]
[InlineData(CutOffAxis.Horizontal)]
public void ZeroClearance_ThroughTwoCrossingVertices_DoesNotCutMaterial(CutOffAxis axis)
{
// Both diamond tips are transverse crossings, each reported by two incident edges.
var points = new[]
{
new Vector(70, 20), new Vector(90, 40),
new Vector(70, 60), new Vector(50, 40),
};
AssertOccupiedSlice(points, axis, 70, 20, 60);
}
[Theory]
[InlineData(CutOffAxis.Vertical)]
[InlineData(CutOffAxis.Horizontal)]
public void ZeroClearance_TwoTangentNotches_DoNotToggleCrossingParity(CutOffAxis axis)
{
// At X=70 the notch tips only touch the line: material remains between Y=20 and 80.
// Deduplicating their events would treat each tangency as a crossing and cut Y=40..60.
var points = new[]
{
new Vector(60, 20), new Vector(90, 20), new Vector(90, 35),
new Vector(70, 40), new Vector(90, 45), new Vector(90, 55),
new Vector(70, 60), new Vector(90, 65), new Vector(90, 80), new Vector(60, 80),
};
AssertOccupiedSlice(points, axis, 70, 20, 80);
}
private static void AssertOccupiedSlice(Vector[] points, CutOffAxis axis,
double cutCoordinate, double occupiedStart, double occupiedEnd)
{
Vector Orient(Vector point) => axis == CutOffAxis.Vertical
? point : new Vector(point.Y, point.X);
var program = new Program();
program.Codes.Add(new RapidMove(Orient(points[0])));
foreach (var point in points.Skip(1).Append(points[0]))
program.Codes.Add(new LinearMove(Orient(point)));
Assert.True(Assert.Single(ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)))).IsClosed());
var plate = new Plate(100, 100);
plate.Parts.Add(new Part(new Drawing("vertex-events", program)));
var cutoff = new CutOff(Orient(new Vector(cutCoordinate, 0)), axis);
plate.CutOffs.Add(cutoff);
plate.RegenerateCutOffs(new CutOffSettings { PartClearance = 0 });
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
var codes = cutPart.Program.Codes;
Assert.Equal(4, codes.Count);
var before = Assert.IsType<LinearMove>(codes[1]).EndPoint + cutPart.Location;
var after = Assert.IsType<RapidMove>(codes[2]).EndPoint + cutPart.Location;
Assert.Equal(occupiedStart, axis == CutOffAxis.Vertical ? before.Y : before.X, 9);
Assert.Equal(occupiedEnd, axis == CutOffAxis.Vertical ? after.Y : after.X, 9);
}
private static Drawing MakeRectangle()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(20, 0));
program.Codes.Add(new LinearMove(20, 20));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
Assert.True(Assert.Single(ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)))).IsClosed());
var drawing = new Drawing("edge-clearance-rectangle", program);
Assert.Equal(400, drawing.Area);
return drawing;
}
private static void AssertRectangleClearance(Program program, Vector location, CutOffAxis axis,
double left, double right, double bottom, double top, double clearance)
{
// Independent analytic oracle: no production offsets, intersections or distance helpers.
// The distance between an axis-aligned segment and a filled rectangle is the norm of
// the gaps between their X/Y intervals. Zero clearance still forbids cutting along an edge.
Assert.NotEmpty(program.Codes);
Assert.Equal(0, program.Codes.Count % 2);
for (var i = 0; i < program.Codes.Count; i += 2)
{
var start = Assert.IsType<RapidMove>(program.Codes[i]).EndPoint + location;
var end = Assert.IsType<LinearMove>(program.Codes[i + 1]).EndPoint + location;
var minX = System.Math.Min(start.X, end.X);
var maxX = System.Math.Max(start.X, end.X);
var minY = System.Math.Min(start.Y, end.Y);
var maxY = System.Math.Max(start.Y, end.Y);
var dx = IntervalGap(minX, maxX, left, right);
var dy = IntervalGap(minY, maxY, bottom, top);
var distance = System.Math.Sqrt(dx * dx + dy * dy);
Assert.True(distance >= clearance - 1e-9,
$"Cut {start} -> {end} is only {distance:R} from the rectangle; requires {clearance:R}.");
var cutsMaterial = axis == CutOffAxis.Vertical
? minX >= left && minX <= right && minY < top && maxY > bottom
: minY >= bottom && minY <= top && minX < right && maxX > left;
Assert.False(cutsMaterial, $"Cut {start} -> {end} crosses or follows the rectangle.");
}
// Must retain the useful scrap cuts on both sides, not suppress the entire line.
Assert.Equal(4, program.Codes.Count);
}
private static double IntervalGap(double a0, double a1, double b0, double b1) =>
System.Math.Max(0, System.Math.Max(b0 - a1, a0 - b1));
}
@@ -0,0 +1,142 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CutOffs;
public class CutOffLeadClearanceTests
{
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Leads_DoNotReplaceRecessedOutlineWithConvexHull(bool horizontal, bool curved)
{
// Like P260805-10 plate 13: the main top edge is recessed 0.25 below small shoulders.
var program = new Program();
program.MoveTo(0, 0);
foreach (var point in new[]
{
new Vector(40, 0), new Vector(40, 20.25), new Vector(38, 20.25),
new Vector(38, 20), new Vector(2, 20), new Vector(2, 20.25),
new Vector(0, 20.25), Vector.Zero,
})
program.Codes.Add(new LinearMove(point));
var drawing = new Drawing("recessed-top", program);
drawing.Quantity.Required = 1;
var nest = new Nest("cutoff-leads");
nest.Drawings.Add(drawing);
var plate = nest.CreatePlate();
plate.Size = new Size(100, 100);
var part = new Part(drawing);
if (horizontal)
part.Rotate(System.Math.PI / 2);
part.Location = new Vector(50, 10);
plate.Parts.Add(part);
var settings = new CutOffSettings();
var cutoff = new CutOff(horizontal ? new Vector(0, 35) : new Vector(75, 0),
horizontal ? CutOffAxis.Horizontal : CutOffAxis.Vertical);
plate.CutOffs.Add(cutoff);
plate.RegenerateCutOffs(settings);
var cleanCut = NestWriter.GetProgramText(cutoff.Drawing.Program);
AssertClearance(plate, horizontal);
part.ApplyLeadIns(new CuttingParameters
{
ExternalLeadIn = curved ? new ArcLeadIn { Radius = 0.25 } : new LineLeadIn { Length = 0.25 },
ExternalLeadOut = curved ? new ArcLeadOut { Radius = 0.25 } : new LineLeadOut { Length = 0.25 },
}, new Vector(-2, -2));
Assert.Contains(OpenNest.Converters.ConvertProgram.ToGeometry(part.Program), e => e.Layer == SpecialLayers.Leadin);
Assert.Contains(OpenNest.Converters.ConvertProgram.ToGeometry(part.Program), e => e.Layer == SpecialLayers.Leadout);
var originalProgram = part.Program;
var originalText = NestWriter.GetProgramText(part.Program);
var originalLocation = part.Location;
var originalRotation = part.Rotation;
var originalBounds = part.BoundingBox;
var nested = drawing.Quantity.Nested;
var perimeter = Assert.IsType<Shape>(Plate.BuildPerimeterCache(plate)[part]);
Assert.True(perimeter.IsClosed());
for (var iteration = 0; iteration < 2; iteration++)
{
plate.RegenerateCutOffs(settings);
Assert.Equal(cleanCut, NestWriter.GetProgramText(cutoff.Drawing.Program));
AssertClearance(plate, horizontal);
Assert.Same(originalProgram, part.Program);
Assert.Equal(originalText, NestWriter.GetProgramText(part.Program));
Assert.Equal(originalLocation, part.Location);
Assert.Equal(originalRotation, part.Rotation);
Assert.Equal(originalBounds, part.BoundingBox);
Assert.Equal(nested, drawing.Quantity.Nested);
Assert.Equal(1, drawing.Quantity.Required);
}
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var reader = new NestReader(stream);
var loaded = reader.Read();
Assert.Empty(reader.Warnings);
var loadedPlate = Assert.Single(loaded.Plates);
Assert.Equal(cleanCut, NestWriter.GetProgramText(Assert.Single(loadedPlate.CutOffs).Drawing.Program));
AssertClearance(loadedPlate, horizontal);
}
private static void AssertClearance(Plate plate, bool horizontal)
{
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
var codes = cutPart.Program.Codes;
Assert.Equal(4, codes.Count);
// Independent material oracle: the unrotated outline is a union of three rectangles.
// Undo the test's exact quarter-turn, not a production geometry/offset operation.
Vector Local(Vector point)
{
point += cutPart.Location - new Vector(50, 10);
return horizontal ? new Vector(point.Y, -point.X) : point;
}
var rectangles = new[] { (0.0, 40.0, 0.0, 20.0), (0.0, 2.0, 20.0, 20.25), (38.0, 40.0, 20.0, 20.25) };
var topGap = double.PositiveInfinity;
for (var i = 0; i < codes.Count; i += 2)
{
var start = Local(Assert.IsType<RapidMove>(codes[i]).EndPoint);
var end = Local(Assert.IsType<LinearMove>(codes[i + 1]).EndPoint);
var minX = System.Math.Min(start.X, end.X);
var maxX = System.Math.Max(start.X, end.X);
var minY = System.Math.Min(start.Y, end.Y);
var maxY = System.Math.Max(start.Y, end.Y);
foreach (var (left, right, bottom, top) in rectangles)
{
var dx = System.Math.Max(0, System.Math.Max(left - maxX, minX - right));
var dy = System.Math.Max(0, System.Math.Max(bottom - maxY, minY - top));
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.02 - 1e-9);
}
if (minY > 20)
topGap = System.Math.Min(topGap, minY - 20);
}
Assert.InRange(topGap, 0.02, 0.021);
}
[Fact]
public void OpenContour_WithLeadIn_StillUsesConservativeFallback()
{
var program = new Program();
program.MoveTo(-1, 0);
program.Codes.Add(new LinearMove(0, 0) { Layer = LayerType.Leadin });
program.LineTo(10, 0);
program.LineTo(10, 10);
var plate = new Plate(100, 100);
var part = new Part(new Drawing("open", program), new Vector(20, 20));
plate.Parts.Add(part);
var perimeter = Assert.IsType<Polygon>(Plate.BuildPerimeterCache(plate)[part]);
Assert.True(perimeter.Vertices.Count >= 3);
var cutoff = new CutOff(new Vector(25, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutoff);
plate.RegenerateCutOffs(new CutOffSettings());
var codes = cutoff.Drawing.Program.Codes;
Assert.Equal(4, codes.Count);
Assert.True(Assert.IsType<LinearMove>(codes[1]).EndPoint.Y <= 19.98);
Assert.True(Assert.IsType<RapidMove>(codes[2]).EndPoint.Y >= 25.02);
}
}
@@ -0,0 +1,108 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CutOffs;
public class CutOffRoundoffTests
{
// Like P260805-10: adding the local minimum and size after placement rounds
// one representational step below translating a material endpoint directly.
private static (Nest Nest, Plate Plate, Part Part) MakeNest(CutOffAxis axis)
{
Vector Point(double x, double y) => axis == CutOffAxis.Vertical ? new(x, y) : new(y, x);
var program = new Program();
program.Codes.Add(new RapidMove(Point(-0.54907, 2)));
program.Codes.Add(new LinearMove(Point(2.80679, 2)));
program.Codes.Add(new LinearMove(Point(2.80679, 6)));
program.Codes.Add(new LinearMove(Point(-0.54907, 6)));
program.Codes.Add(new LinearMove(Point(-0.54907, 2)));
var drawing = new Drawing("translated rectangle", program);
var part = new Part(drawing, Point(13.65687, 10));
var plate = new Plate(60, 120) { PartSpacing = 0 };
var nest = new Nest("roundoff");
nest.Drawings.Add(drawing);
nest.Plates.Add(plate);
plate.Parts.Add(part);
return (nest, plate, part);
}
[Theory]
[InlineData(CutOffAxis.Vertical)]
[InlineData(CutOffAxis.Horizontal)]
public void RoundoffOnlyBoundsMismatch_PlansAppliesAndRoundTripsWithoutChangingParts(CutOffAxis axis)
{
var (nest, plate, part) = MakeNest(axis);
var program = part.Program;
var text = NestWriter.GetProgramText(program);
var box = part.BoundingBox;
var location = part.Location;
var quantity = part.BaseDrawing.Quantity.Nested;
var options = new AutomaticCutOffOptions { Spacing = 5 };
var settings = new CutOffSettings();
var plan = AutomaticCutOffPlanner.Create(plate, options, settings);
Assert.NotEmpty(plan.Definitions);
Assert.True(plan.HasSeparatedTail);
Assert.False(plan.HasBlockingDiagnostics);
Assert.Same(part, Assert.Single(plate.Parts));
Assert.Empty(plate.CutOffs);
foreach (var definition in plan.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
Assert.Same(program, part.Program);
Assert.Same(box, part.BoundingBox);
Assert.Equal(location, part.Location);
Assert.Equal(text, NestWriter.GetProgramText(part.Program));
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
Assert.Empty(AutomaticCutOffPlanner.Create(plate, options, settings).Definitions);
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var loaded = new NestReader(stream).Read();
var loadedPlate = Assert.Single(loaded.Plates);
Assert.Equal(plate.CutOffs.Count, loadedPlate.CutOffs.Count);
Assert.Empty(AutomaticCutOffPlanner.Create(loadedPlate, options, settings).Definitions);
}
[Theory]
[InlineData(CutOffAxis.Vertical, false, 0)]
[InlineData(CutOffAxis.Vertical, true, 0)]
[InlineData(CutOffAxis.Horizontal, false, 0)]
[InlineData(CutOffAxis.Horizontal, true, 0)]
[InlineData(CutOffAxis.Vertical, false, 0.02)]
[InlineData(CutOffAxis.Vertical, true, 0.02)]
[InlineData(CutOffAxis.Horizontal, false, 0.02)]
[InlineData(CutOffAxis.Horizontal, true, 0.02)]
public void LineBeyondRoundedCache_DoesNotSkipMaterialOrClearance(CutOffAxis axis, bool useCache, double clearance)
{
var (_, plate, part) = MakeNest(axis);
var farEdge = 2.80679 + 13.65687;
var cachedEdge = axis == CutOffAxis.Vertical ? part.BoundingBox.Right : part.BoundingBox.Top;
Assert.True(farEdge > cachedEdge);
var cutPosition = farEdge + clearance;
Assert.True(cutPosition > cachedEdge + clearance);
var cut = new CutOff(new Vector(cutPosition, cutPosition), axis);
cut.Regenerate(plate, new CutOffSettings { PartClearance = clearance },
useCache ? Plate.BuildPerimeterCache(plate) : null);
// Independent rectangle oracle: even a line on the true edge must not cut
// along that edge or through its clearance band. Both gaps must remain.
var segments = ConvertProgram.ToGeometry(cut.Drawing.Program).OfType<Line>()
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToArray();
Assert.Equal(2, segments.Length);
foreach (var segment in segments)
{
var from = axis == CutOffAxis.Vertical ? segment.StartPoint.Y : segment.StartPoint.X;
var to = axis == CutOffAxis.Vertical ? segment.EndPoint.Y : segment.EndPoint.X;
var low = System.Math.Min(from, to);
var high = System.Math.Max(from, to);
Assert.True(high <= 12 || low >= 16);
var alongGap = System.Math.Max(12 - high, low - 16);
var acrossGap = cutPosition - farEdge;
var distance = System.Math.Sqrt(acrossGap * acrossGap + alongGap * alongGap);
Assert.True(distance >= clearance - 1e-12);
}
}
}
@@ -0,0 +1,122 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CutOffs;
/// <summary>
/// A cut-off's place in <c>Plate.Parts</c> is its cut sequence number, which the
/// user sets and the posts follow. Regenerating cut-offs (after a part drag, fill
/// or cut-off move) and saving must keep it.
/// </summary>
public class CutOffSequenceTests
{
private static Drawing MakeSquare()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("square", pgm);
}
/// <summary>Three parts along X with vertical cut-offs between them, the
/// cut-offs sequenced as 2 and 4: part, cut, part, cut, part.</summary>
private static Plate MakeSequencedPlate(Drawing drawing)
{
var plate = new Plate(60, 120);
plate.Parts.Add(new Part(drawing, new Vector(1, 2)));
plate.Parts.Add(new Part(drawing, new Vector(30, 2)));
plate.Parts.Add(new Part(drawing, new Vector(60, 2)));
plate.CutOffs.Add(new CutOff(new Vector(20, 0), CutOffAxis.Vertical));
plate.CutOffs.Add(new CutOff(new Vector(50, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(new CutOffSettings());
// As ActionSetSequence does: remove, then insert at the chosen number.
SetSequence(plate, plate.CutOffs[0], 1);
SetSequence(plate, plate.CutOffs[1], 3);
return plate;
}
private static void SetSequence(Plate plate, CutOff cutOff, int index)
{
var part = plate.Parts.First(p => ReferenceEquals(p.BaseDrawing, cutOff.Drawing));
plate.Parts.Remove(part);
plate.Parts.Insert(index, part);
}
/// <summary>The cut sequence as names: "part" or the cut-off's X position.</summary>
private static string[] Sequence(Plate plate) =>
plate
.Parts.Select(p =>
p.BaseDrawing.IsCutOff ? $"cut@{p.BoundingBox.X:F0}" : $"part@{p.Location.X:F0}"
)
.ToArray();
private static readonly string[] Expected =
{
"part@1",
"cut@20",
"part@30",
"cut@50",
"part@60",
};
[Fact]
public void RegenerateCutOffs_KeepsEachCutOffInItsSequencePlace()
{
var plate = MakeSequencedPlate(MakeSquare());
Assert.Equal(Expected, Sequence(plate));
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Equal(Expected, Sequence(plate));
}
[Fact]
public void RegenerateCutOffs_AddsNewCutOffAtTheEnd()
{
var plate = MakeSequencedPlate(MakeSquare());
plate.CutOffs.Add(new CutOff(new Vector(0, 40), CutOffAxis.Horizontal));
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Equal(Expected, Sequence(plate).Take(5));
Assert.Equal(6, plate.Parts.Count);
Assert.Same(plate.CutOffs[2].Drawing, plate.Parts[5].BaseDrawing);
}
[Fact]
public void SaveAndReopen_KeepsCutOffSequence()
{
var drawing = MakeSquare();
var nest = new Nest("seq") { DateCreated = DateTime.Now, DateLastModified = DateTime.Now };
nest.Drawings.Add(drawing);
nest.Plates.Add(MakeSequencedPlate(drawing));
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
stream.Position = 0;
var loaded = new NestReader(stream).Read();
Assert.Equal(Expected, Sequence(loaded.Plates[0]));
}
[Fact]
public void RegenerateCutOffs_OutOfRangeSequence_AppendsInsteadOfThrowing()
{
// A damaged file can name a sequence past the end of the plate.
var plate = new Plate(60, 120);
plate.Parts.Add(new Part(MakeSquare(), new Vector(1, 2)));
var cutOff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutOff);
plate.RegenerateCutOffs(new CutOffSettings(), new Dictionary<CutOff, int> { [cutOff] = 99 });
Assert.Equal(2, plate.Parts.Count);
Assert.Same(cutOff.Drawing, plate.Parts[1].BaseDrawing);
}
}
@@ -0,0 +1,306 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingStrategy;
public class CutoutCornerLeadInTests
{
private const double LeadLength = 0.125;
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ApplyLeadIns_SmallRectangularCutout_PiercesInsideOnBisector(bool reverse)
{
// Dimensions of the small slots in 4980 A01 PT07.dxf.
var vertices = Rectangle();
var part = MakePart(vertices, reverse);
part.ApplyLeadIns(Parameters(), Vector.Zero);
var lead = SingleLeadIn(part);
var corner = new Vector(2.282, 2.532);
AssertPoint(corner, lead.EndPoint);
AssertBisector(lead, corner, new Vector(-1, -1));
Assert.True(lead.StartPoint.X > 2 && lead.StartPoint.X < 2.282);
Assert.True(lead.StartPoint.Y > 2 && lead.StartPoint.Y < 2.532);
}
public static IEnumerable<object[]> Corners()
{
foreach (var reverse in new[] { false, true })
foreach (var rotation in new[] { 0.0, 0.63 })
for (var corner = 0; corner < 4; corner++)
foreach (var incoming in new[] { false, true })
yield return new object[] { reverse, rotation, corner, incoming };
}
[Theory]
[MemberData(nameof(Corners))]
public void ApplySingleLeadIn_EitherCornerEdge_UsesSameInwardBisector(
bool reverse, double rotation, int cornerIndex, bool incoming)
{
var part = MakePart(Rectangle(), reverse);
part.Rotate(rotation);
var cutout = Cutout(part);
var outgoing = Assert.IsType<Line>(cutout.Entities[cornerIndex]);
var point = outgoing.StartPoint;
var entity = incoming
? cutout.Entities[(cornerIndex + cutout.Entities.Count - 1) % cutout.Entities.Count]
: outgoing;
// Normalize each rectangular axis, not the unequal diagonal lengths.
var next = outgoing.EndPoint - point;
var previous = Assert.IsType<Line>(cutout.Entities[(cornerIndex + 3) % 4]).StartPoint - point;
var direction = next / next.DistanceTo(Vector.Zero) + previous / previous.DistanceTo(Vector.Zero);
part.ApplySingleLeadIn(Parameters(), point, entity, ContourType.Internal);
AssertBisector(SingleLeadIn(part), point, direction);
}
[Theory]
[InlineData(30, false)]
[InlineData(30, true)]
[InlineData(90, false)]
[InlineData(90, true)]
[InlineData(140, false)]
[InlineData(140, true)]
public void ApplySingleLeadIn_UnequalEdgeLengths_BisectsAngle(double degrees, bool reverse)
{
var angle = degrees * System.Math.PI / 180;
var point = new Vector(3, 3);
var vertices = new[]
{
point,
point + new Vector(4, 0),
point + new Vector(2 * System.Math.Cos(angle), 2 * System.Math.Sin(angle)),
};
var part = MakePart(vertices, reverse);
var cutout = Cutout(part);
var entity = cutout.Entities.OfType<Line>().First(e => e.StartPoint == point);
part.ApplySingleLeadIn(Parameters(), point, entity, ContourType.Internal);
AssertBisector(SingleLeadIn(part), point,
new Vector(System.Math.Cos(angle / 2), System.Math.Sin(angle / 2)));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ApplySingleLeadIn_ReflexCorner_BisectorPointsIntoScrap(bool reverse)
{
var vertices = new[]
{
new Vector(2, 2), new Vector(6, 2), new Vector(6, 4),
new Vector(4, 4), new Vector(4, 6), new Vector(2, 6),
};
var part = MakePart(vertices, reverse);
var point = new Vector(4, 4);
var entity = Cutout(part).Entities.OfType<Line>().First(e => e.StartPoint == point);
part.ApplySingleLeadIn(Parameters(), point, entity, ContourType.Internal);
AssertBisector(SingleLeadIn(part), point, new Vector(-1, -1));
}
[Fact]
public void ApplySingleLeadIn_MidEdge_KeepsPerpendicularApproach()
{
var part = MakePart(Rectangle());
var entity = Assert.IsType<Line>(Cutout(part).Entities[0]);
var point = entity.MidPoint;
var normal = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.Internal, RotationType.CCW);
var expected = Parameters().InternalLeadIn.GetPiercePoint(point, normal);
part.ApplySingleLeadIn(Parameters(), point, entity, ContourType.Internal);
AssertPoint(expected, SingleLeadIn(part).StartPoint);
}
[Fact]
public void ApplySingleLeadIn_ExternalCorner_ExtendsFirstCutEdgeNotBisector()
{
// Outside perimeter corners have their own rule (PerimeterCornerLeadInTests).
var part = MakePart(Rectangle());
var profile = Profile(part);
var entity = Assert.IsType<Line>(profile.Perimeter.Entities[0]);
var point = entity.StartPoint;
var parameters = Parameters();
parameters.ExternalLeadIn = parameters.InternalLeadIn;
var direction = (entity.EndPoint - entity.StartPoint).Normalize();
part.ApplySingleLeadIn(parameters, point, entity, ContourType.External);
AssertPoint(point - direction * LeadLength, SingleLeadIn(part).StartPoint);
}
[Fact]
public void ApplySingleLeadIn_Corner_DoesNotChangeLeadOutDirection()
{
var part = MakePart(Rectangle());
var cutout = Cutout(part);
var entity = Assert.IsType<Line>(cutout.Entities[0]);
var point = entity.StartPoint;
var parameters = Parameters();
parameters.InternalLeadOut = new LineLeadOut { Length = 0.05 };
var normal = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.Internal,
ContourCuttingStrategy.DetermineWinding(cutout));
var expected = Assert.IsType<LinearMove>(Assert.Single(parameters.InternalLeadOut.Generate(point, normal)));
part.ApplySingleLeadIn(parameters, point, entity, ContourType.Internal);
var leadOut = Assert.IsType<Line>(Assert.Single(part.Program.ToGeometry().Where(e => e.Layer == SpecialLayers.Leadout)));
AssertPoint(expected.EndPoint, leadOut.EndPoint);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void ApplySingleLeadIn_LineArcCorner_UsesTangentBisector(bool reverse, bool selectArc)
{
var part = MakePart(Rectangle());
var program = part.BaseDrawing.Program.Clone() as Program;
// Replace the rectangular hole with a right half-circle, closed by a line.
program!.Codes.RemoveRange(5, program.Codes.Count - 5);
var point = new Vector(3, 3);
var top = new Vector(3, 7);
program.Codes.Add(new RapidMove(point));
if (reverse)
{
program.Codes.Add(new LinearMove(top));
program.Codes.Add(new ArcMove(point, new Vector(3, 5), RotationType.CW));
}
else
{
program.Codes.Add(new ArcMove(top, new Vector(3, 5), RotationType.CCW));
program.Codes.Add(new LinearMove(point));
}
part = new Part(new Drawing("line-arc-corner", program));
var shape = Cutout(part);
Assert.True(shape.IsClosed());
var entity = shape.Entities.Single(e => selectArc ? e is Arc : e is Line);
var parameters = Parameters();
var previewNormal = ContourCuttingStrategy.ComputeLeadInNormal(shape, point, entity,
ContourType.Internal, parameters.InternalLeadIn, ContourCuttingStrategy.DetermineWinding(shape));
part.ApplySingleLeadIn(parameters, point, entity, ContourType.Internal);
var lead = SingleLeadIn(part);
AssertBisector(lead, point, new Vector(1, 1));
AssertPoint(parameters.InternalLeadIn.GetPiercePoint(point, previewNormal), lead.StartPoint);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void ComputeLeadInNormal_NonStraightStyles_KeepEntityNormal(int style)
{
var shape = Cutout(MakePart(Rectangle()));
var entity = Assert.IsType<Line>(shape.Entities[0]);
var leadIn = style switch
{
0 => (LeadIn)new ArcLeadIn { Radius = 0.05 },
1 => new LineArcLeadIn { ArcRadius = 0.05, LineLength = 0.1 },
2 => new LineLineLeadIn { Length1 = 0.05, Length2 = 0.1 },
_ => new NoLeadIn(),
};
var winding = ContourCuttingStrategy.DetermineWinding(shape);
var expected = ContourCuttingStrategy.ComputeNormal(entity.StartPoint, entity, ContourType.Internal, winding);
var actual = ContourCuttingStrategy.ComputeLeadInNormal(shape, entity.StartPoint, entity,
ContourType.Internal, leadIn, winding);
Assert.Equal(expected, actual);
}
[Theory]
[InlineData("open")]
[InlineData("zero-length")]
[InlineData("cusp")]
[InlineData("endpoint-gap")]
public void ComputeLeadInNormal_AmbiguousCorner_FallsBackToFiniteEntityNormal(string kind)
{
var shape = Cutout(MakePart(Rectangle()));
var entity = Assert.IsType<Line>(shape.Entities[0]);
var point = entity.StartPoint;
switch (kind)
{
case "open":
shape.Entities.RemoveAt(3);
break;
case "zero-length":
shape.Entities.Insert(0, new Line(point, point));
break;
case "cusp":
shape.Entities.Clear();
shape.Entities.Add(entity);
shape.Entities.Add(new Line(entity.EndPoint, entity.StartPoint));
break;
case "endpoint-gap":
// A chained contour is not necessarily an exact shared vertex.
var last = Assert.IsType<Line>(shape.Entities[3]);
last.EndPoint = point + new Vector(0, OpenNest.Math.Tolerance.ChainTolerance / 2);
break;
}
var expected = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.Internal, RotationType.CCW);
var actual = ContourCuttingStrategy.ComputeLeadInNormal(shape, point, entity,
ContourType.Internal, Parameters().InternalLeadIn, RotationType.CCW);
Assert.True(double.IsFinite(actual));
Assert.Equal(expected, actual);
}
private static Vector[] Rectangle() => new[]
{
new Vector(2, 2), new Vector(2.282, 2),
new Vector(2.282, 2.532), new Vector(2, 2.532),
};
private static CuttingParameters Parameters() => new()
{
InternalLeadIn = new LineLeadIn { Length = LeadLength, ApproachAngle = 90 },
};
private static Part MakePart(Vector[] hole, bool reverse = false)
{
var program = new Program(Mode.Absolute);
AddContour(program, new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) });
AddContour(program, reverse ? hole.Reverse().ToArray() : hole);
return new Part(new Drawing("corner-test", program));
}
private static void AddContour(Program program, Vector[] vertices)
{
program.Codes.Add(new RapidMove(vertices[0]));
foreach (var point in vertices.Skip(1).Append(vertices[0]))
program.Codes.Add(new LinearMove(point));
}
private static ShapeProfile Profile(Part part) => new(part.Program.ToGeometry()
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList());
private static Shape Cutout(Part part) => Assert.Single(Profile(part).Cutouts);
private static Line SingleLeadIn(Part part) => Assert.IsType<Line>(Assert.Single(
part.Program.ToGeometry().Where(e => e.Layer == SpecialLayers.Leadin)));
private static void AssertBisector(Line lead, Vector corner, Vector direction)
{
AssertPoint(corner, lead.EndPoint);
AssertPoint(corner + direction / direction.DistanceTo(Vector.Zero) * LeadLength, lead.StartPoint);
Assert.Equal(LeadLength, lead.Length, 8);
}
private static void AssertPoint(Vector expected, Vector actual)
{
Assert.Equal(expected.X, actual.X, 8);
Assert.Equal(expected.Y, actual.Y, 8);
}
}
@@ -0,0 +1,185 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingStrategy;
/// <summary>
/// Copying and rotating a part that already carries lead-ins must keep its true
/// rotation, lead-in state and geometry, so Remove Lead-ins (and saving) still
/// see the part the user placed.
/// </summary>
public class PartLeadInTransformTests
{
private const double QuarterTurn = System.Math.PI / 2;
/// <summary>10x10 square with a radius-1 hole at (5, 5), so lead-in
/// assignment emits both a perimeter lead-in and a hole sub-program.</summary>
private static Drawing MakeSquareWithHole()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
pgm.Codes.Add(new RapidMove(new Vector(6, 5)));
pgm.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW));
return new Drawing("square-with-hole", pgm);
}
private static CuttingParameters Parameters() =>
new()
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 90 },
};
private static Part MakeLeadInPart(Drawing drawing, double rotation)
{
var part = new Part(drawing);
part.Rotate(rotation);
part.Offset(20, 5);
part.ApplyLeadIns(Parameters(), new Vector(-5, -5));
return part;
}
/// <summary>Clean part of the drawing at the given rotation and location: what
/// Remove Lead-ins must restore.</summary>
private static Part MakeCleanPart(Drawing drawing, double rotation, Vector location)
{
var part = new Part(drawing);
part.Rotate(rotation);
part.Location = location;
return part;
}
private static void AssertSameBox(Box expected, Box actual)
{
Assert.Equal(expected.X, actual.X, 6);
Assert.Equal(expected.Y, actual.Y, 6);
Assert.Equal(expected.Length, actual.Length, 6);
Assert.Equal(expected.Width, actual.Width, 6);
}
private static int LeadInMoveCount(Program program) =>
program.Codes.OfType<LinearMove>().Count(m => m.Layer == LayerType.Leadin)
+ program.Codes.OfType<ArcMove>().Count(m => m.Layer == LayerType.Leadin);
[Fact]
public void Clone_KeepsLeadInStateAndRotation()
{
var drawing = MakeSquareWithHole();
var source = MakeLeadInPart(drawing, QuarterTurn);
source.LeadInsLocked = true;
var clone = (Part)source.Clone();
Assert.True(clone.HasManualLeadIns);
Assert.True(clone.LeadInsLocked);
Assert.Same(source.CuttingParameters, clone.CuttingParameters);
Assert.Equal(source.Rotation, clone.Rotation, 6);
Assert.Equal(LeadInMoveCount(source.Program), LeadInMoveCount(clone.Program));
AssertSameBox(source.BoundingBox, clone.BoundingBox);
}
[Fact]
public void CloneAtOffset_KeepsLeadInStateAndRotation()
{
var drawing = MakeSquareWithHole();
var source = MakeLeadInPart(drawing, QuarterTurn);
var copy = source.CloneAtOffset(new Vector(3, 4));
Assert.True(copy.HasManualLeadIns);
Assert.Equal(source.Rotation, copy.Rotation, 6);
Assert.Equal(source.Location.X + 3, copy.Location.X, 6);
Assert.Equal(source.Location.Y + 4, copy.Location.Y, 6);
}
[Fact]
public void Clone_ThenRemoveLeadIns_RestoresCleanRotatedPart()
{
var drawing = MakeSquareWithHole();
var source = MakeLeadInPart(drawing, QuarterTurn);
var clone = (Part)source.Clone();
clone.RemoveLeadIns();
var expected = MakeCleanPart(drawing, QuarterTurn, source.Location);
Assert.False(clone.HasManualLeadIns);
Assert.Equal(QuarterTurn, clone.Rotation, 6);
AssertSameBox(expected.BoundingBox, clone.BoundingBox);
}
[Fact]
public void Clone_DoesNotShareHoleSubPrograms()
{
var drawing = MakeSquareWithHole();
var source = MakeLeadInPart(drawing, QuarterTurn);
var sourceBox = source.Program.BoundingBox();
var clone = (Part)source.Clone();
clone.Rotate(QuarterTurn);
// Rotating the copy must not rotate the source's hole lead-in.
AssertSameBox(sourceBox, source.Program.BoundingBox());
Assert.Equal(QuarterTurn, source.Rotation, 6);
}
[Fact]
public void Rotate_LeadInPart_ReportsCumulativeRotation()
{
var drawing = MakeSquareWithHole();
var part = MakeLeadInPart(drawing, QuarterTurn);
part.Rotate(QuarterTurn);
Assert.True(part.HasManualLeadIns);
Assert.Equal(System.Math.PI, part.Rotation, 6);
}
[Fact]
public void RotateAboutOrigin_LeadInPart_ReportsCumulativeRotation()
{
var drawing = MakeSquareWithHole();
var part = MakeLeadInPart(drawing, QuarterTurn);
part.Rotate(QuarterTurn, new Vector(50, 50));
Assert.Equal(System.Math.PI, part.Rotation, 6);
}
[Fact]
public void Rotate_LeadInPart_ThenRemove_RestoresCleanPartAtNewRotation()
{
var drawing = MakeSquareWithHole();
var part = MakeLeadInPart(drawing, QuarterTurn);
part.Rotate(QuarterTurn);
var location = part.Location;
part.RemoveLeadIns();
var expected = MakeCleanPart(drawing, System.Math.PI, location);
Assert.Equal(System.Math.PI, part.Rotation, 6);
AssertSameBox(expected.BoundingBox, part.BoundingBox);
}
[Fact]
public void Rotate_LeadInPart_RotatesHoleLeadInWithPart()
{
var drawing = MakeSquareWithHole();
var part = MakeLeadInPart(drawing, 0);
var before = part.Program.BoundingBox();
part.Rotate(QuarterTurn);
// A quarter turn about the program origin maps (x, y) to (-y, x): the lead-in
// program's box, holes included, must turn with it rather than stay behind.
var after = part.Program.BoundingBox();
Assert.Equal(-(before.Y + before.Width), after.X, 6);
Assert.Equal(before.X, after.Y, 6);
Assert.Equal(before.Width, after.Length, 6);
Assert.Equal(before.Length, after.Width, 6);
}
}
@@ -0,0 +1,284 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingStrategy;
/// <summary>
/// Straight lead-ins at outside perimeter corners extend the edge cut first, so the
/// torch enters on that edge's line whichever of the corner's two edges was picked.
/// </summary>
public class PerimeterCornerLeadInTests
{
private const double LeadLength = 0.25;
public static IEnumerable<object[]> SquareCorners()
{
foreach (var reverse in new[] { false, true })
foreach (var rotation in new[] { 0.0, 0.63 })
for (var corner = 0; corner < 4; corner++)
yield return new object[] { reverse, rotation, corner };
}
[Theory]
[MemberData(nameof(SquareCorners))]
public void ApplyLeadIns_ConvexCorner_ExtendsFirstCutEdge(bool reverse, double rotation, int cornerIndex)
{
var part = MakePart(Square(), reverse);
part.Rotate(rotation);
var perimeter = Perimeter(part);
var outgoing = Assert.IsType<Line>(perimeter.Entities[cornerIndex]);
var corner = outgoing.StartPoint;
var centroid = Centroid(perimeter);
var approach = corner + (corner - centroid) * 2;
part.ApplyLeadIns(Parameters(), approach);
AssertStraightEntry(part, corner, Direction(outgoing));
}
public static IEnumerable<object[]> PickedEdges()
{
foreach (var args in SquareCorners())
foreach (var incoming in new[] { false, true })
yield return args.Append(incoming).ToArray();
}
[Theory]
[MemberData(nameof(PickedEdges))]
public void ApplySingleLeadIn_ConvexCorner_SameStraightLeadForEitherEdge(
bool reverse, double rotation, int cornerIndex, bool incoming)
{
var part = MakePart(Square(), reverse);
part.Rotate(rotation);
var perimeter = Perimeter(part);
var count = perimeter.Entities.Count;
var outgoing = Assert.IsType<Line>(perimeter.Entities[cornerIndex]);
var corner = outgoing.StartPoint;
var entity = incoming ? perimeter.Entities[(cornerIndex + count - 1) % count] : outgoing;
var parameters = Parameters();
var preview = PreviewPierce(perimeter, corner, entity, parameters);
part.ApplySingleLeadIn(parameters, corner, entity, ContourType.External);
AssertStraightEntry(part, corner, Direction(outgoing));
AssertPoint(preview, SingleLead(part, SpecialLayers.Leadin).StartPoint);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ApplySingleLeadIn_ConvexCorner_IgnoresApproachAngle(bool incoming)
{
var part = MakePart(Square());
var perimeter = Perimeter(part);
var outgoing = Assert.IsType<Line>(perimeter.Entities[1]);
var entity = incoming ? perimeter.Entities[0] : outgoing;
var parameters = Parameters(approachAngle: 60);
part.ApplySingleLeadIn(parameters, outgoing.StartPoint, entity, ContourType.External);
AssertStraightEntry(part, outgoing.StartPoint, Direction(outgoing));
}
[Fact]
public void ApplySingleLeadIn_MidEdge_KeepsApproachAngle()
{
var part = MakePart(Square());
var perimeter = Perimeter(part);
var entity = Assert.IsType<Line>(perimeter.Entities[0]);
var point = entity.MidPoint;
var parameters = Parameters(approachAngle: 60);
var normal = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.External,
ContourCuttingStrategy.DetermineWinding(perimeter));
part.ApplySingleLeadIn(parameters, point, entity, ContourType.External);
AssertPoint(parameters.ExternalLeadIn.GetPiercePoint(point, normal),
SingleLead(part, SpecialLayers.Leadin).StartPoint);
}
[Theory]
[InlineData(90, true, false)]
[InlineData(150, true, false)]
[InlineData(160, true, true)]
[InlineData(170, false, false)]
[InlineData(170, false, true)]
[InlineData(179, false, true)]
public void ApplySingleLeadIn_FlatCorner_FallsBackToPerpendicularWithoutPierceClearance(
double interiorDegrees, bool straight, bool incoming)
{
// Corner at (10, 0): cut along +X, then turn left by 180 - interior degrees.
var turn = System.Math.PI - interiorDegrees * System.Math.PI / 180;
var corner = new Vector(10, 0);
var next = corner + new Vector(System.Math.Cos(turn), System.Math.Sin(turn)) * 5;
var part = MakePart(new[] { new Vector(0, 0), corner, next, new Vector(0, next.Y) });
var perimeter = Perimeter(part);
var outgoing = perimeter.Entities.OfType<Line>().Single(e => e.StartPoint == corner);
var entity = incoming ? perimeter.Entities.OfType<Line>().Single(e => e.EndPoint == corner) : outgoing;
var parameters = Parameters();
Assert.Equal(0.0625, parameters.PierceClearance);
part.ApplySingleLeadIn(parameters, corner, entity, ContourType.External);
if (straight)
{
AssertStraightEntry(part, corner, Direction(outgoing));
return;
}
var normal = ContourCuttingStrategy.ComputeNormal(corner, outgoing, ContourType.External,
ContourCuttingStrategy.DetermineWinding(perimeter));
AssertPoint(parameters.ExternalLeadIn.GetPiercePoint(corner, normal),
SingleLead(part, SpecialLayers.Leadin).StartPoint);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void ApplySingleLeadIn_ReflexCorner_BisectsNotch(bool reverse, bool pickFirst)
{
var notch = new Vector(4, 4);
var part = MakePart(LShape(), reverse);
var perimeter = Perimeter(part);
var touching = perimeter.Entities.OfType<Line>()
.Where(e => e.StartPoint == notch || e.EndPoint == notch).ToList();
Assert.Equal(2, touching.Count);
var entity = pickFirst ? touching[0] : touching[1];
var parameters = Parameters();
var preview = PreviewPierce(perimeter, notch, entity, parameters);
part.ApplySingleLeadIn(parameters, notch, entity, ContourType.External);
var lead = SingleLead(part, SpecialLayers.Leadin);
AssertPoint(notch, lead.EndPoint);
AssertPoint(notch + new Vector(1, 1).Normalize() * LeadLength, lead.StartPoint);
AssertPoint(preview, lead.StartPoint);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void ApplySingleLeadIn_ConvexCorner_LineLeadOutRunsOnAlongLastCutEdge(bool reverse, bool incoming)
{
var part = MakePart(Square(), reverse);
var perimeter = Perimeter(part);
var count = perimeter.Entities.Count;
var outgoing = Assert.IsType<Line>(perimeter.Entities[2]);
var lastCut = Assert.IsType<Line>(perimeter.Entities[1]);
var corner = outgoing.StartPoint;
var entity = incoming ? lastCut : outgoing;
var parameters = Parameters();
parameters.ExternalLeadOut = new LineLeadOut { Length = 0.1, ApproachAngle = 60 };
part.ApplySingleLeadIn(parameters, corner, entity, ContourType.External);
var leadOut = SingleLead(part, SpecialLayers.Leadout);
AssertPoint(corner, leadOut.StartPoint);
AssertPoint(corner + Direction(lastCut) * 0.1, leadOut.EndPoint);
}
[Fact]
public void ApplySingleLeadIn_CutoutCorner_KeepsBisector()
{
// Outside perimeter handling must not leak into cutouts.
var program = new Program(Mode.Absolute);
AddContour(program, Square());
AddContour(program, new[] { new Vector(2, 2), new Vector(4, 2), new Vector(4, 4), new Vector(2, 4) });
var part = new Part(new Drawing("cutout", program));
var cutout = Assert.Single(Profile(part).Cutouts);
var entity = cutout.Entities.OfType<Line>().First(e => e.StartPoint == new Vector(4, 4));
var parameters = Parameters();
parameters.InternalLeadIn = new LineLeadIn { Length = LeadLength, ApproachAngle = 90 };
part.ApplySingleLeadIn(parameters, entity.StartPoint, entity, ContourType.Internal);
var lead = SingleLead(part, SpecialLayers.Leadin);
AssertPoint(new Vector(4, 4) + new Vector(-1, -1).Normalize() * LeadLength, lead.StartPoint);
}
private static Vector PreviewPierce(Shape shape, Vector point, Entity entity, CuttingParameters parameters)
{
var leadIn = ContourCuttingStrategy.ResolveLeadIn(shape, point, entity, ContourType.External,
parameters.ExternalLeadIn, ContourCuttingStrategy.DetermineWinding(shape),
parameters.PierceClearance, out var normal);
return leadIn.GetPiercePoint(point, normal);
}
/// <summary>The lead-in runs on the first-cut edge's line and cutting continues along it.</summary>
private static void AssertStraightEntry(Part part, Vector corner, Vector direction)
{
var lead = SingleLead(part, SpecialLayers.Leadin);
AssertPoint(corner, lead.EndPoint);
AssertPoint(corner - direction * LeadLength, lead.StartPoint);
var geometry = part.Program.ToGeometry();
var firstCut = Assert.IsType<Line>(geometry
.SkipWhile(e => e.Layer != SpecialLayers.Leadin).Skip(1)
.First(e => SpecialLayers.IsMaterial(e.Layer)));
AssertPoint(corner, firstCut.StartPoint);
AssertPoint(direction, Direction(firstCut));
}
private static CuttingParameters Parameters(double approachAngle = 90) => new()
{
ExternalLeadIn = new LineLeadIn { Length = LeadLength, ApproachAngle = approachAngle },
};
private static Vector[] Square() => new[]
{
new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10),
};
private static Vector[] LShape() => new[]
{
new Vector(0, 0), new Vector(10, 0), new Vector(10, 4),
new Vector(4, 4), new Vector(4, 10), new Vector(0, 10),
};
private static Part MakePart(Vector[] perimeter, bool reverse = false)
{
var program = new Program(Mode.Absolute);
AddContour(program, reverse ? perimeter.Reverse().ToArray() : perimeter);
return new Part(new Drawing("perimeter-corner-test", program));
}
private static void AddContour(Program program, Vector[] vertices)
{
program.Codes.Add(new RapidMove(vertices[0]));
foreach (var point in vertices.Skip(1).Append(vertices[0]))
program.Codes.Add(new LinearMove(point));
}
private static ShapeProfile Profile(Part part) => new(part.Program.ToGeometry()
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList());
private static Shape Perimeter(Part part)
{
var perimeter = Profile(part).Perimeter;
Assert.True(perimeter.IsClosed());
return perimeter;
}
private static Vector Centroid(Shape shape)
{
var lines = shape.Entities.OfType<Line>().ToList();
var sum = lines.Aggregate(Vector.Zero, (total, line) => total + line.StartPoint);
return sum / lines.Count;
}
private static Vector Direction(Line line) => (line.EndPoint - line.StartPoint).Normalize();
private static Line SingleLead(Part part, Layer layer) => Assert.IsType<Line>(Assert.Single(
part.Program.ToGeometry().Where(e => e.Layer == layer)));
private static void AssertPoint(Vector expected, Vector actual)
{
Assert.Equal(expected.X, actual.X, 8);
Assert.Equal(expected.Y, actual.Y, 8);
}
}
@@ -0,0 +1,178 @@
using System.Text.Json;
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class EngineSelectionSettingsTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
private readonly string _path;
public EngineSelectionSettingsTests()
{
Directory.CreateDirectory(_directory);
_path = Path.Combine(_directory, "engine-selection.json");
}
public void Dispose() => Directory.Delete(_directory, recursive: true);
[Fact]
public void DefaultPath_UsesApplicationDataOpenNestDirectory()
{
Assert.Equal(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest", "engine-selection.json"),
EngineSelectionSettings.DefaultPath);
}
[Fact]
public void SaveThenLoad_RoundTripsPluginNameAsCamelCaseJson()
{
new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path);
var loaded = EngineSelectionSettings.Load(_path);
using var json = JsonDocument.Parse(File.ReadAllText(_path));
Assert.Equal("Opus55NestingEngine", loaded.EngineName);
Assert.Equal("Opus55NestingEngine", json.RootElement.GetProperty("engineName").GetString());
}
[Fact]
public void Save_OverwritesPreviousSelection()
{
new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path);
new EngineSelectionSettings { EngineName = "Strip" }.Save(_path);
Assert.Equal("Strip", EngineSelectionSettings.Load(_path).EngineName);
}
[Fact]
public void Save_CreatesMissingParentDirectory()
{
var path = Path.Combine(_directory, "nested", "engine-selection.json");
new EngineSelectionSettings { EngineName = "Strip" }.Save(path);
Assert.Equal("Strip", EngineSelectionSettings.Load(path).EngineName);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Save_EmptySelectionWritesDefault(string? engineName)
{
new EngineSelectionSettings { EngineName = engineName! }.Save(_path);
using var json = JsonDocument.Parse(File.ReadAllText(_path));
Assert.Equal("Default", json.RootElement.GetProperty("engineName").GetString());
}
[Fact]
public void Load_MissingFileReturnsDefaultWithoutCreatingFile()
{
var loaded = EngineSelectionSettings.Load(_path);
Assert.Equal("Default", loaded.EngineName);
Assert.False(File.Exists(_path));
Assert.Equal("Default", loaded.Resolve(new[] { "Default" }, out var message));
Assert.Null(message);
}
[Theory]
[InlineData("{ broken json")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("{}")]
[InlineData("{\"engineName\":null}")]
[InlineData("{\"engineName\":\" \"}")]
[InlineData("{\"engineName\":42}")]
public void Load_CorruptOrEmptySettingsReturnsDefault(string json)
{
File.WriteAllText(_path, json);
Assert.Equal("Default", EngineSelectionSettings.Load(_path).EngineName);
Assert.Equal(json, File.ReadAllText(_path));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("\0")]
public void Load_InvalidPathReturnsDefault(string? path)
{
Assert.Equal("Default", EngineSelectionSettings.Load(path!).EngineName);
}
[Fact]
public void Load_UnreadablePathReturnsDefault()
{
Directory.CreateDirectory(_path);
Assert.Equal("Default", EngineSelectionSettings.Load(_path).EngineName);
}
[Fact]
public void Load_IgnoresUnknownFieldsAndTrimsSelection()
{
File.WriteAllText(_path,
"""{"EngineName":" Opus55NestingEngine ","futureSetting":true}""");
Assert.Equal("Opus55NestingEngine", EngineSelectionSettings.Load(_path).EngineName);
}
[Theory]
[InlineData("Default")]
[InlineData("Strip")]
[InlineData("Vertical Remnant")]
[InlineData("Horizontal Remnant")]
[InlineData("Opus55NestingEngine")]
public void Resolve_AvailableSelectionUsesRegisteredCasingWithoutWarning(string engineName)
{
var settings = new EngineSelectionSettings { EngineName = engineName.ToLowerInvariant() };
var resolved = settings.Resolve(new[] { "Default", engineName }, out var message);
Assert.Equal(engineName, resolved);
Assert.Null(message);
}
[Fact]
public void Resolve_MissingPluginFallsBackWithVisibleMessageWithoutOverwritingSavedChoice()
{
new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path);
var settings = EngineSelectionSettings.Load(_path);
var resolved = settings.Resolve(new[] { "Default", "Strip" }, out var message);
Assert.Equal("Default", resolved);
Assert.Equal("Saved Auto Nest engine 'Opus55NestingEngine' is unavailable. Using Default.", message);
Assert.Equal("Opus55NestingEngine", settings.EngineName);
Assert.Equal("Opus55NestingEngine", EngineSelectionSettings.Load(_path).EngineName);
}
[Fact]
public void Load_DoesNotResolveBeforePluginDiscovery()
{
new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path);
var settings = EngineSelectionSettings.Load(_path);
var availableEngines = new List<string> { "Default" };
// The host loads settings independently, then supplies the completed registry.
availableEngines.Add("Opus55NestingEngine");
var resolved = settings.Resolve(availableEngines, out var message);
Assert.Equal("Opus55NestingEngine", resolved);
Assert.Null(message);
}
[Fact]
public void Save_UnwritablePathReportsFailureToCaller()
{
File.WriteAllText(_path, "not a directory");
var path = Path.Combine(_path, "engine-selection.json");
Assert.Throws<IOException>(() => new EngineSelectionSettings().Save(path));
}
}
+209
View File
@@ -0,0 +1,209 @@
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Tests.Data;
public class NestDefaultsTests : IDisposable
{
private readonly string _testDir;
private readonly string _path;
public NestDefaultsTests()
{
_testDir = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
_path = Path.Combine(_testDir, "defaults.json");
}
public void Dispose()
{
if (Directory.Exists(_testDir))
Directory.Delete(_testDir, true);
}
[Fact]
public void Save_ThenLoad_RoundTrips()
{
var original = new NestDefaults
{
Units = Units.Millimeters,
Size = new Size(1220, 2440),
Quadrant = 3,
PartSpacing = 2.5,
EdgeSpacing = new Spacing(1.5, 3, 1.5, 3),
};
original.Save(_path);
var loaded = NestDefaults.Load(_path, out var status);
Assert.Equal(NestDefaultsStatus.Ok, status);
Assert.Equal(Units.Millimeters, loaded.Units);
Assert.Equal(original.Size, loaded.Size);
Assert.Equal(3, loaded.Quadrant);
Assert.Equal(2.5, loaded.PartSpacing);
Assert.Equal(original.EdgeSpacing.Left, loaded.EdgeSpacing.Left);
Assert.Equal(original.EdgeSpacing.Bottom, loaded.EdgeSpacing.Bottom);
Assert.Equal(original.EdgeSpacing.Right, loaded.EdgeSpacing.Right);
Assert.Equal(original.EdgeSpacing.Top, loaded.EdgeSpacing.Top);
}
[Fact]
public void Save_CreatesMissingDirectory()
{
var nested = Path.Combine(_testDir, "nested", "defaults.json");
new NestDefaults().Save(nested);
Assert.True(File.Exists(nested));
}
[Fact]
public void Load_MissingFile_ReturnsFallback()
{
var loaded = NestDefaults.Load(_path, out var status);
Assert.Equal(NestDefaultsStatus.Missing, status);
AssertFallback(loaded);
}
[Fact]
public void Load_CorruptJson_ReturnsFallbackButReportsInvalid()
{
File.WriteAllText(_path, "{ this is not json");
var loaded = NestDefaults.Load(_path, out var status);
Assert.Equal(NestDefaultsStatus.Invalid, status);
AssertFallback(loaded);
}
[Fact]
public void Load_PartialFile_MergesPerField()
{
File.WriteAllText(_path, """{ "units": "millimeters", "partSpacing": 4 }""");
var loaded = NestDefaults.Load(_path, out var status);
Assert.Equal(NestDefaultsStatus.Ok, status);
Assert.Equal(Units.Millimeters, loaded.Units);
Assert.Equal(4, loaded.PartSpacing);
// Untouched fields keep fallback values.
Assert.Equal(new Size(100, 100), loaded.Size);
Assert.Equal(1, loaded.Quadrant);
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
}
[Fact]
public void Load_OutOfRangeValues_FallBackPerField()
{
File.WriteAllText(
_path,
"""
{
"units": "furlongs",
"size": { "width": -50, "length": 100 },
"quadrant": 9,
"partSpacing": -1,
"edgeSpacing": { "left": 1, "bottom": -2, "right": 1, "top": 1 }
}
"""
);
var loaded = NestDefaults.Load(_path, out var status);
// The file parses; only the invalid values fall back, so no warning.
Assert.Equal(NestDefaultsStatus.Ok, status);
Assert.Equal(Units.Inches, loaded.Units);
Assert.Equal(new Size(100, 100), loaded.Size);
Assert.Equal(1, loaded.Quadrant);
Assert.Equal(1, loaded.PartSpacing);
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
}
[Fact]
public void Load_NonFiniteValues_FallBack()
{
File.WriteAllText(_path, """{ "partSpacing": 1e400 }""");
var loaded = NestDefaults.Load(_path);
// 1e400 deserializes to Infinity, which is rejected.
Assert.Equal(1, loaded.PartSpacing);
}
[Fact]
public void Load_UnknownFieldsAndFutureVersion_Ignored()
{
File.WriteAllText(
_path,
"""
{
"version": 2,
"units": "inches",
"partSpacing": 2,
"futureFeature": { "enabled": true },
"material": { "name": "steel" }
}
"""
);
var loaded = NestDefaults.Load(_path, out var status);
Assert.Equal(NestDefaultsStatus.Ok, status);
Assert.Equal(2, loaded.PartSpacing);
}
[Fact]
public void FromNest_CapturesUnitsAndPlateDefaults()
{
var nest = new Nest
{
Units = Units.Millimeters,
};
nest.PlateDefaults.Size = new Size(60, 120);
nest.PlateDefaults.Quadrant = 2;
nest.PlateDefaults.PartSpacing = 0.5;
nest.PlateDefaults.EdgeSpacing = new Spacing(2, 2, 2, 2);
var captured = NestDefaults.FromNest(nest);
Assert.Equal(Units.Millimeters, captured.Units);
Assert.Equal(new Size(60, 120), captured.Size);
Assert.Equal(2, captured.Quadrant);
Assert.Equal(0.5, captured.PartSpacing);
Assert.Equal(new Spacing(2, 2, 2, 2), captured.EdgeSpacing);
}
[Fact]
public void ApplyTo_SetsUnitsAndPlateDefaults_AndDoesNotAliasSourceNest()
{
var source = new Nest();
source.PlateDefaults.Size = new Size(48, 96);
source.PlateDefaults.EdgeSpacing = new Spacing(1.25, 1.25, 1.25, 1.25);
var defaults = NestDefaults.FromNest(source);
// Mutating the source nest afterwards must not change the capture.
source.PlateDefaults.Size = new Size(1, 1);
source.PlateDefaults.EdgeSpacing = new Spacing(9, 9, 9, 9);
var target = new Nest();
defaults.ApplyTo(target);
Assert.Equal(new Size(48, 96), target.PlateDefaults.Size);
Assert.Equal(
new Spacing(1.25, 1.25, 1.25, 1.25),
target.PlateDefaults.EdgeSpacing
);
// And the applied target owns its own values too.
target.PlateDefaults.Size = new Size(2, 2);
Assert.Equal(new Size(48, 96), defaults.Size);
}
private static void AssertFallback(NestDefaults loaded)
{
Assert.Equal(Units.Inches, loaded.Units);
Assert.Equal(new Size(100, 100), loaded.Size);
Assert.Equal(1, loaded.Quadrant);
Assert.Equal(1, loaded.PartSpacing);
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
}
}
@@ -0,0 +1,69 @@
using System.Reflection;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Diagnostics;
public class ConsolePostVerificationTests
{
[Fact]
public void RealConsoleBlocksOutputUntilExplicitRiskSwitchAndNeverRemembersConsent()
{
var directory = Path.Combine(Path.GetTempPath(), "opennest-post-verification-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
try
{
var input = Path.Combine(directory, "job.nest");
var output = Path.Combine(directory, "job.cnc");
var nest = new Nest("VerificationFixture");
var program = new OpenNest.CNC.Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(2, 0));
program.Codes.Add(new LinearMove(2, 2));
program.Codes.Add(new LinearMove(0, 2));
program.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("square", program);
nest.Drawings.Add(drawing);
var plate = nest.CreatePlate();
plate.Size = new Size(10, 10);
plate.Parts.Add(new Part(drawing));
Assert.True(new NestWriter(nest).Write(input));
var args = new[] { input, "--quantity", "1", "--no-save", "--post", "Cincinnati CL-707",
"--posts-dir", AppContext.BaseDirectory, "--post-output", output };
Assert.Equal(1, Run(args));
Assert.False(File.Exists(output));
File.WriteAllText(output, "existing CNC must survive refusal");
Assert.Equal(1, Run(args));
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
var colliding = args.Where(arg => arg != "--no-save")
.Concat(new[] { "--output", Path.Combine(directory, ".", "job.cnc") }).ToArray();
Assert.Equal(1, Run(colliding));
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
Assert.Equal(1, Run(colliding.Append("--acknowledge-post-risks").ToArray()));
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
Assert.Equal(0, Run(args.Append("--acknowledge-post-risks").ToArray()));
var posted = File.ReadAllText(output);
Assert.Contains("M30", posted);
Assert.Contains("VerificationFixture", posted);
Assert.Equal(1, Run(args));
Assert.Equal(posted, File.ReadAllText(output));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
private static int Run(string[] args)
{
var entry = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
.GetMethod("Run", BindingFlags.Public | BindingFlags.Static)!;
return (int)entry.Invoke(null, new object[] { args })!;
}
}

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