217 Commits
Author SHA1 Message Date
aj 5bf3c5f9aa docs(packing): clarify PackingId is caller-supplied and clone-preserved 2026-09-30 07:40:54 -04:00
aj 984d31a391 fix(packing): preserve item type and separate packing identity in clones 2026-09-30 07:24:39 -04:00
aj 4f46fa15ea style(packing): normalize circle-packing file encoding 2026-09-30 07:24:02 -04:00
aj 38324628b0 test(editor): prove per-operation highlighting with a status sentinel
The reorder event snapshot could previously show the reverse pass's
stale timeout label. Set a sentinel before each click so the
ProgramChanged handler can only observe the label applied by the
current operation's highlighting pass.
2026-09-30 01:39:26 -04:00
aj 132bb07bc9 test(editor): make control assertions handle-state independent and order-proving
Quality review of 50884da found the unshown RichTextBox oracle compared
cached CRLF text against the highlighted editor's native LF-normalized
text, which would fail on first Windows execution. Compare generated
text with normalized line endings on both sides instead, capture the
status label and preview state inside the ProgramChanged handler to
prove fallback-before-notify ordering, document the HighlightSpan
UTF-16 and rule-index contract, and pin the null-text argument check.
2026-09-30 01:29:11 -04:00
aj 50884daca1 fix(editor): bound highlighting without interrupting program updates 2026-09-30 00:14:43 -04:00
aj fc53c7732e fix(ui): format stock sizes invariantly and bound validation notices
Address Task 3b quality review:
- FormatSize now emits invariant decimal notation so a comma-decimal
  locale cannot produce stock text its own invariant parser rejects.
- Validation notices embed a bounded single-line preview instead of the
  full cell value.
- Documented GetPlateOptions' all-or-nothing throwing contract.
- STA test helpers run the worker as a background thread with a
  diagnostic timeout message; added a de-DE fractional stock round-trip
  regression.
2026-09-29 23:29:20 -04:00
aj ab1f3a28d1 fix(import): abort failed CAD text extraction 2026-09-29 22:31:26 -04:00
aj 43ebd61fdf fix(ui): reject timed-out stock and shape input 2026-09-29 22:27:05 -04:00
aj bf608fa271 test(math): name fraction culture coverage accurately 2026-09-29 22:02:32 -04:00
aj fa5e5a8e29 fix(io): bound bend-note regex execution without partial imports 2026-09-29 21:41:27 -04:00
aj 388816b015 fix(math): bound fraction regex execution 2026-09-29 21:41:20 -04:00
aj 3bdefb1d1c docs(engines): document built-in engines, renames and change rules
Adds docs/nesting-engines.md: which engine suits which jobs, the old plug-in
names the registry maps, retired engines, and the rule that an engine change
lands only when it beats that engine's current benchmark result with every
layout valid. README, release and automatic-nesting docs drop the bundled
plug-in wording; AGENTS.md now places built-in engines in
OpenNest.Engine/NestingEngines and keeps only external plug-ins out of the
solution.
2026-09-29 21:19:27 -04:00
aj 7f648e6400 build(release): package built-in engines instead of external plug-ins
Rectangles and Irregular now ship inside OpenNest.Engine.dll, so the Windows
package no longer fetches, tests and bundles OpenNest-Engines at a pinned
commit. Removes scripts/external-engines.json and the Engines/ folder,
manifest and license from the package; build-info.json drops enginesCommit.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Tools > Nest Defaults... edits the file directly (new dialog).
- Tools > Save Current Plate as Defaults captures the active plate with
  no dialog (visible when a nest is open).
- New_Click no longer unzips a template; a corrupt defaults file warns
  once per session and falls back to built-in values.
- First run converts an existing NestTemplatePath .nstdot to
  defaults.json and clears the legacy setting (kept readable on a
  failed conversion); the setting itself stays, marked legacy.
- Save As no longer offers the .nstdot filter; SaveTemplate removed.
- BomImportForm and OptionsForm template plumbing removed in favor of
  the shared NestDefaults store.
2026-09-28 08:20:49 -04:00
aj bdb78a689d feat(defaults): JSON nest defaults store with per-field fallback
Replaces the .nstdot nest-template mechanism for new-nest plate
defaults. NestDefaults persists units, plate size, quadrant, part
spacing, and edge spacing to a single JSON file (DefaultPath:
%APPDATA%/OpenNest/defaults.json). Load never throws: a missing,
corrupt, or partially valid file degrades field-by-field to the
built-in fallback values, and unknown fields or a future version
number are ignored.
2026-09-28 07:09:52 -04:00
aj 3f89f8f5dd feat(ui): Tools > Expand Spacing for selected parts
Mirrors PushSelected: PlateView/SelectionManager.ExpandSelected runs
Expander.Expand on the selection, marks parts dirty, regenerates cut
offs, and reports achieved spacing plus blocked-pair count in the
status bar. Menu item joins Align Selected's enable surface.
2026-09-28 03:17:55 -04:00
aj 3cab90747a feat(expand): plateview spacing expander
Grows part-to-part spacing of a selected group with the work area and
non-selected parts as hard boundaries. Doubling + bisection search over
the target spacing; Gauss-Seidel straight-line relaxation with anchor
mover policy (later-index selected part moves, first selection never).
Overlapping input is separated along penetration MTVs instead of being
rejected. Cancel/failure never mutates part positions. Clearance gains
BoundaryDistance for ring-pair gaps (part-in-cutout legality).
2026-09-28 03:05:45 -04:00
aj fff3bef4e4 feat(geom): signed clearance kernel between polygons
Omnidirectional minimum distance with separating direction (positive)
and penetration depth with minimum-translation direction (negative),
for the PlateView spacing expander. Overlap verdict defers to
Collision.HasOverlap so kernels never disagree. Basis for the fixed-s
separation solver.
2026-09-27 23:36:26 -04:00
aj 2b78fb3a75 fix(geometry): resolve blocking contacts across directional slide paths 2026-09-27 23:24:31 -04:00
aj a04460c57b refactor(ui): drop duplicate plate preview from nesting progress dialog
The dialog rendered the same best-parts layout as transparent ghost
parts already shown live on the main plate view, and its embedded view
reset zoom on every improvement while lacking placed parts, work-area
and cut-off context. The progress dialog is now a compact stats strip
(266px wide) with the plate counter preserved for multi-plate runs.
Removes the never-called SetStationaryParts stationary-preview path
along with PreviewPlate/UpdatePreview/CreatePreviewPlate.
2026-09-27 20:43:34 -04:00
aj 27948ba8cf docs(posts): replace bundled manual with implementation references 2026-09-27 19:52:25 -04:00
aj 1fdede396b ci(release): bundle and verify external nesting engines 2026-09-27 19:11:14 -04:00
aj 1b9988a1ba ci(release): build and verify Windows desktop packages 2026-09-27 18:57:38 -04:00
aj ca1902e075 fix(bestfit): stop at internal arc tangency 2026-09-27 16:35:53 -04:00
aj bc5fd86996 test(fill): characterize unchanged-row validation reuse 2026-09-27 15:10:01 -04:00
aj 1b23ad79f2 perf(fill): reuse part triangulations within an overlap check
After 1b, triangulating both polygons on every pair was the largest
remaining overlap cost (27% of main-thread samples on the corpus job).
PartOverlapChecker now triangulates each part at most once per check,
lazily after the bounding-box gate, and passes the triangles to a new
internal Collision.HasOverlap overload that runs the unchanged
OverlapRegions body. Triangles are only read by clipping and hole
subtraction, so reuse gives identical verdicts.

Verification:
- 49,000 seeded decisions with reused triangles match LegacyCollision;
  triangles stay bit-identical to a fresh triangulation afterwards.
- Debug PolygonTriangulations: 246 -> 40 and 64 -> 36 per grid check;
  sharing triangles per Program instead fails 23 tests.
- Corpus job (169 parts, --engines Default --parallel 1): median
  13,398 -> 12,702 ms over 4+4 alternating runs vs 1b, identical
  outcomes; serialized layout byte-identical to the base.

Also records the Follow-up B' (Slices 1a, 1b, 2a) measurements in
docs/performance/fill-performance.md.
2026-09-27 13:49:46 -04:00
aj a27290a29c perf(fill): prepare overlap polygons once per check
Both HasOverlappingParts loops rebuilt each part's polygon from its
Program on every pair. PartOverlapChecker prepares each distinct Program
(reference identity) once and each part's world polygon once per call,
then uses the overlap-only Collision.HasOverlap. Loop order, bounding-box
prefilter, early exit and returned indices are unchanged; Part.Intersects
shares the material/polygon recipe and still returns crossing points.

Verification:
- Frozen LegacyPartOverlap differential (original Intersects and both
  loops): verdicts, indices and world polygons bit-identical across fill
  grids, patterns, touching/epsilon gaps, scribe/rapid/empty programs.
- Debug OverlapPolygonPreparations: 246 -> 1 and 64 -> 2 per check.
- Corpus job (169 parts, --engines Default --parallel 1, with 1a):
  median 18,885 -> 13,464 ms over 4+4 alternating runs, identical
  outcomes; serialized layout byte-identical to the base.
2026-09-27 13:34:04 -04:00
aj f44a1a3d2c style(console): consolidate usage text into one multiline write 2026-09-27 13:17:35 -04:00
aj 2b5485f6cf perf(core): skip unused crossing points in overlap-only checks
Collision.HasOverlap only needs the verdict, but it went through Check,
which also collected crossing points. Triangulation, clipping and hole
subtraction now live in one private OverlapRegions method shared by Check
and HasOverlap, so verdict arithmetic stays single-sourced; Check output is
unchanged.

Tests: a frozen copy of the previous Collision is the oracle. 50,000 seeded
HasOverlap verdicts and 2,400 bitwise Check results match it, plus
containment, contact, hole and input-immutability cases. A Debug-only
PerfCounters.CrossingPointScans counter proves HasOverlap no longer scans.
Malformed polygons with null outer vertices still throw when the bounding
boxes overlap (now ArgumentNullException from triangulation rather than
NullReferenceException from ToLines).

Measured (Release, same harness in both trees): about 44% less time per
overlap-only polygon check, allocations 10.0 -> 7.9 MB per 155-pair sweep.
The 169-part serialized corpus layout is byte-identical.
2026-09-27 12:54:20 -04:00
aj a98a49c00a test(fill): add opt-in overlap-check micro benchmark
Times Collision.HasOverlap over the box-overlapping neighbour pairs of two
FillLinear grids, and the FillHelpers.HasOverlappingParts grid checks
themselves. Opt-in via OPENNEST_RUN_FILL_PERF=1 (Category=FillPerformance).
Uses only APIs that predate the overlap-check work, so the same file can be
copied into a before tree for same-harness comparisons.
2026-09-27 12:54:20 -04:00
aj fdbc1fb99f style(console): combine adjacent output with readable multiline strings 2026-09-27 12:48:52 -04:00
aj 82feb78b0f feat(posts): add Cincinnati CI Fiber (TF5200) post-processor
New post-processor plugin OpenNest.Posts.CincinnatiCIFiber for the CI
Fiber laser family (nLight CLX / Beckhoff TF5200 / Precitec ProCutter,
e.g. the CI Fiber 4020 8kW). Named by machine family, not table size.

Emits the machine program contract of the Cincinnati-supplied sample NC
(12992-4SS_NEST.nc): V.E.* header, restart jump, per-part V.E.R4 blocks,
per-contour N labels with V.E.R3, skippable /L macro lines (L0/L2+G41
interior, L4+G42 exterior, L6 cut-on, ZHSOFF cut-end), G162 incremental
arc I/J, trimmed 3-decimal spaceless coordinates, CRLF, M50/M30/%.

Contour classification (interior vs exterior) derives from the material
side of the closed cut path, not hardcoded winding. SubProgramCall holes
are flattened to sheet coordinates (rotation-safe). Arc lead-ins are
rejected per TF5200 13.2.4.1 (first motion block after G41/G42 selection
must be linear). Table envelope (default 160.25 x 81.25 in) validated.

Tests: structure golden on a square-with-hole nest, rotated-hole flatten,
coordinate format, arc-lead-in rejection, table validation, suppressed/
scribe skipping, plus a SkippableFact regression against the real sample
NC (109 parts, 2071 contours, L2=1962, L4=109, perimeter vertices match
within 0.001). Fixtures configure through OpenNest.Tests/test-config.json
and the regression skips when absent.
2026-09-27 04:10:22 -04:00
aj c825f40213 refactor(posts): move post-processor projects under Posts/
Group the Cincinnati and GravographIS plugin projects in a Posts/
folder so new machine posts have one home. Project names, namespaces,
and the runtime Posts/ deploy target are unchanged; only relative
paths in the solution and project references move.
2026-09-26 22:35:23 -04:00
aj 0df2587cf2 perf(fill): reuse offset geometry for translated copies
FillLinear re-prepared offset perimeter geometry (ConvertProgram ->
ShapeProfile -> OffsetOutward) for every part it measured, although
tiled copies share one Program and differ only by Location. A CPU
profile of a 169-part Default job put 62% of wall time there.

Prepare each distinct Program (reference identity) once per public
Fill/FillRow call in local frame, then clone and translate for each
location. The cache is created per call and passed down privately
because FillHelpers.FillPattern calls Fill concurrently on one
instance. PartGeometry gains a local-frame Program overload that the
Part overload now delegates to.

Evaluation order, lazy preparation, fallbacks and tiling are
unchanged. Differential tests against a frozen copy of the previous
FillLinear check bitwise equality, including concurrent calls; Debug
work tests pin preparation counts. With the thread pool capped at one
worker, before/after whole-job layouts are byte-identical. The
Default corpus job median drops from 40,715 to 18,810 ms.
2026-09-26 20:24:34 -04:00
aj 094c4c196b fix(bestfit): never reject pair candidates by utilization
Thin-framed, hollow, or concave parts (e.g. SULLYS-035's frame) have
inherently low part-to-bbox utilization yet nest tightly, so the 30%
MinUtilization floor wrongly dropped every candidate for them. Pair
quality is judged by the rotated pair bounding-box area the results
are already sorted on; utilization now only ever serves as the
high-aspect exception (UtilizationOverride), never as a rejection.

Adds a hollow-frame helper plus regression tests that kept pairs
exist with low utilization and results stay sorted by pair area.
2026-09-26 14:14:44 -04:00
aj 77b729e58c docs(readme): drop opt-in fill performance measurements section
Keeps the README approachable for new users; microbenchmark details
remain in docs/performance/ for maintainers.
2026-09-26 13:00:32 -04:00
aj 1d1c60daa7 docs(fill): record combined initial-batch acceptance 2026-09-26 11:25:17 -04:00
aj 1e8e532063 perf(fill): skip feature extraction without an angle model 2026-09-26 10:02:15 -04:00
aj d70505b7c0 docs(fill): sync shared guidance for scalar-only extraction 2026-09-26 07:36:25 -04:00
aj 8188533d72 perf(ml): support scalar-only angle features 2026-09-26 00:02:28 -04:00
aj 6863c8bdb1 docs(fill): clarify Task 3 evidence retention 2026-09-25 21:46:31 -04:00
aj 4553f8afad perf(fill): avoid redundant bounds recomputation 2026-09-25 21:45:03 -04:00
aj 4ec92c95ec docs: share coding-agent guidance through AGENTS.md 2026-09-25 20:03:42 -04:00
aj b2c864a328 docs(fill): synchronize performance workflow guidance 2026-09-25 19:59:02 -04:00
aj 6efa6b1117 perf(fill): remove discarded extents pitch geometry 2026-09-25 19:52:19 -04:00
aj cec7396da6 perf(fill): avoid unused scores with custom comparers 2026-09-25 17:18:15 -04:00
aj b318950a54 perf(fill): short-circuit default comparisons by count 2026-09-25 16:43:09 -04:00
ajandClaude Opus 5.5 1b862dc1a8 fix(engine): allow 0.0005 spacing slack in layout validation
Layouts placed exactly at the part spacing can land ~1e-4 short once
rotated, rounded (e.g. PEP's 4-decimal exports) and snapped to the
Clipper grid, so both validators rejected layouts that were correct in
practice. NestTolerances.SpacingSlack (0.0005, far below anything a
cutting machine resolves) is now subtracted from the spacing by
NestLayoutCheck's inflation and NestJobPlacementValidator's edge-distance
check. The frozen LegacyNestValidator takes the same rule so the
equivalence tests keep comparing like with like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:19:04 -04:00
aj d1f3907505 Merge branch 'fix/mcp-config-path' 2026-09-25 14:19:01 -04:00
ajandClaude Opus 5.5 4fb924b03a chore: restore LF line endings in CLAUDE.md
39db4dc committed CLAUDE.md with CRLF endings while the repo stores it
as LF (text=auto), turning a one-line doc addition into a whole-file
diff. Renormalized; the content is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:12:40 -04:00
ajandClaude Opus 5.5 39db4dc10f feat(ui): route Auto Nest through whole-job engines
StockLadder and Engines/ plug-ins only implement INestingEngine.Solve, so
selecting them in Auto Nest had no path to run. MainForm now solves the
whole job through JobEngineNest when the selected engine is not a
built-in fill strategy, feeding NestJobProgress into NestProgressForm and
binding the result poses back onto the nest's own drawings. Whole-job
engines throw on cancel rather than returning a partial layout, so the
progress form hides Accept for these runs. Built-in strategies keep the
existing per-plate fill path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:03:10 -04:00
ajandClaude Opus 5.5 23dd99fa2f feat(benchmark): add --progress logging for engine solves
Long whole-job solves ran silently, so there was no way to tell a slow
engine from a hung one until the timeout fired. --progress hands each
solve a JobProgressLog that prints [job/engine] lines for start, finish
(or failure/timeout), every plate commit, and candidate evaluations
throttled to one line per 2 s so parallel runs stay readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:03:10 -04:00
695ccc0a3b perf(engine): use cached triangulations in the layout check
The benchmark validator and every engine test re-triangulated both parts
for each nearby pair. NestLayoutCheck now uses TriangulatedRegion, with
Collision.HasOverlap as the fallback when it cannot decide. Verdicts are
unchanged (the frozen-validator equivalence tests still pass); validating
100 discs went from 1,254 ms to 94 ms.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:05:43 -04:00
aj e65f849c7a refactor(engine): remove legacy nesting engine surface 2026-09-22 14:25:46 -04:00
ajandClaude Opus 5.5 1be0904506 fix(mcp): make opennest server launchable on any machine
.mcp.json pointed at C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd, a
profile path and wrapper script that only existed on one machine, so the
server failed with CONNECTION_CLOSED everywhere else. Launch the published
exe via ${USERPROFILE} instead.

Also route console logging to stderr: the host's default console logger
wrote to stdout, interleaving log lines with the JSON-RPC stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 13:02:28 -04:00
ajandClaude Sonnet 5 98c4929d14 Merge branch 'fix/pairs-overlap'
Fixes Part.Clone() double-counting baked drawing rotation, which caused
overlapping placements when tiling interlocking pairs for drawings needing
canonical-frame axis correction.

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes RunAsync_FiniteStockExhaustion_PreservesUnplacedRequirementAndLockedRotation.

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

Fixes the mixed-stock NestRunner tests.

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

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

Fixes the three NestInvarianceTests.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 08:12:10 -04:00
aj f0fe79f0f1 Merge remote-tracking branch 'origin/master' 2026-09-19 07:44:40 -04:00
aj a68e252ac7 docs: describe whole-job engine architecture 2026-09-18 20:18:24 -04:00
aj bc6bcae419 docs: document whole-job nesting contracts and migration boundaries 2026-09-18 16:27:32 -04:00
aj 02141f6ca5 refactor(engine): separate plate placement from job allocation 2026-09-18 08:38:23 -04:00
aj a9e0f8a1d4 Rework OpenNest.Benchmark into a full multi-plate, multi-size nest
Previously each job fixed one plate size and ran a single Nest() call,
which doesn't reflect the actual problem: a real job is fulfilled
across however many plates are needed, drawn from a pool of standard
sheet sizes, not forced onto one fixed sheet.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-06 23:15:18 -04:00
803 changed files with 77055 additions and 13296 deletions
+68
View File
@@ -0,0 +1,68 @@
# Unified code style for OpenNest.
# Canonical formatter: dotnet format.
# Format only the files or directories being changed:
# dotnet format OpenNest.sln --include path/to/changed-file.cs
# dotnet format OpenNest.sln --include path/to/changed-file.cs --verify-no-changes
# These settings are used by dotnet format and IDE auto-formatting
# (VS / Rider / VS Code).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{csproj,props,targets,xml,config,manifest}]
indent_style = space
indent_size = 2
[*.{json,yml,yaml}]
indent_style = space
indent_size = 2
[*.{cs,csx}]
indent_style = space
indent_size = 4
# dotnet format cannot re-wrap source to this limit, but IDEs can surface it
# as a visual guide and analyzers can flag hard violations.
max_line_length = 100
# --- Using directives (System first, outside the namespace) ---
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning
# --- Brace placement: Allman (opening brace on its own line) ---
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# --- Spacing ---
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_after_cast = false
csharp_space_after_comma = true
csharp_space_before_comma = false
# --- Code style preferences ---
# Project rule: always use var for locals (see CLAUDE.md).
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = true:suggestion
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:silent
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+3
View File
@@ -0,0 +1,3 @@
# Commits whose changes git blame should skip (whitespace-only sweeps).
# Enable locally: git config blame.ignoreRevsFile .git-blame-ignore-revs
aec052306234e3c4313c0ee8905e2557d3c3671b
+90
View File
@@ -0,0 +1,90 @@
name: Windows release build
on:
push:
branches: ['release/**']
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Release version (for example 0.3.0)'
required: true
type: string
permissions:
contents: read
jobs:
windows:
runs-on: windows-2022
timeout-minutes: 30
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Resolve version
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
$version = $env:INPUT_VERSION
if ($env:GITHUB_EVENT_NAME -eq 'push') {
$version = $env:GITHUB_REF_NAME -replace '^release/', '' -replace '^v', ''
}
if ($version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
throw "Expected an X.Y.Z version, not '$version'."
}
"RELEASE_VERSION=$version" >> $env:GITHUB_ENV
- name: Build solution
run: |
dotnet build OpenNest.sln -c Release
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
- name: Run all test projects on Windows
run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
}
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw 'Debug tests failed.' }
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION
- name: Verify overwrite protection
run: |
$zip = Get-ChildItem artifacts/*.zip
$before = (Get-FileHash $zip.FullName).Hash
$rejected = $false
try { ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION }
catch {
if ($_.Exception.Message -notlike 'Refusing to overwrite existing output:*') { throw }
$rejected = $true
}
if (-not $rejected) { throw 'Existing output was not rejected.' }
if ((Get-FileHash $zip.FullName).Hash -ne $before) { throw 'Existing ZIP changed.' }
Write-Host 'PASS: existing release package preserved.'
- name: Upload verified release candidate
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: OpenNest-${{ env.RELEASE_VERSION }}-win-x64
path: |
artifacts/*.zip
artifacts/*.sha256
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-test-results
path: TestResults/*.trx
if-no-files-found: warn
retention-days: 14
+10 -1
View File
@@ -11,6 +11,7 @@
*.userprefs *.userprefs
# Build results # Build results
/artifacts/
[Dd]ebug/ [Dd]ebug/
[Dd]ebugPublic/ [Dd]ebugPublic/
[Rr]elease/ [Rr]elease/
@@ -206,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
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"mcpServers": { "mcpServers": {
"opennest": { "opennest": {
"command": "cmd", "command": "${USERPROFILE}/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"] "args": []
} }
} }
} }
+62
View File
@@ -0,0 +1,62 @@
# OpenNest agent instructions
Shared instructions; keep `CLAUDE.md` as the thin `@AGENTS.md` import.
OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform libraries.
## Working rules
- 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 and test
```sh
# Full solution: Windows
dotnet build OpenNest.sln
# Cross-platform suites: run independently on Linux/macOS/Windows
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
# Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
```
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`.
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.
## Project map and boundaries
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
## Geometry and ownership safeguards
- 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.
Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md)
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md)
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
+1 -122
View File
@@ -1,122 +1 @@
# CLAUDE.md @AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
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.
## Build
This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-windows`. Build with:
```bash
dotnet build OpenNest.sln
```
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
## Architecture
Eight projects form a layered architecture:
### OpenNest.Core (class library)
Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `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 with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine.
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
- **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.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts.
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
- `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`.
### OpenNest.Console (console app, depends on Core + Engine + IO)
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
### 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.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.
- **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.
## 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 `CLAUDE.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.
**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.Nfp` (NFP-based nesting, not yet integrated), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`.
- `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`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
+6
View File
@@ -6,17 +6,23 @@ namespace OpenNest.Api;
public class NestRequest public class NestRequest
{ {
public IReadOnlyList<NestRequestPart> Parts { get; init; } = []; public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
/// <summary> /// <summary>
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback; /// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
/// an empty list deliberately means no stock is available. /// an empty list deliberately means no stock is available.
/// </summary> /// </summary>
public IReadOnlyList<NestRequestPlate> Plates { get; init; } public IReadOnlyList<NestRequestPlate> Plates { get; init; }
public Size SheetSize { get; init; } = new(60, 120); public Size SheetSize { get; init; } = new(60, 120);
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary> /// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
public string PlacementStrategy { get; init; } = "Default"; public string PlacementStrategy { get; init; } = "Default";
/// <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;
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary> /// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
public NestStrategy Strategy { get; init; } = NestStrategy.Auto; public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default; public CutParameters Cutting { get; init; } = CutParameters.Default;
+1
View File
@@ -7,6 +7,7 @@ public class NestRequestPlate
{ {
public string Id { get; init; } public string Id { get; init; }
public Size Size { get; init; } public Size Size { get; init; }
/// <summary>Available physical sheets; null means unlimited.</summary> /// <summary>Available physical sheets; null means unlimited.</summary>
public int? Quantity { get; init; } public int? Quantity { get; init; }
public double PartSpacing { get; init; } public double PartSpacing { get; init; }
+46 -17
View File
@@ -5,6 +5,7 @@ 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.Engine.Jobs;
using OpenNest.IO; using OpenNest.IO;
namespace OpenNest.Api; namespace OpenNest.Api;
@@ -18,20 +19,28 @@ 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;
public int SheetCount { get; init; } public int SheetCount { get; init; }
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary> /// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
public double Utilization { get; init; } public double Utilization { get; init; }
public TimeSpan CutTime { get; init; } public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; } public TimeSpan Elapsed { get; init; }
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary> /// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
public NestJobStatus? Status { get; init; } public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; } public NestJobStopReason? StopReason { get; init; }
/// <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; } = [];
@@ -43,7 +52,7 @@ public class NestResponse
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true, WriteIndented = true,
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields. IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
Converters = { new JsonStringEnumConverter() } Converters = { new JsonStringEnumConverter() },
}; };
public async Task SaveAsync(string path) public async Task SaveAsync(string path)
@@ -61,7 +70,9 @@ public class NestResponse
var responseEntry = zip.CreateEntry("response.json"); var responseEntry = zip.CreateEntry("response.json");
await using (var stream = responseEntry.Open()) await using (var stream = responseEntry.Open())
{ {
await JsonSerializer.SerializeAsync(stream, new NestResponseArchiveDto await JsonSerializer.SerializeAsync(
stream,
new NestResponseArchiveDto
{ {
SchemaVersion = CurrentSchemaVersion, SchemaVersion = CurrentSchemaVersion,
SheetCount = SheetCount, SheetCount = SheetCount,
@@ -70,10 +81,18 @@ public class NestResponse
ElapsedTicks = Elapsed.Ticks, ElapsedTicks = Elapsed.Ticks,
Status = Status, Status = Status,
StopReason = StopReason, StopReason = StopReason,
Fulfillment = Fulfillment is null ? [] : new List<NestPartFulfillment>(Fulfillment), ValidationStatus = ValidationStatus,
Violations = Violations is null ? [] : new List<string>(Violations),
Fulfillment = Fulfillment is null
? []
: new List<NestPartFulfillment>(Fulfillment),
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage), StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
PlateStockMappings = PlateStockMappings is null ? [] : new List<NestPlateStockMapping>(PlateStockMappings) PlateStockMappings = PlateStockMappings is null
}, JsonOptions); ? []
: new List<NestPlateStockMapping>(PlateStockMappings),
},
JsonOptions
);
} }
var nestEntry = zip.CreateEntry("nest.nest"); var nestEntry = zip.CreateEntry("nest.nest");
@@ -91,16 +110,19 @@ public class NestResponse
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(fs, ZipArchiveMode.Read); using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
var requestEntry = zip.GetEntry("request.json") var requestEntry =
zip.GetEntry("request.json")
?? throw new InvalidOperationException("Missing request.json in .nestquote file"); ?? throw new InvalidOperationException("Missing request.json in .nestquote file");
NestRequest request; NestRequest request;
await using (var stream = requestEntry.Open()) await using (var stream = requestEntry.Open())
{ {
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions) request =
await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
?? throw new InvalidOperationException("Invalid request.json in .nestquote file"); ?? throw new InvalidOperationException("Invalid request.json in .nestquote file");
} }
var responseEntry = zip.GetEntry("response.json") var responseEntry =
zip.GetEntry("response.json")
?? throw new InvalidOperationException("Missing response.json in .nestquote file"); ?? throw new InvalidOperationException("Missing response.json in .nestquote file");
NestResponseArchiveDto archive; NestResponseArchiveDto archive;
var hasSchemaVersion = false; var hasSchemaVersion = false;
@@ -110,16 +132,19 @@ public class NestResponse
{ {
var root = document.RootElement; var root = document.RootElement;
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _); hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
hasStatusMetadata = root.TryGetProperty("status", out _) || hasStatusMetadata =
root.TryGetProperty("stopReason", out _) || root.TryGetProperty("status", out _)
root.TryGetProperty("fulfillment", out _) || || root.TryGetProperty("stopReason", out _)
root.TryGetProperty("stockUsage", out _) || || root.TryGetProperty("fulfillment", out _)
root.TryGetProperty("plateStockMappings", out _); || root.TryGetProperty("stockUsage", out _)
archive = root.Deserialize<NestResponseArchiveDto>(JsonOptions) || root.TryGetProperty("plateStockMappings", out _);
archive =
root.Deserialize<NestResponseArchiveDto>(JsonOptions)
?? throw new InvalidOperationException("Invalid response.json in .nestquote file"); ?? throw new InvalidOperationException("Invalid response.json in .nestquote file");
} }
var nestEntry = zip.GetEntry("nest.nest") var nestEntry =
zip.GetEntry("nest.nest")
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file"); ?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
Nest nest; Nest nest;
using (var nestMs = new MemoryStream()) using (var nestMs = new MemoryStream())
@@ -141,11 +166,13 @@ 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 ?? [] : [],
Nest = nest, Nest = nest,
Request = request Request = request,
}; };
} }
@@ -158,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; } = [];
+135 -46
View File
@@ -5,6 +5,8 @@ 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.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.IO; using OpenNest.IO;
namespace OpenNest.Api; namespace OpenNest.Api;
@@ -16,90 +18,156 @@ public static class NestRunner
public static Task<NestResponse> RunAsync( public static Task<NestResponse> RunAsync(
NestRequest request, NestRequest request,
IProgress<NestProgress> progress = null, IProgress<NestProgress> progress = null,
CancellationToken token = default) CancellationToken token = default
)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
var requestParts = request.Parts ?? throw new ArgumentException("Request parts must not be null.", nameof(request)); var requestParts =
request.Parts
?? throw new ArgumentException("Request parts must not be null.", nameof(request));
if (requestParts.Count == 0) if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request)); throw new ArgumentException("Request must contain at least one part.", nameof(request));
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)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (!File.Exists(part.Request.DxfPath)) if (!File.Exists(part.Request.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.Request.DxfPath}", part.Request.DxfPath); throw new FileNotFoundException(
$"DXF file not found: {part.Request.DxfPath}",
part.Request.DxfPath
);
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing)) if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
{ {
try try
{ {
drawing = CadImporter.ImportDrawing(part.Request.DxfPath, drawing = CadImporter.ImportDrawing(
new CadImportOptions { Quantity = part.Request.Quantity }); part.Request.DxfPath,
new CadImportOptions { Quantity = part.Request.Quantity }
);
} }
catch (Exception exception) catch (Exception exception)
{ {
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}", exception); throw new InvalidOperationException(
$"Failed to import DXF: {part.Request.DxfPath}",
exception
);
} }
if (drawing.Program == null || drawing.Program.Codes.Count == 0) if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}"); throw new InvalidOperationException(
$"Failed to import DXF: {part.Request.DxfPath}"
);
importedByPath.Add(part.Request.DxfPath, drawing); importedByPath.Add(part.Request.DxfPath, drawing);
} }
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(jobParts, CreateStock(request), var stock = CreateStock(request);
new NestJobOptions(ResolvePlacementStrategy(request))); var engineName = request.Engine ?? 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);
sw.Stop(); sw.Stop();
return Task.FromResult(new NestResponse return Task.FromResult(
new NestResponse
{ {
SheetCount = nest.Plates.Count, SheetCount = nest.Plates.Count,
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.Fulfillment ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
.Select(value => new NestPartFulfillment(value.PartId, value.Requested, value.Placed, value.Unplaced)) : result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
.ToArray(), Violations = result.Violations,
StockUsage = result.StockUsage Fulfillment = fulfillment,
.Select(value => new NestStockUsage(value.StockId, value.Used, value.Remaining)) StockUsage = usage,
.ToArray(), PlateStockMappings = result.Plates.Select((value, index) =>
PlateStockMappings = result.Plates new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
.Select(value => new NestPlateStockMapping(value.PlateIndex, value.StockId))
.ToArray(),
Nest = nest, Nest = nest,
Request = request Request = request,
}); }
);
} }
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(IReadOnlyList<NestRequestPart> requestParts) private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(
IReadOnlyList<NestRequestPart> requestParts
)
{ {
var identified = new List<IdentifiedRequestPart>(requestParts.Count); var identified = new List<IdentifiedRequestPart>(requestParts.Count);
var ids = new HashSet<string>(StringComparer.Ordinal); var ids = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < requestParts.Count; index++) for (var index = 0; index < requestParts.Count; index++)
{ {
var part = requestParts[index] ?? throw new ArgumentException("Request parts must not contain null entries.", nameof(requestParts)); var part =
requestParts[index]
?? throw new ArgumentException(
"Request parts must not contain null entries.",
nameof(requestParts)
);
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));
@@ -117,8 +185,12 @@ public static class NestRunner
{ {
return return
[ [
new NestPlateStock(LegacyStockId, request.SheetSize, quantity: null, new NestPlateStock(
partSpacing: request.Spacing) LegacyStockId,
request.SheetSize,
quantity: null,
partSpacing: request.Spacing
),
]; ];
} }
@@ -126,9 +198,20 @@ public static class NestRunner
foreach (var plate in request.Plates) foreach (var plate in request.Plates)
{ {
if (plate is null) if (plate is null)
throw new ArgumentException("Request plates must not contain null entries.", nameof(request)); throw new ArgumentException(
stock.Add(new NestPlateStock(plate.Id, plate.Size, plate.Quantity, plate.PartSpacing, "Request plates must not contain null entries.",
plate.EdgeSpacing, plate.Quadrant)); nameof(request)
);
stock.Add(
new NestPlateStock(
plate.Id,
plate.Size,
plate.Quantity,
plate.PartSpacing,
plate.EdgeSpacing,
plate.Quadrant
)
);
} }
return stock; return stock;
@@ -147,25 +230,31 @@ public static class NestRunner
} }
} }
private static string ResolvePlacementStrategy(NestRequest request) => request.PlacementStrategy ?? request.Strategy switch private static string ResolvePlacementStrategy(NestRequest request) =>
request.PlacementStrategy
?? request.Strategy switch
{ {
NestStrategy.Auto => "Default", NestStrategy.Auto => "Default",
_ => throw new NotSupportedException($"Unknown legacy nesting strategy: {request.Strategy}.") _ => throw new NotSupportedException(
$"Unknown legacy nesting strategy: {request.Strategy}."
),
}; };
private static double CalculateUtilization(Nest nest) private static double CalculateUtilization(Nest nest)
{ {
var sheetArea = nest.Plates.Sum(plate => plate.Area()); var sheetArea = nest.Plates.Sum(plate => plate.Area());
if (sheetArea == 0) return 0; if (sheetArea == 0)
var placedArea = nest.Plates.Sum(plate => plate.Parts return 0;
.Where(part => !part.BaseDrawing.IsCutOff) var placedArea = nest.Plates.Sum(plate =>
.Sum(part => part.BaseDrawing.Area)); plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
);
return placedArea / sheetArea; return placedArea / sheetArea;
} }
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request); private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
private sealed class JobProgressBridge(IProgress<NestProgress> progress) : IProgress<NestJobProgress> private sealed class JobProgressBridge(IProgress<NestProgress> progress)
: IProgress<NestJobProgress>
{ {
public void Report(NestJobProgress value) public void Report(NestJobProgress value)
{ {
+4 -1
View File
@@ -1,3 +1,6 @@
namespace OpenNest.Api; namespace OpenNest.Api;
public enum NestStrategy { Auto } public enum NestStrategy
{
Auto,
}
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Api</RootNamespace> <RootNamespace>OpenNest.Api</RootNamespace>
<AssemblyName>OpenNest.Api</AssemblyName> <AssemblyName>OpenNest.Api</AssemblyName>
</PropertyGroup> </PropertyGroup>
+94
View File
@@ -0,0 +1,94 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Benchmark
{
/// <summary>
/// One request to nest a specific drawing, with the quantity and rotation
/// constraints pulled from its source .nest file.
/// </summary>
public class DrawingRequest
{
public Drawing Drawing { get; init; }
public int Quantity { get; init; }
public int Priority { get; init; }
public double StepAngle { get; init; }
public double RotationStart { get; init; }
public double RotationEnd { get; init; }
}
/// <summary>
/// An immutable specification for one benchmark job: the full set of
/// drawings/quantities that must be nested, and the pool of sheet sizes the
/// engine may draw from while doing it. A single run may use several
/// plates - possibly of different sizes - to place everything, the same
/// way a real production job spreads across whatever plates it needs
/// rather than being handed one fixed-size sheet.
/// </summary>
public class BenchmarkJob
{
public string SourceFile { get; init; }
public List<Size> CandidateSizes { get; init; }
public Spacing EdgeSpacing { get; init; }
public double PartSpacing { get; init; }
public int Quadrant { get; init; }
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
public double SalvageRate { get; init; }
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
public List<DrawingRequest> Requests { get; init; }
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
/// <summary>Sheet area charged per unplaced part: the largest candidate
/// sheet. Any single part that fits the stock at all fits on one such
/// sheet, so placing a part is never scored worse than leaving it out.</summary>
public double UnplacedPartPenalty =>
CandidateSizes.Count == 0 ? 0 : CandidateSizes.Max(s => s.Width * s.Length);
/// <summary>
/// Builds the whole-job request this job represents: one NestJobPart per
/// requested drawing, and one NestPlateStock per candidate sheet size
/// (unlimited quantity - the engine under test decides how many of each
/// size it actually uses, and how demand splits across plates). The
/// engine owns its own multi-plate/size strategy; this harness no
/// longer picks plate sizes on the engine's behalf.
/// </summary>
public NestJob BuildNestJob(
int maxPlates,
double? salvageRate = null,
double? minimumSalvageDimension = null
)
{
var parts = Requests.Select(r =>
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
);
var stock = CandidateSizes.Select(size => new NestPlateStock(
size.ToString(1),
size,
null,
PartSpacing,
EdgeSpacing,
Quadrant
));
return new NestJob(
parts,
stock,
new NestJobOptions(
"Default",
maxPlates,
salvageRate ?? SalvageRate,
minimumSalvageDimension ?? 0
)
);
}
}
}
+397
View File
@@ -0,0 +1,397 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Benchmark
{
/// <summary>
/// Runs every candidate engine against every job. Each engine is a full
/// INestingEngine: it owns its own plate/size selection and multi-plate
/// strategy for the whole job, rather than being handed one already-sized
/// plate at a time by this harness. A per-run timeout guards against a
/// runaway or hanging engine — cooperative cancellation, so it reliably
/// stops engines built on NestJobRunner (all four built-ins) but can't
/// forcibly interrupt an engine that never checks its token.
/// </summary>
public static class BenchmarkRunner
{
/// <summary>Physical-sheet cap passed to every job's NestJobOptions.MaxPlates.</summary>
private const int MaxPlates = 40;
/// <summary>Wall-clock budget for one engine solving one job.</summary>
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
public static List<JobResult> Run(
List<BenchmarkJob> jobs,
IReadOnlyList<NestingEngineInfo> engines,
double? salvageRate = null,
double? minimumSalvageDimension = null,
string outputDirectory = null,
int maxParallelism = 1,
System.IO.TextWriter progressLog = null
)
{
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
.ToList();
var results = new JobResult[pairs.Count];
var options = new ParallelOptions
{
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
};
var baselineResults = new JobResult[jobs.Count];
Parallel.ForEach(
Partitioner.Create(
Enumerable.Range(0, jobs.Count),
EnumerablePartitionerOptions.NoBuffering
),
options,
i => baselineResults[i] = RunBaseline(jobs[i], salvageRate, minimumSalvageDimension)
);
// NoBuffering hands out one pair at a time: solves run for seconds to minutes,
// so chunked partitioning would leave workers idle behind a slow engine.
Parallel.ForEach(
Partitioner.Create(
Enumerable.Range(0, pairs.Count),
EnumerablePartitionerOptions.NoBuffering
),
options,
i =>
results[i] = RunOne(
pairs[i].Job,
pairs[i].Engine,
salvageRate,
minimumSalvageDimension,
outputDirectory,
progressLog
)
);
// Indexed writes keep the report in job-then-engine order whatever finishes first.
var ordered = new List<JobResult>(
results.Length + baselineResults.Count(result => result != null)
);
for (var jobIndex = 0; jobIndex < jobs.Count; jobIndex++)
{
if (baselineResults[jobIndex] != null)
ordered.Add(baselineResults[jobIndex]);
var firstResult = jobIndex * engines.Count;
for (var engineIndex = 0; engineIndex < engines.Count; engineIndex++)
ordered.Add(results[firstResult + engineIndex]);
}
return ordered;
}
private static JobResult RunBaseline(
BenchmarkJob job,
double? salvageRate,
double? minimumSalvageDimension
)
{
if (job.BaselinePlateRuns == null)
return null;
var requested = job.TotalRequestedQuantity;
try
{
var requirements = job.Requests.ToDictionary<
DrawingRequest,
Drawing,
(string Name, int Quantity)
>(
request => request.Drawing,
request => (request.Drawing.Name, request.Quantity),
ReferenceEqualityComparer.Instance
);
var partIds = job.Requests.ToDictionary<DrawingRequest, Drawing, string>(
request => request.Drawing,
request => request.Drawing.Id.ToString(),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(job.BaselinePlateRuns, requirements);
var benchmarkJob = job.BuildNestJob(
MaxPlates,
salvageRate,
minimumSalvageDimension
);
var instanceIndices = new Dictionary<string, int>(StringComparer.Ordinal);
var plateResults = job
.BaselinePlateRuns.Select(
(run, index) =>
{
var stock = new NestPlateStock(
$"baseline-{index}",
run.Plate.Size,
1,
run.Plate.PartSpacing,
run.Plate.EdgeSpacing,
run.Plate.Quadrant
);
var placements = run
.Parts.Select(part =>
{
var partId = partIds[part.BaseDrawing];
instanceIndices.TryGetValue(partId, out var instanceIndex);
instanceIndices[partId] = instanceIndex + 1;
return new NestJobPlacement(
partId,
instanceIndex,
part.Location.X,
part.Location.Y,
part.Rotation
);
})
.ToList();
return new NestJobPlateResult(index, stock, placements);
}
)
.ToList();
var baselineJob = new NestJob(
benchmarkJob.Parts,
plateResults.Select(result => result.Stock),
benchmarkJob.Options
);
var baselineJobResult = new NestJobResult(
NestJobStatus.Complete,
NestJobStopReason.Completed,
plateResults,
Array.Empty<PartFulfillment>(),
Array.Empty<StockUsage>()
);
NestValidator.ValidateAgainstJob(
baselineJob,
baselineJobResult,
job.Requests.ToDictionary(
request => request.Drawing.Id.ToString(),
request => request.Drawing.Name
),
validation
);
var plateRuns = job.BaselinePlateRuns;
var placedArea = validation.Valid
? plateRuns.Sum(run => run.Parts.Sum(part => part.BaseDrawing.Area))
: 0;
var plateArea = plateRuns.Sum(run => run.Plate.Area());
var netSheetArea = validation.Valid
? plateResults.Sum(result =>
NestJobCost.NetSheetArea(baselineJob, result)
)
: 0;
var sizeBreakdown = plateRuns
.GroupBy(run => run.Plate.Size.ToString(1))
.OrderByDescending(group => group.Count())
.ToDictionary(group => group.Key, group => group.Count());
return new JobResult
{
EngineName = "Baseline",
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = plateRuns.Sum(run => run.Parts.Count),
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
NetSheetArea = netSheetArea,
UnplacedPartPenalty = job.UnplacedPartPenalty,
PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = 0,
};
}
catch (Exception ex)
{
return new JobResult
{
EngineName = "Baseline",
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
Error = $"{ex.GetType().Name}: {ex.Message}",
};
}
}
private static JobResult RunOne(
BenchmarkJob job,
NestingEngineInfo engineInfo,
double? salvageRate,
double? minimumSalvageDimension,
string outputDirectory,
System.IO.TextWriter progressLog
)
{
var requested = job.TotalRequestedQuantity;
var log = progressLog == null
? null
: new JobProgressLog(progressLog, $"{job.Name}/{engineInfo.Name}");
log?.Started();
var sw = Stopwatch.StartNew();
try
{
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(SolveTimeout);
var jobResult = engine.Solve(nestJob, log, cts.Token);
log?.Finished(jobResult, sw.ElapsedMilliseconds);
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
var plateRuns = materialized
.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
.ToList();
var requirements = job.Requests.ToDictionary<
DrawingRequest,
Drawing,
(string Name, int Quantity)
>(
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
r => (r.Drawing.Name, r.Quantity),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(plateRuns, requirements);
NestValidator.ValidateAgainstJob(
nestJob,
jobResult,
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing.Name),
validation
);
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
var placedArea = validation.Valid
? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area))
: 0;
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
// Salvage credit is recomputed from the job's own geometry, never taken from the engine.
var netSheetArea = validation.Valid
? jobResult.Plates.Sum(p =>
NestJobCost.NetSheetArea(nestJob, p)
)
: 0;
var sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
if (validation.Valid && outputDirectory != null)
{
System.IO.Directory.CreateDirectory(outputDirectory);
// Keep names and job metadata for a useful inspectable output; never modify source.
// Manifest jobs have no source nest to copy from, so they keep the job's name.
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
{
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
materialized.Nest.Name = source.Name;
materialized.Nest.Units = source.Units;
materialized.Nest.Material = source.Material;
materialized.Nest.Thickness = source.Thickness;
}
else
{
materialized.Nest.Name = job.Name;
}
materialized.Nest.SalvageRate = nestJob.Options.SalvageRate;
foreach (var request in job.Requests)
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request
.Drawing
.Name;
var path = System.IO.Path.Combine(
outputDirectory,
$"{job.Name}-{engineInfo.Name}.nest"
);
if (
System.IO.Path.GetFullPath(path)
== System.IO.Path.GetFullPath(job.SourceFile)
)
throw new InvalidOperationException(
"Output must not overwrite the source nest."
);
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
var report = new
{
Source = job.SourceFile,
Engine = engineInfo.Name,
jobResult.Status,
jobResult.StopReason,
Requested = requested,
Placed = totalPlaced,
SheetArea = plateArea,
PlacedArea = placedArea,
SalvageRate = nestJob.Options.SalvageRate,
MinimumSalvageDimension = nestJob.Options.MinimumSalvageDimension,
EstimatedNetArea = netSheetArea,
Fulfillment = jobResult.Fulfillment,
StockUsage = jobResult.StockUsage,
Plates = jobResult.Plates,
validation.Violations,
};
System.IO.File.WriteAllText(
System.IO.Path.ChangeExtension(path, ".json"),
System.Text.Json.JsonSerializer.Serialize(
report,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }
)
);
}
sw.Stop();
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = totalPlaced,
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
NetSheetArea = netSheetArea,
UnplacedPartPenalty = job.UnplacedPartPenalty,
PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = sw.ElapsedMilliseconds,
};
}
catch (OperationCanceledException)
{
sw.Stop();
log?.Failed("timed out", sw.ElapsedMilliseconds);
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
};
}
catch (Exception ex)
{
sw.Stop();
log?.Failed($"{ex.GetType().Name}: {ex.Message}", sw.ElapsedMilliseconds);
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"{ex.GetType().Name}: {ex.Message}",
};
}
}
}
}
+175
View File
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Benchmark
{
/// <summary>
/// Builds a BenchmarkJob from a JSON manifest that lists DXF files and the
/// quantity of each to nest. DXF paths resolve relative to the manifest.
/// Sheet sizes come from the manifest or the caller's override; unlike a
/// .nest file there is no plate to inherit them from, so a job with none is
/// an error. Sheet sizes must use the same units as the DXFs.
/// </summary>
public static class DxfManifestLoader
{
/// <summary>Suffix a manifest needs to be picked up when scanning a folder.</summary>
public const string FolderSuffix = ".manifest.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static BenchmarkJob Load(
string manifestPath,
IReadOnlyList<Size> sheetSizeOverrides = null,
double? partSpacingOverride = null
)
{
var manifest = ReadManifest(manifestPath);
var baseDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath));
if (manifest.Parts == null || manifest.Parts.Count == 0)
throw new InvalidOperationException(
$"Manifest '{manifestPath}' has no parts. Add entries to \"parts\"."
);
var sizes = ResolveSheetSizes(manifest, sheetSizeOverrides, manifestPath);
var requests = manifest.Parts.Select(p => BuildRequest(p, baseDir)).ToList();
return new BenchmarkJob
{
SourceFile = manifestPath,
CandidateSizes = sizes,
EdgeSpacing = new Spacing(manifest.EdgeSpacing, manifest.EdgeSpacing),
PartSpacing = partSpacingOverride ?? manifest.Spacing,
Quadrant = manifest.Quadrant,
Requests = requests,
};
}
private static Manifest ReadManifest(string manifestPath)
{
try
{
return JsonSerializer.Deserialize<Manifest>(
File.ReadAllText(manifestPath),
JsonOptions
) ?? throw new InvalidOperationException("The manifest is empty.");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"Manifest '{manifestPath}' is not valid JSON: {ex.Message}",
ex
);
}
}
private static List<Size> ResolveSheetSizes(
Manifest manifest,
IReadOnlyList<Size> overrides,
string manifestPath
)
{
if (overrides != null && overrides.Count > 0)
return overrides.ToList();
var sizes = new List<Size>();
foreach (var text in manifest.SheetSizes ?? new List<string>())
{
if (!JobLoader.TryParseSheetSize(text, out var size))
throw new InvalidOperationException(
$"Manifest '{manifestPath}': could not parse sheet size '{text}' (expected e.g. \"48x96\")."
);
sizes.Add(size);
}
if (sizes.Count == 0)
throw new InvalidOperationException(
$"Manifest '{manifestPath}' has no sheet sizes. Set \"sheetSizes\" or pass --sheet-sizes."
);
return sizes.Distinct().ToList();
}
private static DrawingRequest BuildRequest(ManifestPart part, string baseDir)
{
if (string.IsNullOrWhiteSpace(part.Dxf))
throw new InvalidOperationException("A manifest part is missing \"dxf\".");
if (part.Quantity <= 0)
throw new InvalidOperationException(
$"Manifest part '{part.Dxf}': quantity must be greater than 0 (was {part.Quantity})."
);
var dxfPath = Path.GetFullPath(Path.Combine(baseDir, part.Dxf));
if (!File.Exists(dxfPath))
throw new FileNotFoundException($"DXF file not found: {dxfPath}", dxfPath);
Drawing drawing;
try
{
drawing = CadImporter.ImportDrawing(
dxfPath,
new CadImportOptions { Quantity = part.Quantity }
);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}", ex);
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
if (!part.AllowRotation)
{
drawing.Constraints ??= new NestConstraints();
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
drawing.Constraints.StartAngle = 0;
drawing.Constraints.EndAngle = 0;
}
var constraints = drawing.Constraints;
return new DrawingRequest
{
Drawing = drawing,
Quantity = part.Quantity,
Priority = drawing.Priority,
StepAngle = constraints?.StepAngle ?? 0,
RotationStart = constraints?.StartAngle ?? 0,
RotationEnd = constraints?.EndAngle ?? 0,
};
}
private class Manifest
{
public List<string> SheetSizes { get; set; }
public double Spacing { get; set; }
public double EdgeSpacing { get; set; }
public int Quadrant { get; set; } = 1;
public List<ManifestPart> Parts { get; set; }
}
private class ManifestPart
{
public string Dxf { get; set; }
public int Quantity { get; set; }
public bool AllowRotation { get; set; } = true;
}
}
}
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Benchmark
{
/// <summary>
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
/// any valid .nest file, using whatever drawings/quantities/plate settings
/// it contains. One job per file, carrying the full pool of candidate
/// sheet sizes the engine may use across the whole nest - by default the
/// distinct sizes already present in that file, or a fixed override list
/// (e.g. a standard sheet-size lineup) applied to every file.
/// </summary>
public static class JobLoader
{
public static List<BenchmarkJob> Load(
string inputPath,
IReadOnlyList<Size> sheetSizeOverrides = null,
double? partSpacingOverride = null
)
{
var files = ResolveFiles(inputPath);
var jobs = new List<BenchmarkJob>();
foreach (var file in files)
{
if (file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
// Hand-written manifests fail loudly rather than being skipped like unreadable .nest files.
jobs.Add(DxfManifestLoader.Load(file, sheetSizeOverrides, partSpacingOverride));
continue;
}
Nest nest;
try
{
nest = new NestReader(file).Read();
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"[JobLoader] Skipping '{file}': failed to read ({ex.Message})"
);
continue;
}
var requests = BuildRequests(nest);
if (requests.Count == 0)
{
Console.Error.WriteLine(
$"[JobLoader] Skipping '{file}': no drawings with quantity > 0"
);
continue;
}
var template = ResolvePlateTemplate(nest);
var sizes =
sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
? sheetSizeOverrides.ToList()
: ResolveSheetSizes(nest);
jobs.Add(
new BenchmarkJob
{
SourceFile = file,
CandidateSizes = sizes,
EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant,
SalvageRate = nest.SalvageRate,
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
Requests = requests,
}
);
}
return jobs;
}
/// <summary>Parses "WxL" with invariant-culture numbers, so "48.5x96" means the
/// same thing on every machine (Size.Parse follows the current culture).</summary>
public static bool TryParseSheetSize(string text, out Size size)
{
size = default;
var dims = text?.Split('x', 'X');
if (dims == null || dims.Length != 2)
return false;
var style = System.Globalization.NumberStyles.Float;
var culture = System.Globalization.CultureInfo.InvariantCulture;
if (
!double.TryParse(dims[0].Trim(), style, culture, out var width)
|| !double.TryParse(dims[1].Trim(), style, culture, out var length)
)
return false;
size = new Size(width, length);
return true;
}
private static List<string> ResolveFiles(string inputPath)
{
if (Directory.Exists(inputPath))
{
return Directory
.EnumerateFiles(inputPath, "*", SearchOption.AllDirectories)
.Where(f =>
f.EndsWith(".nest", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(
DxfManifestLoader.FolderSuffix,
StringComparison.OrdinalIgnoreCase
)
)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
}
if (File.Exists(inputPath))
return new List<string> { inputPath };
throw new FileNotFoundException($"Benchmark input not found: {inputPath}");
}
private static List<DrawingRequest> BuildRequests(Nest nest)
{
var requests = new List<DrawingRequest>();
foreach (var drawing in nest.Drawings)
{
var qty = drawing.Quantity.Required;
if (qty <= 0)
continue;
var constraints = drawing.Constraints;
requests.Add(
new DrawingRequest
{
Drawing = drawing,
Quantity = qty,
Priority = drawing.Priority,
StepAngle = constraints?.StepAngle ?? 0,
RotationStart = constraints?.StartAngle ?? 0,
RotationEnd = constraints?.EndAngle ?? 0,
}
);
}
return requests;
}
private static List<(Plate Plate, List<Part> Parts)> BuildBaselinePlateRuns(
Nest nest,
double? partSpacingOverride
)
{
var runs = new List<(Plate Plate, List<Part> Parts)>();
foreach (var plate in nest.Plates ?? Enumerable.Empty<Plate>())
{
var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
if (parts.Count == 0)
continue;
var validationPlate = new Plate(new Size(plate.Size.Width, plate.Size.Length))
{
Quantity = 1,
Quadrant = plate.Quadrant,
PartSpacing = partSpacingOverride ?? plate.PartSpacing,
EdgeSpacing = new Spacing(
plate.EdgeSpacing.Left,
plate.EdgeSpacing.Bottom,
plate.EdgeSpacing.Right,
plate.EdgeSpacing.Top
),
};
for (var copy = 0; copy < plate.Quantity; copy++)
runs.Add((validationPlate, parts));
}
return runs.Count > 0 ? runs : null;
}
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
Nest nest
)
{
var source = nest.Plates?.FirstOrDefault();
if (source != null)
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
var defaults = nest.PlateDefaults;
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
}
private static List<Size> ResolveSheetSizes(Nest nest)
{
var sizes = (nest.Plates ?? Enumerable.Empty<Plate>())
.Select(p => p.Size)
.Distinct()
.ToList();
if (sizes.Count == 0)
sizes.Add(nest.PlateDefaults.Size);
return sizes;
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using System;
using System.Diagnostics;
using System.IO;
using OpenNest.Engine.Jobs;
namespace OpenNest.Benchmark
{
/// <summary>
/// Writes one solve's NestJobProgress as log lines prefixed with "[job/engine]". Every
/// PlateCommitted is written; EvaluatingCandidate is throttled to one line per interval so a
/// chatty engine cannot flood the console. One instance per solve; Report is thread-safe.
/// </summary>
public sealed class JobProgressLog : IProgress<NestJobProgress>
{
public static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(2);
private readonly TextWriter writer;
private readonly string label;
private readonly TimeSpan interval;
private readonly Func<TimeSpan> clock;
private readonly object sync = new();
private TimeSpan? lastEvaluating;
public JobProgressLog(
TextWriter writer,
string label,
TimeSpan? interval = null,
Func<TimeSpan> clock = null
)
{
this.writer = writer ?? throw new ArgumentNullException(nameof(writer));
this.label = label;
this.interval = interval ?? DefaultInterval;
if (clock == null)
{
var stopwatch = Stopwatch.StartNew();
clock = () => stopwatch.Elapsed;
}
this.clock = clock;
}
public void Started() => Write("started");
public void Finished(NestJobResult result, long elapsedMs) =>
Write(
$"finished in {elapsedMs} ms: {result.Status} ({result.StopReason}), "
+ $"{result.Plates.Count} plate(s)"
);
public void Failed(string error, long elapsedMs) =>
Write($"failed after {elapsedMs} ms: {error}");
public void Report(NestJobProgress value)
{
if (value == null)
return;
if (value.Stage == NestJobStage.PlateCommitted)
{
Write(
$"committed plate {value.CommittedPlates} on stock {value.StockId} "
+ $"({value.CommittedParts} parts placed)"
);
return;
}
lock (sync)
{
var now = clock();
if (lastEvaluating.HasValue && now - lastEvaluating.Value < interval)
return;
lastEvaluating = now;
}
var plate = value.PlateIndex >= 0 ? value.PlateIndex + 1 : value.CommittedPlates + 1;
Write(
$"evaluating plate {plate} on stock {value.StockId} "
+ $"({value.CommittedPlates} plate(s), {value.CommittedParts} parts committed)"
);
}
private void Write(string message)
{
lock (sync)
writer.WriteLine($"[{label}] {message}");
}
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Collections.Generic;
namespace OpenNest.Benchmark
{
/// <summary>
/// Outcome of running one engine against one job. A job may span several
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
/// one sheet - possibly of different sizes - to place everything asked of
/// it. An invalid or crashed run places nothing as far as scoring is
/// concerned: it earns no area and pays the unplaced penalty on every
/// requested part.
/// </summary>
public class JobResult
{
public string EngineName { get; init; }
public string JobName { get; init; }
public bool Valid { get; init; }
public List<string> Violations { get; init; } = new();
public string Error { get; init; }
public int PartsPlaced { get; init; }
public int PartsRequested { get; init; }
public double PlacedArea { get; init; }
public double PlateArea { get; init; }
/// <summary>Sheet area consumed after crediting salvageable offcuts
/// (NestJobCost.NetSheetArea summed over every plate).
/// Equals PlateArea when salvage credit is disabled.</summary>
public double NetSheetArea { get; init; }
/// <summary>Sheet area charged for each requested part that was not
/// placed: the largest candidate sheet's area, so leaving a part out
/// always costs at least as much as the extra sheet it would need.</summary>
public double UnplacedPartPenalty { get; init; }
public int PlatesUsed { get; init; }
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
public long ElapsedMs { get; init; }
public bool Crashed => Error != null;
public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested;
/// <summary>Aggregate utilization across every plate the engine used:
/// total placed drawing area over total plate area, matching
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
/// <summary>Placed area over salvage-credited sheet area.</summary>
public double NetUtilization =>
Valid && NetSheetArea > 0 ? PlacedArea / NetSheetArea : 0;
public int PartsUnplaced =>
Valid ? System.Math.Max(0, PartsRequested - PartsPlaced) : PartsRequested;
/// <summary>
/// The ranking score, in sheet area (lower is better): net sheet area
/// consumed plus the unplaced penalty. An engine cannot improve it by
/// dropping awkward parts, and it sums honestly across jobs of
/// different sizes. Invalid runs consume no sheet but pay the penalty
/// on every requested part.
/// </summary>
public double Cost => (Valid ? NetSheetArea : 0) + PartsUnplaced * UnplacedPartPenalty;
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using OpenNest.Engine.Jobs;
namespace OpenNest.Benchmark;
/// <summary>Benchmark validation outcome.</summary>
public class ValidationResult
{
public bool Valid => Violations.Count == 0;
public List<string> Violations { get; } = new();
}
/// <summary>Compatibility wrapper over the shared layout validation contract.</summary>
public static class NestValidator
{
/// <summary>Checks materialized plates using drawing-reference requirement identity.</summary>
public static ValidationResult Validate(
List<(Plate Plate, List<Part> Parts)> plateRuns,
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
{
var result = new ValidationResult();
result.Violations.AddRange(NestLayoutCheck.Validate(plateRuns, requirements));
return result;
}
/// <summary>Appends offered-stock, finite-stock and rotation-policy violations.</summary>
public static void ValidateAgainstJob(NestJob job, NestJobResult jobResult,
IReadOnlyDictionary<string, string> displayNames, ValidationResult result) =>
NestLayoutCheck.ValidateAgainstJob(job, jobResult, displayNames, result.Violations);
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Benchmark</RootNamespace>
<AssemblyName>OpenNest.Benchmark</AssemblyName>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+349
View File
@@ -0,0 +1,349 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.Benchmark;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
return BenchmarkConsole.Run(args);
static class BenchmarkConsole
{
public static int Run(string[] args)
{
var options = ParseArgs(args);
if (options == null)
return 0; // --help was requested
if (options.InputPath == null)
{
PrintUsage();
return 1;
}
List<BenchmarkJob> jobs;
try
{
jobs = JobLoader.Load(options.InputPath, options.SheetSizes, options.PartSpacing);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
if (jobs.Count == 0)
{
Console.Error.WriteLine(
"No benchmark jobs found (no .nest files with any drawing quantity > 0, or *.manifest.json files)."
);
return 1;
}
var enginesDir = Path.Combine(AppContext.BaseDirectory, "Engines");
NestingEngineRegistry.LoadPlugins(enginesDir);
var engines = NestingEngineRegistry.AvailableEngines;
if (options.EngineNames.Count > 0)
{
engines = engines
.Where(e =>
options.EngineNames.Any(n =>
n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)
)
)
.ToList();
if (engines.Count == 0)
{
Console.Error.WriteLine(
"None of the requested engines are registered. Available: "
+ string.Join(
", ",
NestingEngineRegistry.AvailableEngines.Select(e => e.Name)
)
);
return 1;
}
}
Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'");
foreach (var job in jobs)
{
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
Console.WriteLine(
$" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}"
);
}
if (
options.SheetSizes.Count == 0
&& jobs.Any(j => j.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
)
{
Console.Error.WriteLine(
"Warning: no --sheet-sizes given, so each .nest job only offers the sheet sizes its "
+ "original layout used - a hint toward that answer. Pass --sheet-sizes with the "
+ "sizes you actually stock for an unbiased comparison."
);
}
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
var effectiveSalvageRates = jobs.Select(job => options.SalvageRate ?? job.SalvageRate);
if (
effectiveSalvageRates.Any(rate => rate > 0)
&& (options.MinimumSalvageDimension ?? 0) <= 0
)
{
Console.Error.WriteLine(
"Warning: salvage credit is disabled because --min-salvage-dimension was not set to a positive value."
);
}
var solves = jobs.Count * engines.Count;
if (options.Parallel > 1 && solves > 1)
{
Console.WriteLine(
$"Running up to {options.Parallel} solves at a time; Time(ms) is measured under that "
+ "concurrent load. Use --parallel 1 for strictly isolated timings."
);
}
var results = BenchmarkRunner.Run(
jobs,
engines,
options.SalvageRate,
options.MinimumSalvageDimension,
options.OutputDirectory,
options.Parallel,
options.Progress ? Console.Out : null
);
Report.PrintDetailed(results);
Report.PrintSummary(results);
if (options.CsvPath != null)
{
Report.WriteCsv(options.CsvPath, results);
Console.WriteLine();
Console.WriteLine($"Wrote CSV report to {options.CsvPath}");
}
return 0;
}
private static Options ParseArgs(string[] args)
{
var o = new Options();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--sheet-sizes" when i + 1 < args.Length:
o.SheetSizes = ParseSheetSizes(args[++i]);
break;
case "--spacing" when i + 1 < args.Length:
o.PartSpacing = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--engines" when i + 1 < args.Length:
o.EngineNames = args[++i]
.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
.ToList();
break;
case "--csv" when i + 1 < args.Length:
o.CsvPath = args[++i];
break;
case "--salvage-rate" when i + 1 < args.Length:
o.SalvageRate = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--min-salvage-dimension" when i + 1 < args.Length:
o.MinimumSalvageDimension = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--output" when i + 1 < args.Length:
o.OutputDirectory = args[++i];
break;
case "--parallel" when i + 1 < args.Length:
if (int.TryParse(args[++i], out var parallel) && parallel >= 1)
o.Parallel = parallel;
else
Console.Error.WriteLine(
$"Warning: --parallel needs a whole number >= 1, using {o.Parallel}"
);
break;
case "--progress":
o.Progress = true;
break;
case "--help":
PrintUsage();
return null;
default:
if (!args[i].StartsWith("--"))
o.InputPath = args[i];
break;
}
}
return o;
}
private static List<Size> ParseSheetSizes(string arg)
{
var sizes = new List<Size>();
foreach (
var token in arg.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
)
{
if (JobLoader.TryParseSheetSize(token, out var size))
sizes.Add(size);
else
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
}
return sizes.Distinct().ToList();
}
private static void PrintUsage()
{
Console.Error.WriteLine(
"OpenNest.Benchmark - compare registered whole-job nesting engines on a set of .nest files"
);
Console.Error.WriteLine();
Console.Error.WriteLine(
"For each .nest file, every drawing with quantity > 0 is nested (mixed together),"
);
Console.Error.WriteLine(
"once per registered INestingEngine. Each engine is handed the full job - every"
);
Console.Error.WriteLine(
"requested part and the whole pool of candidate sheet sizes - and owns its own"
);
Console.Error.WriteLine(
"multi-plate/size strategy: how many plates it uses, of which sizes, and how"
);
Console.Error.WriteLine(
"demand splits across them. Ranking: a run that places every requested part beats"
);
Console.Error.WriteLine(
"one that does not; then lower cost = sheet area consumed (minus salvage credit for a"
);
Console.Error.WriteLine(
"usable offcut) + the largest candidate sheet's area per unplaced part; then fewer"
);
Console.Error.WriteLine(
"plates. An invalid layout (out of bounds, overlapping, over-quantity, off-stock, or"
);
Console.Error.WriteLine(
"breaking a rotation constraint), a thrown exception, or a timeout places nothing."
);
Console.Error.WriteLine();
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(
" OpenNest.Benchmark <file.nest | manifest.json | folder> [options]"
);
Console.Error.WriteLine();
Console.Error.WriteLine(
"A manifest.json builds a job straight from DXF files (paths relative to the manifest):"
);
Console.Error.WriteLine(
" { \"sheetSizes\": [\"48x96\"], \"spacing\": 0.25, \"edgeSpacing\": 0.25, \"quadrant\": 1,"
);
Console.Error.WriteLine(
" \"parts\": [ { \"dxf\": \"a.dxf\", \"quantity\": 12 }, { \"dxf\": \"b.dxf\", \"quantity\": 4, \"allowRotation\": false } ] }"
);
Console.Error.WriteLine(
"Sheet sizes must use the same units as the DXFs. A folder is scanned for *.nest and"
);
Console.Error.WriteLine(
"*.manifest.json files. --sheet-sizes and --spacing override the manifest."
);
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(
" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest"
);
Console.Error.WriteLine(
" (default: the distinct sizes already in each file,"
);
Console.Error.WriteLine(
" which hints engines toward the original layout)"
);
Console.Error.WriteLine(
" --spacing <value> Override part spacing for every job"
);
Console.Error.WriteLine(
" --engines Name1,Name2,... Only benchmark these registered engines (default: all)"
);
Console.Error.WriteLine(
" --csv <path> Write a flat CSV of all results"
);
Console.Error.WriteLine(
" --salvage-rate <0..1> Fraction of eligible offcut area credited (default: saved .nest rate;"
);
Console.Error.WriteLine(
" manifests 0; needs positive --min-salvage-dimension)"
);
Console.Error.WriteLine(
" --min-salvage-dimension <value> Both offcut dimensions must qualify; positive value enables credit (default 0)"
);
Console.Error.WriteLine(
" --output <directory> Save valid layouts as .nest plus detailed JSON reports"
);
Console.Error.WriteLine(
" --parallel <n> Solves to run at once (default 3; 1 = strictly sequential,"
);
Console.Error.WriteLine(
" which gives the cleanest per-engine timings)"
);
Console.Error.WriteLine(
" --progress Log each solve's start, engine progress and finish"
);
Console.Error.WriteLine(" --help Show this message");
}
private class Options
{
public string InputPath;
public List<Size> SheetSizes = new();
public double? PartSpacing;
public List<string> EngineNames = new();
public string CsvPath;
public string OutputDirectory;
public double? SalvageRate;
public double? MinimumSalvageDimension;
public int Parallel = 3;
public bool Progress;
}
}
+203
View File
@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenNest.Benchmark
{
/// <summary>
/// Console + CSV reporting for benchmark results. Ranking rule per job:
/// valid beats invalid; placing every requested part beats not; then lower
/// JobResult.Cost wins - salvage-credited sheet area consumed plus a
/// largest-sheet penalty per unplaced part, so dropping awkward parts can
/// never buy a better score; then fewer plates. Ties beyond that are a
/// shared win. Across jobs, costs and areas are summed (not averaged), so
/// a big job weighs more than a three-part one.
/// </summary>
public static class Report
{
private const double Epsilon = 1e-6;
public static void PrintDetailed(List<JobResult> results)
{
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
Console.WriteLine();
Console.WriteLine($"=== {jobGroup.Key} ===");
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
var best = ranked.Count > 0 ? ranked[0] : null;
Console.WriteLine(
$"{"Engine", -16} {"Result", -9} {"Parts", -10} {"Util%", -7} {"Net%", -7} {"Cost", -12} {"Plates", -18} {"Time(ms)", -9} Notes"
);
foreach (var r in ranked)
{
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
var marker = isWinner ? "*" : " ";
var status =
r.Crashed ? "CRASH"
: r.Valid ? "ok"
: "INVALID";
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
var netCol = r.Valid ? $"{r.NetUtilization * 100:F1}" : "-";
var platesCol =
r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
Console.WriteLine(
$"{marker}{r.EngineName, -15} {status, -9} {partsCol, -10} {utilCol, -7} {netCol, -7} {r.Cost, -12:F1} {platesCol, -18} {r.ElapsedMs, -9} {notes}"
);
}
}
}
public static void PrintSummary(List<JobResult> results)
{
Console.WriteLine();
Console.WriteLine("=== Summary ===");
var byEngine = results
.GroupBy(r => r.EngineName)
.Select(g => new
{
Engine = g.Key,
Jobs = g.Count(),
Valid = g.Count(r => r.Valid),
Crashed = g.Count(r => r.Crashed),
FullyPlaced = g.Count(r => r.FullyPlaced),
Unplaced = g.Sum(r => r.PartsUnplaced),
PlacedArea = g.Where(r => r.Valid).Sum(r => r.PlacedArea),
PlateArea = g.Where(r => r.Valid).Sum(r => r.PlateArea),
NetSheetArea = g.Where(r => r.Valid).Sum(r => r.NetSheetArea),
TotalCost = g.Sum(r => r.Cost),
TotalPlates = g.Sum(r => r.PlatesUsed),
TotalTimeMs = g.Sum(r => r.ElapsedMs),
})
.OrderBy(e => e.TotalCost)
.ToList();
var wins = CountWins(results);
// Util% and Net% are area-weighted over valid runs (sum placed / sum
// sheet), not a mean of per-job percentages. TotalCost sums across
// jobs, so it is only meaningful when every job uses the same units.
Console.WriteLine(
$"{"Engine", -16} {"Jobs", -6} {"Valid", -7} {"Complete", -9} {"Unplaced", -9} {"Wins", -6} {"Util%", -7} {"Net%", -7} {"TotalCost", -14} {"Plates", -8} {"TotalTime(ms)", -14}"
);
foreach (var e in byEngine)
{
var util = e.PlateArea > 0 ? e.PlacedArea / e.PlateArea * 100 : 0;
var netUtil = e.NetSheetArea > 0 ? e.PlacedArea / e.NetSheetArea * 100 : 0;
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
Console.WriteLine(
$"{e.Engine, -16} {e.Jobs, -6} {e.Valid, -7} {e.FullyPlaced, -9} {e.Unplaced, -9} {winCount, -6} {util, -7:F1} {netUtil, -7:F1} {e.TotalCost, -14:F1} {e.TotalPlates, -8} {e.TotalTimeMs, -14}"
);
}
}
public static void WriteCsv(string path, List<JobResult> results)
{
var sb = new StringBuilder();
sb.AppendLine(
"Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes"
);
foreach (var r in results)
{
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
sb.AppendLine(
string.Join(
",",
Csv(r.JobName),
Csv(r.EngineName),
r.Valid,
r.Crashed,
r.FullyPlaced,
r.PartsPlaced,
r.PartsRequested,
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
r.NetUtilization.ToString("F4", CultureInfo.InvariantCulture),
r.PlateArea.ToString("F2", CultureInfo.InvariantCulture),
r.NetSheetArea.ToString("F2", CultureInfo.InvariantCulture),
r.Cost.ToString("F2", CultureInfo.InvariantCulture),
r.PlatesUsed,
Csv(SizeSummary(r.SizeBreakdown)),
r.ElapsedMs,
Csv(notes)
)
);
}
File.WriteAllText(path, sb.ToString());
}
private static string SizeSummary(Dictionary<string, int> breakdown)
{
if (breakdown == null || breakdown.Count == 0)
return "-";
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
private static Dictionary<string, int> CountWins(List<JobResult> results)
{
var wins = new Dictionary<string, int>();
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
if (ranked.Count == 0 || !ranked[0].Valid)
continue;
foreach (var r in ranked.TakeWhile(r => Compare(r, ranked[0]) == 0))
wins[r.EngineName] = wins.GetValueOrDefault(r.EngineName) + 1;
}
return wins;
}
/// <summary>Lower sorts first (better). Valid beats invalid, complete beats
/// incomplete, then lower cost (relative tolerance, since costs are areas
/// in whatever units the job uses), then fewer plates.</summary>
public static int Compare(JobResult a, JobResult b)
{
if (a.Valid != b.Valid)
return a.Valid ? -1 : 1;
if (!a.Valid)
return 0;
if (a.FullyPlaced != b.FullyPlaced)
return a.FullyPlaced ? -1 : 1;
var costDiff = a.Cost - b.Cost;
var scale = System.Math.Max(1, System.Math.Max(a.Cost, b.Cost));
if (System.Math.Abs(costDiff) > Epsilon * scale)
return costDiff > 0 ? 1 : -1;
if (a.PlatesUsed != b.PlatesUsed)
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
return 0;
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Console</RootNamespace> <RootNamespace>OpenNest.Console</RootNamespace>
<AssemblyName>OpenNest.Console</AssemblyName> <AssemblyName>OpenNest.Console</AssemblyName>
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants> <DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
+381 -85
View File
@@ -1,13 +1,19 @@
using OpenNest;
using OpenNest.Geometry;
using OpenNest.IO;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading; using System.Threading;
using OpenNest;
using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bending;
return NestConsole.Run(args); return NestConsole.Run(args);
@@ -15,17 +21,90 @@ 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)
return 0; // --help was requested return 0; // --help was requested
if (
options.RepairBendsMillimeters.HasValue
&& (
options.CadUnits == BendRepairUnits.Unspecified
|| !double.IsFinite(options.RepairBendsMillimeters.Value)
|| options.RepairBendsMillimeters <= 0.001
|| options.RepairBendsMillimeters > 3.175
)
)
{
Console.Error.WriteLine(
"Error: --repair-bends-mm requires a limit > 0.001 and <= 3.175 mm and --cad-units inches|mm."
);
return 1;
}
if (options.ListPosts) if (options.ListPosts)
{ {
ListPostProcessors(options); ListPostProcessors(options);
return 0; return 0;
} }
// Validate --engine up front: autonest names a jobs engine, plain fill names a
// single-plate placement strategy. Unknown names exit with the valid choices.
if (options.AutoNest)
{
var isJobsEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
e.Name.Equals(options.Engine, StringComparison.OrdinalIgnoreCase)
);
if (!isJobsEngine)
{
Console.Error.WriteLine(
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
);
return 1;
}
}
else
{
try
{
PlateFillService.ResolveStrategy(options.Engine);
}
catch (NotSupportedException)
{
Console.Error.WriteLine(
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
);
return 1;
}
}
if (options.InputFiles.Count == 0) if (options.InputFiles.Count == 0)
{ {
PrintUsage(); PrintUsage();
@@ -48,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);
@@ -58,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;
} }
@@ -82,6 +170,26 @@ static class NestConsole
{ {
switch (args[i]) switch (args[i])
{ {
case "--repair-bends-mm":
o.RepairBendsMillimeters =
i + 1 < args.Length
&& double.TryParse(
args[++i],
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var limit
)
? limit
: double.NaN;
break;
case "--cad-units" when i + 1 < args.Length:
o.CadUnits = args[++i] switch
{
"inches" => BendRepairUnits.Inches,
"mm" => BendRepairUnits.Millimeters,
_ => BendRepairUnits.Unspecified,
};
break;
case "--drawing" when i + 1 < args.Length: case "--drawing" when i + 1 < args.Length:
o.DrawingName = args[++i]; o.DrawingName = args[++i];
break; break;
@@ -113,15 +221,21 @@ 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;
case "--engine" when i + 1 < args.Length: case "--engine" when i + 1 < args.Length:
NestEngineRegistry.ActiveEngineName = args[++i]; o.Engine = args[++i];
break; break;
case "--post" when i + 1 < args.Length: case "--post" when i + 1 < args.Length:
o.PostName = args[++i]; o.PostName = args[++i];
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;
@@ -149,10 +263,14 @@ static class NestConsole
{ {
var nestFile = options.InputFiles.FirstOrDefault(f => var nestFile = options.InputFiles.FirstOrDefault(f =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase) f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); || f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
var dxfFiles = options.InputFiles.Where(f => );
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) || var dxfFiles = options
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList(); .InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
.ToList();
// If we have a nest file, load it and optionally add DXFs. // If we have a nest file, load it and optionally add DXFs.
if (nestFile != null) if (nestFile != null)
@@ -167,13 +285,15 @@ static class NestConsole
if (options.PlateIndex >= nest.Plates.Count) if (options.PlateIndex >= nest.Plates.Count)
{ {
Console.Error.WriteLine($"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})"); Console.Error.WriteLine(
$"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})"
);
return null; return null;
} }
foreach (var dxf in dxfFiles) foreach (var dxf in dxfFiles)
{ {
var drawing = ImportDxf(dxf); var drawing = ImportDxf(dxf, options);
if (drawing == null) if (drawing == null)
return null; return null;
@@ -194,7 +314,9 @@ static class NestConsole
if (!options.PlateSize.HasValue) if (!options.PlateSize.HasValue)
{ {
Console.Error.WriteLine("Error: --size WxL is required when importing DXF files without a nest"); Console.Error.WriteLine(
"Error: --size WxL is required when importing DXF files without a nest"
);
return null; return null;
} }
@@ -204,7 +326,7 @@ static class NestConsole
foreach (var dxf in dxfFiles) foreach (var dxf in dxfFiles)
{ {
var drawing = ImportDxf(dxf); var drawing = ImportDxf(dxf, options);
if (drawing == null) if (drawing == null)
return null; return null;
@@ -216,11 +338,28 @@ static class NestConsole
return newNest; return newNest;
} }
static Drawing ImportDxf(string path) static Drawing ImportDxf(string path, Options options)
{ {
try try
{ {
return CadImporter.ImportDrawing(path); var result = CadImporter.Import(
path,
new CadImportOptions
{
BendRepair = options.RepairBendsMillimeters.HasValue
? new BendRepairOptions
{
DrawingUnits = options.CadUnits,
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
}
: null,
}
);
foreach (var report in result.BendRepairReports)
Console.WriteLine(
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
);
return CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null);
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
@@ -256,7 +395,8 @@ static class NestConsole
// Only apply size override when it wasn't already used to create the plate. // Only apply size override when it wasn't already used to create the plate.
var hasDxfOnly = !options.InputFiles.Any(f => var hasDxfOnly = !options.InputFiles.Any(f =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase) f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); || f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
);
if (options.PlateSize.HasValue && !hasDxfOnly) if (options.PlateSize.HasValue && !hasDxfOnly)
plate.Size = options.PlateSize.Value; plate.Size = options.PlateSize.Value;
@@ -264,36 +404,61 @@ static class NestConsole
static Drawing ResolveDrawing(Nest nest, Options options) static Drawing ResolveDrawing(Nest nest, Options options)
{ {
var drawing = options.DrawingName != null var drawing =
options.DrawingName != null
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName) ? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
: nest.Drawings.FirstOrDefault(); : nest.Drawings.FirstOrDefault();
if (drawing != null) if (drawing != null)
return drawing; return drawing;
Console.Error.WriteLine(options.DrawingName != null Console.Error.WriteLine(
options.DrawingName != null
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}" ? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
: "Error: nest file contains no drawings"); : "Error: nest file contains no drawings"
);
return null; return null;
} }
static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options) static void PrintHeader(
Nest nest,
Plate plate,
Drawing drawing,
int existingCount,
Options options
)
{ {
Console.WriteLine($"Nest: {nest.Name}"); Console.WriteLine($"Nest: {nest.Name}");
var wa = plate.WorkArea(); var wa = plate.WorkArea();
Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}"); Console.WriteLine(
Console.WriteLine($"Drawing: {drawing.Name}"); $"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}"
Console.WriteLine(options.KeepParts );
var existingPartsMessage = options.KeepParts
? $"Keeping {existingCount} existing parts" ? $"Keeping {existingCount} existing parts"
: $"Cleared {existingCount} existing parts"); : options.AutoNest
Console.WriteLine("---"); ? $"Will replace {existingCount} existing parts only after acceptance"
: $"Cleared {existingCount} existing parts";
Console.WriteLine(
$"""
Drawing: {drawing.Name}
{existingPartsMessage}
---
"""
);
} }
static (bool success, long elapsedMs) Fill(Nest nest, Plate plate, Drawing drawing, Options options) static (bool success, long elapsedMs, bool accepted) Fill(
Nest nest,
Plate plate,
Drawing drawing,
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)
{ {
@@ -310,22 +475,96 @@ static class NestConsole
nestItems.Add(new NestItem { Drawing = d, Quantity = qty }); nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
} }
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); Console.WriteLine(
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
);
var engine = NestEngineRegistry.Create(plate); var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
var nestParts = engine.Nest(nestItems, null, CancellationToken.None); accepted = outcome.accepted;
plate.Parts.AddRange(nestParts); success = outcome.committed > 0;
success = nestParts.Count > 0;
} }
else else
{ {
var engine = NestEngineRegistry.Create(plate); // Single-plate fill: explicit placement strategy through the public service;
// the process-global engine registry is never consulted.
var strategy = ResolveFillStrategy(options.Engine);
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity }; var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
success = engine.Fill(item); var parts = PlateFillService.FillItem(
strategy,
plate,
item,
plate.WorkArea(),
null,
token
);
token.ThrowIfCancellationRequested();
if (parts.Count > 0)
plate.Parts.AddRange(parts);
success = parts.Count > 0;
} }
sw.Stop(); sw.Stop();
return (success, sw.ElapsedMilliseconds); return (success, sw.ElapsedMilliseconds, accepted);
}
static (bool accepted, int committed) AutoNestJob(
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
{
var result = NestPipeline.Run(new NestPipelineRequest(
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
foreach (var violation in result.Violations)
Console.Error.WriteLine($"Violation: {violation}");
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
{
Console.Error.WriteLine(result.Plates.Count > 1
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
: !result.CanKeep
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
return (false, 0);
}
token.ThrowIfCancellationRequested();
var proposed = result.Plates.SingleOrDefault();
var committed = proposed?.Parts.Count ?? 0;
if (committed == 0)
{
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
return (false, 0);
}
plate.Parts.Clear();
plate.Size = proposed.Stock.Size;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = 1;
plate.Parts.AddRange(proposed.Parts);
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
return (true, committed);
}
static string ResolveFillStrategy(string engineName)
{
try
{
return PlateFillService.ResolveStrategy(engineName);
}
catch (NotSupportedException)
{
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
);
Console.Error.WriteLine(
isJobEngine
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
);
Environment.Exit(1);
throw; // unreachable
}
} }
static int CheckOverlaps(Plate plate, Options options) static int CheckOverlaps(Plate plate, Options options)
@@ -334,19 +573,25 @@ static class NestConsole
return 0; return 0;
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts); var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
Console.WriteLine(hasOverlaps Console.WriteLine(
hasOverlaps
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points" ? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
: "Overlap check: PASS"); : "Overlap check: PASS"
);
return overlapPts.Count; return overlapPts.Count;
} }
static void PrintResults(bool success, Plate plate, long elapsedMs) static void PrintResults(bool success, Plate plate, long elapsedMs)
{ {
Console.WriteLine($"Result: {(success ? "success" : "failed")}"); Console.WriteLine(
Console.WriteLine($"Parts placed: {plate.Parts.Count}"); $"""
Console.WriteLine($"Utilization: {plate.Utilization():P1}"); Result: {(success ? "success" : "failed")}
Console.WriteLine($"Time: {elapsedMs}ms"); Parts placed: {plate.Parts.Count}
Utilization: {plate.Utilization():P1}
Time: {elapsedMs}ms
"""
);
} }
static void Save(Nest nest, Options options) static void Save(Nest nest, Options options)
@@ -354,22 +599,23 @@ 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)
return options.PostsDir; return options.PostsDir;
var exePath = Assembly.GetEntryAssembly()?.Location var exePath =
?? typeof(NestConsole).Assembly.Location; Assembly.GetEntryAssembly()?.Location ?? typeof(NestConsole).Assembly.Location;
return Path.Combine(Path.GetDirectoryName(exePath), "Posts"); return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
} }
@@ -388,7 +634,11 @@ static class NestConsole
foreach (var type in assembly.GetTypes()) foreach (var type in assembly.GetTypes())
{ {
if (!typeof(IPostProcessor).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract) if (
!typeof(IPostProcessor).IsAssignableFrom(type)
|| type.IsInterface
|| type.IsAbstract
)
continue; continue;
if (Activator.CreateInstance(type) is IPostProcessor processor) if (Activator.CreateInstance(type) is IPostProcessor processor)
@@ -397,7 +647,9 @@ static class NestConsole
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine($"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}"); Console.Error.WriteLine(
$"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}"
);
} }
} }
@@ -421,26 +673,40 @@ 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);
var post = processors.FirstOrDefault(p => var post = processors.FirstOrDefault(p =>
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase)); p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase)
);
if (post == null) if (post == null)
{ {
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found"); Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
if (processors.Count > 0) if (processors.Count > 0)
Console.Error.WriteLine($"Available: {string.Join(", ", processors.Select(p => p.Name))}"); Console.Error.WriteLine(
$"Available: {string.Join(", ", processors.Select(p => p.Name))}"
);
else else
Console.Error.WriteLine($"No post processors found in: {postsDir}"); Console.Error.WriteLine($"No post processors found in: {postsDir}");
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;
@@ -450,42 +716,67 @@ static class NestConsole
var firstInput = options.InputFiles[0]; var firstInput = options.InputFiles[0];
outputFile = Path.Combine( outputFile = Path.Combine(
Path.GetDirectoryName(firstInput), Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc"); $"{Path.GetFileNameWithoutExtension(firstInput)}.cnc"
);
} }
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()
{ {
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]"); Console.Error.WriteLine(
Console.Error.WriteLine(); """
Console.Error.WriteLine("Arguments:"); Usage: OpenNest.Console <input-files...> [options]
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
Console.Error.WriteLine(); Arguments:
Console.Error.WriteLine("Modes:"); input-files One or more .nest nest files or .dxf/.dwg drawing files
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill"); Modes:
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings"); <nest.nest> Load nest and fill (existing behavior)
Console.Error.WriteLine(); <part.dxf> --size WxL Import DXF, create plate, and fill
Console.Error.WriteLine("Options:"); <nest.nest> <part.dxf> Load nest and add imported DXF drawings
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)"); Options:
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)"); --repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm
Console.Error.WriteLine(" --spacing <value> Override part spacing"); --cad-units inches|mm Explicit source coordinate units required for bend repair
Console.Error.WriteLine(" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode"); --drawing <name> Drawing name to fill with (default: first drawing)
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.nest)"); --plate <index> Plate index to fill (default: 0)
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)"); --quantity <n> Max parts to place (default: 0 = unlimited)
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); --spacing <value> Override part spacing
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); --output <path> Output nest file path (default: <input>-result.nest)
Console.Error.WriteLine(" --no-save Skip saving output file"); --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
Console.Error.WriteLine(" --post <name> Run a post processor after nesting"); --autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)"); --allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)"); --engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
Console.Error.WriteLine(" --list-posts List available post processors and exit"); Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
Console.Error.WriteLine(" -h, --help Show this help"); --keep-parts Don't clear existing parts before filling
--check-overlaps Run overlap detection after fill (exit code 1 if found)
--no-save Skip saving output file
--post <name> Run a post processor after nesting
--post-output <path> Output file for post processor (default: <input>.cnc)
--acknowledge-post-risks Explicitly accept displayed verification risks for this invocation
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
--list-posts List available post processors and exit
-h, --help Show this help
"""
);
} }
class Options class Options
@@ -501,10 +792,15 @@ 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 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;
public double? RepairBendsMillimeters;
public BendRepairUnits CadUnits;
} }
} }
+34 -11
View File
@@ -1,5 +1,5 @@
using OpenNest.Geometry; using System.Collections.Generic;
using System.Collections.Generic; using OpenNest.Geometry;
namespace OpenNest namespace OpenNest
{ {
@@ -7,7 +7,10 @@ namespace OpenNest
{ {
public static void Vertically(Entity fixedEntity, Entity movableEntity) public static void Vertically(Entity fixedEntity, Entity movableEntity)
{ {
movableEntity.Offset(fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X, 0); movableEntity.Offset(
fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X,
0
);
} }
public static void Vertically(Entity fixedEntity, List<Entity> entities) public static void Vertically(Entity fixedEntity, List<Entity> entities)
@@ -17,7 +20,10 @@ namespace OpenNest
public static void Vertically(Part fixedPart, Part movablePart) public static void Vertically(Part fixedPart, Part movablePart)
{ {
movablePart.Offset(fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X, 0); movablePart.Offset(
fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X,
0
);
} }
public static void Vertically(Part fixedPart, List<Part> parts) public static void Vertically(Part fixedPart, List<Part> parts)
@@ -27,7 +33,10 @@ namespace OpenNest
public static void Horizontally(Entity fixedEntity, Entity movableEntity) public static void Horizontally(Entity fixedEntity, Entity movableEntity)
{ {
movableEntity.Offset(0, fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y); movableEntity.Offset(
0,
fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y
);
} }
public static void Horizontally(Entity fixedEntity, List<Entity> entities) public static void Horizontally(Entity fixedEntity, List<Entity> entities)
@@ -37,7 +46,10 @@ namespace OpenNest
public static void Horizontally(Part fixedPart, Part movablePart) public static void Horizontally(Part fixedPart, Part movablePart)
{ {
movablePart.Offset(0, fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y); movablePart.Offset(
0,
fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y
);
} }
public static void Horizontally(Part fixedPart, List<Part> parts) public static void Horizontally(Part fixedPart, List<Part> parts)
@@ -67,7 +79,10 @@ namespace OpenNest
public static void Right(Entity fixedEntity, Entity movableEntity) public static void Right(Entity fixedEntity, Entity movableEntity)
{ {
movableEntity.Offset(fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right, 0); movableEntity.Offset(
fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right,
0
);
} }
public static void Right(Entity fixedEntity, List<Entity> entities) public static void Right(Entity fixedEntity, List<Entity> entities)
@@ -107,7 +122,10 @@ namespace OpenNest
public static void Bottom(Entity fixedEntity, Entity movableEntity) public static void Bottom(Entity fixedEntity, Entity movableEntity)
{ {
movableEntity.Offset(0, fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom); movableEntity.Offset(
0,
fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom
);
} }
public static void Bottom(Entity fixedEntity, List<Entity> entities) public static void Bottom(Entity fixedEntity, List<Entity> entities)
@@ -137,14 +155,19 @@ namespace OpenNest
return; return;
var list = new List<Part>(parts); var list = new List<Part>(parts);
list.Sort((p1, p2) => horizontal list.Sort(
(p1, p2) =>
horizontal
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X) ? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y)); : p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y)
);
var lastIndex = list.Count - 1; var lastIndex = list.Count - 1;
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y; var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y; var end = horizontal
? list[lastIndex].BoundingBox.Center.X
: list[lastIndex].BoundingBox.Center.Y;
var spacing = (end - start) / lastIndex; var spacing = (end - start) / lastIndex;
+2 -3
View File
@@ -1,5 +1,4 @@
 namespace OpenNest
namespace OpenNest
{ {
public enum AlignType public enum AlignType
{ {
@@ -10,6 +9,6 @@ namespace OpenNest
Horizontally, Horizontally,
Vertically, Vertically,
EvenlySpaceHorizontally, EvenlySpaceHorizontally,
EvenlySpaceVertically EvenlySpaceVertically,
} }
} }
+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);
}
}
+16 -9
View File
@@ -1,7 +1,7 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Bending namespace OpenNest.Bending
{ {
@@ -10,7 +10,7 @@ namespace OpenNest.Bending
public static readonly Layer EtchLayer = new Layer("ETCH") public static readonly Layer EtchLayer = new Layer("ETCH")
{ {
Color = Color.Green, Color = Color.Green,
IsVisible = true IsVisible = true,
}; };
private const double DefaultEtchLength = 1.0; private const double DefaultEtchLength = 1.0;
@@ -32,9 +32,8 @@ namespace OpenNest.Bending
public double Length => StartPoint.DistanceTo(EndPoint); public double Length => StartPoint.DistanceTo(EndPoint);
public double AngleRadians => Angle.HasValue public double AngleRadians =>
? OpenNest.Math.Angle.ToRadians(Angle.Value) Angle.HasValue ? OpenNest.Math.Angle.ToRadians(Angle.Value) : 0;
: 0;
public Line ToLine() => new Line(StartPoint, EndPoint); public Line ToLine() => new Line(StartPoint, EndPoint);
@@ -66,7 +65,9 @@ namespace OpenNest.Bending
var dx = System.Math.Cos(angle) * etchLength; var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength; var dy = System.Math.Sin(angle) * etchLength;
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))); result.Add(
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
);
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint)); result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
} }
@@ -79,7 +80,8 @@ namespace OpenNest.Bending
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends) public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
{ {
entities.RemoveAll(e => e.Tag == BendEtchTag); entities.RemoveAll(e => e.Tag == BendEtchTag);
if (bends == null) return; if (bends == null)
return;
foreach (var bend in bends) foreach (var bend in bends)
entities.AddRange(bend.GetEtchEntities()); entities.AddRange(bend.GetEtchEntities());
@@ -87,7 +89,12 @@ namespace OpenNest.Bending
private static Line CreateEtchLine(Vector start, Vector end) private static Line CreateEtchLine(Vector start, Vector end)
{ {
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green, Tag = BendEtchTag }; return new Line(start, end)
{
Layer = EtchLayer,
Color = Color.Green,
Tag = BendEtchTag,
};
} }
public override string ToString() public override string ToString()
+1 -1
View File
@@ -4,6 +4,6 @@ namespace OpenNest.Bending
{ {
Unknown, Unknown,
Up, Up,
Down Down,
} }
} }
+19 -12
View File
@@ -5,16 +5,22 @@ namespace OpenNest.CNC
{ {
public class ArcMove : Motion public class ArcMove : Motion
{ {
public ArcMove() public ArcMove() { }
{
}
public ArcMove(double x, double y, double i, double j, RotationType rotation = RotationType.CCW) public ArcMove(
: this(new Vector(x, y), new Vector(i, j), rotation) double x,
{ double y,
} double i,
double j,
RotationType rotation = RotationType.CCW
)
: this(new Vector(x, y), new Vector(i, j), rotation) { }
public ArcMove(Vector endPoint, Vector centerPoint, RotationType rotation = RotationType.CCW) public ArcMove(
Vector endPoint,
Vector centerPoint,
RotationType rotation = RotationType.CCW
)
{ {
EndPoint = endPoint; EndPoint = endPoint;
CenterPoint = centerPoint; CenterPoint = centerPoint;
@@ -68,7 +74,8 @@ namespace OpenNest.CNC
{ {
Layer = Layer, Layer = Layer,
Suppressed = Suppressed, Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null VariableRefs =
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
}; };
} }
@@ -85,9 +92,9 @@ namespace OpenNest.CNC
var i = CenterPoint.X.ToString(dp); var i = CenterPoint.X.ToString(dp);
var j = CenterPoint.Y.ToString(dp); var j = CenterPoint.Y.ToString(dp);
return Rotation == RotationType.CW ? return Rotation == RotationType.CW
string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j) : ? string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j)
string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j); : string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j);
} }
} }
} }
+2 -3
View File
@@ -1,5 +1,4 @@
 namespace OpenNest.CNC
namespace OpenNest.CNC
{ {
public enum CodeType public enum CodeType
{ {
@@ -9,6 +8,6 @@ namespace OpenNest.CNC
RapidMove, RapidMove,
SetFeedrate, SetFeedrate,
SetKerf, SetKerf,
SubProgramCall SubProgramCall,
} }
} }
+1 -3
View File
@@ -2,9 +2,7 @@
{ {
public class Comment : ICode public class Comment : ICode
{ {
public Comment() public Comment() { }
{
}
public Comment(string value) public Comment(string value)
{ {
@@ -1,7 +1,7 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -48,7 +48,12 @@ namespace OpenNest.CNC.CuttingStrategy
for (var iter = 0; iter < 3; iter++) for (var iter = 0; iter < 3; iter++)
{ {
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point; var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _); perimeterSeed = FindPerimeterIntersection(
profile.Perimeter,
lastCutoutPt,
nextPartStart,
out _
);
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed); orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
orderedCutouts.Reverse(); orderedCutouts.Reverse();
@@ -56,7 +61,12 @@ namespace OpenNest.CNC.CuttingStrategy
} }
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point; var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity); perimeterPt = FindPerimeterIntersection(
profile.Perimeter,
finalLastCutout,
nextPartStart,
out perimeterEntity
);
} }
else else
{ {
@@ -79,18 +89,25 @@ namespace OpenNest.CNC.CuttingStrategy
if (!profile.Perimeter.IsClosed()) if (!profile.Perimeter.IsClosed())
EmitRawContour(result, profile.Perimeter); EmitRawContour(result, profile.Perimeter);
else else
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External); EmitContour(
result,
profile.Perimeter,
perimeterPt,
perimeterEntity,
ContourType.External
);
result.Mode = Mode.Incremental; result.Mode = Mode.Incremental;
return new CuttingResult return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
{
Program = result,
LastCutPoint = perimeterPt
};
} }
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType) public CuttingResult ApplySingle(
Program partProgram,
Vector point,
Entity entity,
ContourType contourType
)
{ {
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -141,14 +158,14 @@ namespace OpenNest.CNC.CuttingStrategy
result.Mode = Mode.Incremental; result.Mode = Mode.Incremental;
return new CuttingResult return new CuttingResult { Program = result, LastCutPoint = point };
{
Program = result,
LastCutPoint = point
};
} }
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity) private static (Shape Shape, Entity Entity) FindTargetShape(
ShapeProfile profile,
Vector point,
Entity clickedEntity
)
{ {
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity); var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
if (matched != null) if (matched != null)
@@ -190,20 +207,26 @@ namespace OpenNest.CNC.CuttingStrategy
if (shapeEntity is Line sLine && clickedEntity is Line cLine) if (shapeEntity is Line sLine && clickedEntity is Line cLine)
{ {
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon if (
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon) sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon
)
return shapeEntity; return shapeEntity;
} }
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc) else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
{ {
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon if (
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon) System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon
)
return shapeEntity; return shapeEntity;
} }
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle) else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
{ {
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon if (
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon) System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon
)
return shapeEntity; return shapeEntity;
} }
} }
@@ -218,7 +241,10 @@ namespace OpenNest.CNC.CuttingStrategy
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint)); program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
} }
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint) private static List<ContourEntry> ResolveLeadInPoints(
List<Shape> cutouts,
Vector startPoint
)
{ {
var entries = new ContourEntry[cutouts.Count]; var entries = new ContourEntry[cutouts.Count];
var currentPoint = startPoint; var currentPoint = startPoint;
@@ -235,7 +261,12 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ContourEntry>(entries); return new List<ContourEntry>(entries);
} }
private static Vector FindPerimeterIntersection(Shape perimeter, Vector lastCutout, Vector nextPartStart, out Entity entity) private static Vector FindPerimeterIntersection(
Shape perimeter,
Vector lastCutout,
Vector nextPartStart,
out Entity entity
)
{ {
var ray = new Line(lastCutout, nextPartStart); var ray = new Line(lastCutout, nextPartStart);
@@ -269,7 +300,13 @@ namespace OpenNest.CNC.CuttingStrategy
return HashCode.Combine(r, a); return HashCode.Combine(r, a);
} }
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null) private void EmitContour(
Program program,
Shape shape,
Vector point,
Entity entity,
ContourType? forceType = null
)
{ {
var contourType = forceType ?? DetectContourType(shape); var contourType = forceType ?? DetectContourType(shape);
var winding = DetermineWinding(shape); var winding = DetermineWinding(shape);
@@ -289,7 +326,8 @@ namespace OpenNest.CNC.CuttingStrategy
var outwardAngle = normal - System.Math.PI; var outwardAngle = normal - System.Math.PI;
point = new Vector( point = new Vector(
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle), circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle)); circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle)
);
} }
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal); leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
@@ -297,7 +335,10 @@ namespace OpenNest.CNC.CuttingStrategy
// Build hole sub-program relative to (0,0) // Build hole sub-program relative to (0,0)
var holeCenter = circle.Center; var holeCenter = circle.Center;
var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y); var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y);
var relativeCircle = new Circle(new Vector(0, 0), circle.Radius) { Rotation = circle.Rotation }; var relativeCircle = new Circle(new Vector(0, 0), circle.Radius)
{
Rotation = circle.Rotation,
};
var relativeShape = new Shape(); var relativeShape = new Shape();
relativeShape.Entities.Add(relativeCircle); relativeShape.Entities.Add(relativeCircle);
@@ -314,30 +355,44 @@ namespace OpenNest.CNC.CuttingStrategy
if (!program.SubPrograms.ContainsKey(key)) if (!program.SubPrograms.ContainsKey(key))
program.SubPrograms[key] = subPgm; program.SubPrograms[key] = subPgm;
program.Codes.Add(new SubProgramCall program.Codes.Add(
new SubProgramCall
{ {
Id = key, Id = key,
Program = program.SubPrograms[key], Program = program.SubPrograms[key],
Offset = holeCenter Offset = holeCenter,
}); }
);
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 (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External) var tabbed = Parameters.TabsEnabled
&& Parameters.TabConfig != null
&& 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)
{ {
if (scribeEntities.Count == 0) return; if (scribeEntities.Count == 0)
return;
var shapes = ShapeBuilder.GetShapes(scribeEntities); var shapes = ShapeBuilder.GetShapes(scribeEntities);
foreach (var shape in shapes) foreach (var shape in shapes)
@@ -388,8 +443,265 @@ namespace OpenNest.CNC.CuttingStrategy
return ContourType.Internal; return ContourType.Internal;
} }
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType, /// <summary>
RotationType winding = RotationType.CW) /// Uses the inward angle bisector for straight lead-ins at cutout corners.
/// Edge interiors and other lead-in styles keep the entity normal. Shared
/// by program generation and the manual placement preview.
/// </summary>
public static double ComputeLeadInNormal(
Shape shape,
Vector point,
Entity entity,
ContourType contourType,
LeadIn leadIn,
RotationType winding = RotationType.CW
)
{
var normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.Internal || leadIn is not LineLeadIn
|| !TryGetCorner(shape, point, entity, out var corner))
return normal;
return BisectCorner(point, corner, contourType, winding) ?? normal;
}
/// <summary>
/// Returns the lead-in to emit at <paramref name="point"/> and the normal to
/// generate it with. At a corner of an outside perimeter, a straight
/// (<see cref="LineLeadIn"/>) lead-in extends the edge cut first so the torch
/// enters on that edge's line, provided the pierce keeps
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
/// reflex corner it bisects the notch. The result does not depend on which of
/// the two edges meeting at the corner was picked. Other styles and contour
/// types keep <see cref="ComputeLeadInNormal"/>.
/// </summary>
public static LeadIn ResolveLeadIn(
Shape shape,
Vector point,
Entity entity,
ContourType contourType,
LeadIn leadIn,
RotationType winding,
double pierceClearance,
out double normal
)
{
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|| !TryGetCorner(shape, point, entity, out var corner))
return leadIn;
switch (ClassifyCorner(corner, winding))
{
case CornerKind.Convex:
var pierce = point - corner.TangentOut * line.Length;
if (IsClearStraightLead(shape, point, pierce, pierceClearance))
{
normal = Angle.NormalizeRad((-corner.TangentOut).Angle());
return new LineLeadIn { Length = line.Length, ApproachAngle = 90 };
}
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
return leadIn;
case CornerKind.Smooth:
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
return leadIn;
case CornerKind.Reflex:
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
return leadIn;
default:
return leadIn;
}
}
/// <summary>
/// Lead-out counterpart of <see cref="ResolveLeadIn"/>. At a convex outside
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
/// bisects the notch. Other styles and contour types keep the entity normal.
/// </summary>
public static LeadOut ResolveLeadOut(
Shape shape,
Vector point,
Entity entity,
ContourType contourType,
LeadOut leadOut,
RotationType winding,
double clearance,
out double normal
)
{
normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|| !TryGetCorner(shape, point, entity, out var corner))
return leadOut;
switch (ClassifyCorner(corner, winding))
{
case CornerKind.Convex:
var end = point + corner.TangentIn * line.Length;
if (IsClearStraightLead(shape, point, end, clearance))
{
normal = Angle.NormalizeRad(corner.TangentIn.Angle());
return new LineLeadOut { Length = line.Length, ApproachAngle = 90 };
}
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
return leadOut;
case CornerKind.Smooth:
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
return leadOut;
case CornerKind.Reflex:
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
return leadOut;
default:
return leadOut;
}
}
private enum CornerKind
{
Convex,
Reflex,
Smooth,
Cusp,
}
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
private readonly record struct ContourCorner(
Entity Incoming,
Entity Outgoing,
Vector TangentIn,
Vector TangentOut
);
private static bool TryGetCorner(Shape shape, Vector point, Entity entity, out ContourCorner corner)
{
corner = default;
if (entity is not (Line or Arc) || entity.Length <= Tolerance.Epsilon
|| shape.Entities.Count < 2 || !shape.IsClosed())
return false;
var index = shape.Entities.IndexOf(entity);
if (index < 0)
return false;
var atStart = point.DistanceTo(EntityStartPoint(entity)) <= Tolerance.Epsilon;
if (!atStart && point.DistanceTo(EntityEndPoint(entity)) > Tolerance.Epsilon)
return false;
var adjacentIndex = atStart
? (index + shape.Entities.Count - 1) % shape.Entities.Count
: (index + 1) % shape.Entities.Count;
var adjacent = shape.Entities[adjacentIndex];
var adjacentPoint = atStart ? EntityEndPoint(adjacent) : EntityStartPoint(adjacent);
if (adjacent is not (Line or Arc) || adjacent.Length <= Tolerance.Epsilon
|| point.DistanceTo(adjacentPoint) > Tolerance.Epsilon)
return false;
var incoming = atStart ? adjacent : entity;
var outgoing = atStart ? entity : adjacent;
var tangentIn = TravelTangent(incoming, point);
var tangentOut = TravelTangent(outgoing, point);
if (!IsFinite(tangentIn) || !IsFinite(tangentOut))
return false;
corner = new ContourCorner(incoming, outgoing, tangentIn, tangentOut);
return true;
}
/// <summary>Unit direction of travel along a line or arc at a point on it.</summary>
private static Vector TravelTangent(Entity entity, Vector point)
{
if (entity is Line line)
return (line.EndPoint - line.StartPoint).Normalize();
var arc = (Arc)entity;
var radial = (point - arc.Center).Normalize();
return arc.IsReversed ? new Vector(radial.Y, -radial.X) : new Vector(-radial.Y, radial.X);
}
private static bool IsFinite(Vector v) => double.IsFinite(v.X) && double.IsFinite(v.Y);
/// <summary>
/// Convex corners point away from the part (interior angle under 180 degrees).
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner.
/// </summary>
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
{
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
return dot > 0 ? CornerKind.Smooth : CornerKind.Cusp;
return turn > 0 ? CornerKind.Convex : CornerKind.Reflex;
}
private static double? BisectCorner(
Vector point,
ContourCorner corner,
ContourType contourType,
RotationType winding
)
{
var normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
// Sum unit normals rather than averaging angles (which fails at 0/2π).
// Winding makes this point into the scrap even at reflex corners.
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
if (!double.IsFinite(x) || !double.IsFinite(y)
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
return null; // Opposing normals at a cusp have no unique bisector.
return Angle.NormalizeRad(System.Math.Atan2(y, x));
}
/// <summary>
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
/// its free end keeps <paramref name="clearance"/> from the contour and the lead
/// crosses the contour nowhere but at the corner.
/// </summary>
private static bool IsClearStraightLead(Shape shape, Vector corner, Vector end, double clearance)
{
if (!IsFinite(end) || end.DistanceTo(corner) <= Tolerance.Epsilon)
return false;
var nearest = shape.ClosestPointTo(end, out _);
if (nearest.DistanceTo(end) < System.Math.Max(clearance, 0) - Tolerance.Epsilon)
return false;
if (shape.Intersects(new Line(end, corner), out var crossings))
{
foreach (var crossing in crossings)
{
if (crossing.DistanceTo(corner) > Tolerance.ChainTolerance)
return false;
}
}
return true;
}
private static Vector EntityEndPoint(Entity entity)
{
if (entity is Line line)
return line.EndPoint;
if (entity is Arc arc)
return arc.EndPoint();
return Vector.Invalid;
}
public static double ComputeNormal(
Vector point,
Entity entity,
ContourType contourType,
RotationType winding = RotationType.CW
)
{ {
double normal; double normal;
@@ -442,7 +754,12 @@ namespace OpenNest.CNC.CuttingStrategy
return polygon.RotationDirection(); return polygon.RotationDirection();
} }
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle) private LeadIn ClampLeadInForCircle(
LeadIn leadIn,
Circle circle,
Vector contourPoint,
double normalAngle
)
{ {
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0) if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
return leadIn; return leadIn;
@@ -492,7 +809,7 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn, ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
ContourType.Internal => Parameters.InternalLeadIn, ContourType.Internal => Parameters.InternalLeadIn,
_ => Parameters.ExternalLeadIn _ => Parameters.ExternalLeadIn,
}; };
} }
@@ -502,7 +819,7 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut, ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
ContourType.Internal => Parameters.InternalLeadOut, ContourType.Internal => Parameters.InternalLeadOut,
_ => Parameters.ExternalLeadOut _ => Parameters.ExternalLeadOut,
}; };
} }
@@ -565,12 +882,18 @@ namespace OpenNest.CNC.CuttingStrategy
private static Vector EntityStartPoint(Entity entity) private static Vector EntityStartPoint(Entity entity)
{ {
if (entity is Line line) return line.StartPoint; if (entity is Line line)
if (entity is Arc arc) return arc.StartPoint(); return line.StartPoint;
if (entity is Arc arc)
return arc.StartPoint();
return Vector.Zero; return Vector.Zero;
} }
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint, LayerType layer = LayerType.Display) private List<ICode> ConvertShapeToMoves(
Shape shape,
Vector startPoint,
LayerType layer = LayerType.Display
)
{ {
var moves = new List<ICode>(); var moves = new List<ICode>();
@@ -582,15 +905,28 @@ namespace OpenNest.CNC.CuttingStrategy
} }
else if (entity is Arc arc) else if (entity is Arc arc)
{ {
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer }); moves.Add(
new ArcMove(
arc.EndPoint(),
arc.Center,
arc.IsReversed ? RotationType.CW : RotationType.CCW
)
{
Layer = layer,
}
);
} }
else if (entity is Circle circle) else if (entity is Circle circle)
{ {
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }); moves.Add(
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
);
} }
else else
{ {
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}"); throw new System.InvalidOperationException(
$"Unsupported entity type: {entity.Type}"
);
} }
} }
@@ -600,9 +936,12 @@ namespace OpenNest.CNC.CuttingStrategy
private static Vector GetShapeStartPoint(Shape shape) private static Vector GetShapeStartPoint(Shape shape)
{ {
var first = shape.Entities[0]; var first = shape.Entities[0];
if (first is Line line) return line.StartPoint; if (first is Line line)
if (first is Arc arc) return arc.StartPoint(); return line.StartPoint;
if (first is Circle circle) return new Vector(circle.Center.X + circle.Radius, circle.Center.Y); if (first is Arc arc)
return arc.StartPoint();
if (first is Circle circle)
return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
return Vector.Zero; return Vector.Zero;
} }
} }
@@ -4,6 +4,6 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
External, External,
Internal, Internal,
ArcCircle ArcCircle,
} }
} }
@@ -15,7 +15,8 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn(); public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut(); public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }; public LeadIn InternalLeadIn { get; set; } =
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut(); public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn(); public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -7,19 +7,23 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
public double Radius { get; set; } public double Radius { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenter = new Vector( var arcCenter = new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle), contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)); contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin } new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
}; };
} }
@@ -30,10 +34,10 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector( return new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle), arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)); arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
new ArcLeadIn { Radius = Radius * factor };
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double ArcRadius { get; set; } public double ArcRadius { get; set; }
public double Kerf { get; set; } public double Kerf { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0); var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStart = new Vector( var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle), arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)); arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin }, new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin } new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
}; };
} }
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector( return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle), arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)); arcStartY + LineLength * System.Math.Sin(lineAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, Kerf = Kerf }; new CleanHoleLeadIn
{
LineLength = LineLength * factor,
ArcRadius = ArcRadius * factor,
Kerf = Kerf,
};
} }
} }
@@ -1,12 +1,15 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public abstract class LeadIn public abstract class LeadIn
{ {
public abstract List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public abstract List<ICode> Generate(
RotationType winding = RotationType.CW); Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle); public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double ApproachAngle { get; set; } = 135.0; public double ApproachAngle { get; set; } = 135.0;
public double ArcRadius { get; set; } public double ArcRadius { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStart = new Vector( var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle), arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)); arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin }, new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin } new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
}; };
} }
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector( return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle), arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)); arcStartY + LineLength * System.Math.Sin(lineAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new LineArcLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, ApproachAngle = ApproachAngle }; new LineArcLeadIn
{
LineLength = LineLength * factor,
ArcRadius = ArcRadius * factor,
ApproachAngle = ApproachAngle,
};
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -9,15 +9,18 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length { get; set; } public double Length { get; set; }
public double ApproachAngle { get; set; } = 90.0; public double ApproachAngle { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin } new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
}; };
} }
@@ -26,7 +29,8 @@ namespace OpenNest.CNC.CuttingStrategy
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle); var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
return new Vector( return new Vector(
contourStartPoint.X + Length * System.Math.Cos(approachAngle), contourStartPoint.X + Length * System.Math.Cos(approachAngle),
contourStartPoint.Y + Length * System.Math.Sin(approachAngle)); contourStartPoint.Y + Length * System.Math.Sin(approachAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -11,21 +11,25 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length2 { get; set; } public double Length2 { get; set; }
public double ApproachAngle2 { get; set; } = 90.0; public double ApproachAngle2 { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1); var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midPoint = new Vector( var midPoint = new Vector(
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle), contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)); contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(piercePoint),
new LinearMove(midPoint) { Layer = LayerType.Leadin }, new LinearMove(midPoint) { Layer = LayerType.Leadin },
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin } new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
}; };
} }
@@ -38,10 +42,17 @@ namespace OpenNest.CNC.CuttingStrategy
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2); var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
return new Vector( return new Vector(
midX + Length1 * System.Math.Cos(firstAngle), midX + Length1 * System.Math.Cos(firstAngle),
midY + Length1 * System.Math.Sin(firstAngle)); midY + Length1 * System.Math.Sin(firstAngle)
);
} }
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new LineLineLeadIn { Length1 = Length1 * factor, ApproachAngle1 = ApproachAngle1, Length2 = Length2 * factor, ApproachAngle2 = ApproachAngle2 }; new LineLineLeadIn
{
Length1 = Length1 * factor,
ApproachAngle1 = ApproachAngle1,
Length2 = Length2 * factor,
ApproachAngle2 = ApproachAngle2,
};
} }
} }
@@ -1,17 +1,17 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public class NoLeadIn : LeadIn public class NoLeadIn : LeadIn
{ {
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
return new List<ICode> return new List<ICode> { new RapidMove(contourStartPoint) };
{
new RapidMove(contourStartPoint)
};
} }
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -7,8 +7,11 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
public double Radius { get; set; } public double Radius { get; set; }
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle); var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle); var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
@@ -16,11 +19,12 @@ namespace OpenNest.CNC.CuttingStrategy
var endPoint = new Vector( var endPoint = new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2), arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)); arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
);
return new List<ICode> return new List<ICode>
{ {
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout } new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout },
}; };
} }
} }
@@ -1,11 +1,14 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public abstract class LeadOut public abstract class LeadOut
{ {
public abstract List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public abstract List<ICode> Generate(
RotationType winding = RotationType.CW); Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -9,18 +9,19 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length { get; set; } public double Length { get; set; }
public double ApproachAngle { get; set; } = 90.0; public double ApproachAngle { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle); var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
var endPoint = new Vector( var endPoint = new Vector(
contourEndPoint.X + Length * System.Math.Cos(overcutAngle), contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)); contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)
);
return new List<ICode> return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
{
new LinearMove(endPoint) { Layer = LayerType.Leadout }
};
} }
} }
} }
@@ -1,12 +1,15 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public class NoLeadOut : LeadOut public class NoLeadOut : LeadOut
{ {
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle, public override List<ICode> Generate(
RotationType winding = RotationType.CW) Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
return new List<ICode>(); return new List<ICode>();
} }
@@ -9,7 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
BottomSide = 4, BottomSide = 4,
EdgeStart = 5, EdgeStart = 5,
LeftSide = 7, LeftSide = 7,
RightSideAlt = 8 RightSideAlt = 8,
} }
public class SequenceParameters public class SequenceParameters
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double BreakerAngle { get; set; } public double BreakerAngle { get; set; }
public override List<ICode> Generate( public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, Vector tabStartPoint,
RotationType winding = RotationType.CW) Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var codes = new List<ICode>(); var codes = new List<ICode>();
@@ -21,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
var scoreAngle = contourNormalAngle + System.Math.PI; var scoreAngle = contourNormalAngle + System.Math.PI;
var scoreEnd = new Vector( var scoreEnd = new Vector(
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle), tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle),
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)); tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)
);
codes.Add(new LinearMove(scoreEnd)); codes.Add(new LinearMove(scoreEnd));
codes.Add(new RapidMove(tabEndPoint)); codes.Add(new RapidMove(tabEndPoint));
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -8,13 +8,13 @@ namespace OpenNest.CNC.CuttingStrategy
public int MachineTabId { get; set; } public int MachineTabId { get; set; }
public override List<ICode> Generate( public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, Vector tabStartPoint,
RotationType winding = RotationType.CW) Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
return new List<ICode> return new List<ICode> { new RapidMove(tabEndPoint) };
{
new RapidMove(tabEndPoint)
};
} }
} }
} }
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -11,8 +11,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double CutoutMaxHeight { get; set; } public double CutoutMaxHeight { get; set; }
public override List<ICode> Generate( public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, Vector tabStartPoint,
RotationType winding = RotationType.CW) Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var codes = new List<ICode>(); var codes = new List<ICode>();
@@ -29,8 +32,10 @@ namespace OpenNest.CNC.CuttingStrategy
public bool AppliesToCutout(double cutoutWidth, double cutoutHeight) public bool AppliesToCutout(double cutoutWidth, double cutoutHeight)
{ {
return cutoutWidth >= CutoutMinWidth && cutoutWidth <= CutoutMaxWidth return cutoutWidth >= CutoutMinWidth
&& cutoutHeight >= CutoutMinHeight && cutoutHeight <= CutoutMaxHeight; && cutoutWidth <= CutoutMaxWidth
&& cutoutHeight >= CutoutMinHeight
&& cutoutHeight <= CutoutMaxHeight;
} }
} }
} }
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -10,7 +10,10 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadOut TabLeadOut { get; set; } public LeadOut TabLeadOut { get; set; }
public abstract List<ICode> Generate( public abstract List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, Vector tabStartPoint,
RotationType winding = RotationType.CW); Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
} }
} }
+1 -3
View File
@@ -6,9 +6,7 @@
public const int UseMax = -2; public const int UseMax = -2;
public Feedrate() public Feedrate() { }
{
}
public Feedrate(double value) public Feedrate(double value)
{ {
+2 -3
View File
@@ -1,10 +1,9 @@
 namespace OpenNest.CNC
namespace OpenNest.CNC
{ {
public enum KerfType public enum KerfType
{ {
None, None,
Left, Left,
Right Right,
} }
} }
+2 -3
View File
@@ -1,5 +1,4 @@
 namespace OpenNest.CNC
namespace OpenNest.CNC
{ {
public enum LayerType public enum LayerType
{ {
@@ -7,6 +6,6 @@ namespace OpenNest.CNC
Scribe, Scribe,
Cut, Cut,
Leadin, Leadin,
Leadout Leadout,
} }
} }
+4 -7
View File
@@ -6,14 +6,10 @@ namespace OpenNest.CNC
public class LinearMove : Motion public class LinearMove : Motion
{ {
public LinearMove() public LinearMove()
: this(new Vector()) : this(new Vector()) { }
{
}
public LinearMove(double x, double y) public LinearMove(double x, double y)
: this(new Vector(x, y)) : this(new Vector(x, y)) { }
{
}
public LinearMove(Vector endPoint) public LinearMove(Vector endPoint)
{ {
@@ -34,7 +30,8 @@ namespace OpenNest.CNC
{ {
Layer = Layer, Layer = Layer,
Suppressed = Suppressed, Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null VariableRefs =
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
}; };
} }
+2 -3
View File
@@ -1,9 +1,8 @@
 namespace OpenNest.CNC
namespace OpenNest.CNC
{ {
public enum Mode public enum Mode
{ {
Absolute, Absolute,
Incremental Incremental,
} }
} }
+84 -34
View File
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.CNC namespace OpenNest.CNC
{ {
@@ -10,7 +10,8 @@ namespace OpenNest.CNC
{ {
public List<ICode> Codes; public List<ICode> Codes;
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, VariableDefinition> Variables { get; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<int, Program> SubPrograms { get; } = new(); public Dictionary<int, Program> SubPrograms { get; } = new();
@@ -66,9 +67,17 @@ namespace OpenNest.CNC
{ {
if (code is Motion m) if (code is Motion m)
{ {
var cmd = m is RapidMove ? "G00" : (m is ArcMove am ? (am.Rotation == RotationType.CW ? "G02" : "G03") : "G01"); var cmd =
m is RapidMove
? "G00"
: (
m is ArcMove am
? (am.Rotation == RotationType.CW ? "G02" : "G03")
: "G01"
);
sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}"); sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}");
if (m is ArcMove arc) sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}"); if (m is ArcMove arc)
sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
sb.AppendLine(); sb.AppendLine();
} }
} }
@@ -81,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];
@@ -97,10 +109,11 @@ namespace OpenNest.CNC
var dy = subpgm.Offset.Y - origin.Y; var dy = subpgm.Offset.Y - origin.Y;
subpgm.Offset = new Geometry.Vector( subpgm.Offset = new Geometry.Vector(
origin.X + dx * cos - dy * sin, origin.X + dx * cos - dy * sin,
origin.Y + dx * sin + dy * cos); origin.Y + dx * sin + dy * cos
);
} }
if (subpgm.Program != null) if (subpgm.Program != null && rotatedSubPrograms.Add(subpgm.Program))
subpgm.Program.Rotate(angle, origin); subpgm.Program.Rotate(angle, origin);
} }
@@ -130,8 +143,7 @@ namespace OpenNest.CNC
if (code is SubProgramCall subpgm) if (code is SubProgramCall subpgm)
{ {
subpgm.Offset = new Geometry.Vector( subpgm.Offset = new Geometry.Vector(subpgm.Offset.X + x, subpgm.Offset.Y + y);
subpgm.Offset.X + x, subpgm.Offset.Y + y);
} }
if (code is Motion == false) if (code is Motion == false)
@@ -159,7 +171,9 @@ namespace OpenNest.CNC
if (code is SubProgramCall subpgm) if (code is SubProgramCall subpgm)
{ {
subpgm.Offset = new Geometry.Vector( subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y); subpgm.Offset.X + voffset.X,
subpgm.Offset.Y + voffset.Y
);
} }
if (code is Motion == false) if (code is Motion == false)
@@ -264,7 +278,8 @@ namespace OpenNest.CNC
var code = Codes[i]; var code = Codes[i];
var motion = code as Motion; var motion = code as Motion;
if (motion == null) continue; if (motion == null)
continue;
return motion.EndPoint; return motion.EndPoint;
} }
@@ -280,7 +295,8 @@ namespace OpenNest.CNC
var code = Codes[i]; var code = Codes[i];
var motion = code as Motion; var motion = code as Motion;
if (motion == null) continue; if (motion == null)
continue;
pos += motion.EndPoint; pos += motion.EndPoint;
} }
@@ -292,22 +308,27 @@ namespace OpenNest.CNC
return new Vector(0, 0); return new Vector(0, 0);
} }
/// <summary>
/// Bounding box of the geometry the program visits. The tool's starting position is not
/// part of the geometry, so the origin only contributes when the program reaches it.
/// An empty program returns a zero-size box at the origin.
/// </summary>
public Box BoundingBox() public Box BoundingBox()
{ {
var origin = new Vector(0, 0); var origin = new Vector(0, 0);
return BoundingBox(ref origin); return BoundingBox(ref origin, out var box) ? box : new Box(0, 0, 0, 0);
} }
private Box BoundingBox(ref Vector pos) private bool BoundingBox(ref Vector pos, out Box result)
{ {
// Capture the frame origin at entry. Sub-program Offsets and // Capture the frame origin at entry. Sub-program Offsets and
// absolute-mode endpoints are relative to this fixed origin. // absolute-mode endpoints are relative to this fixed origin.
var frameOrigin = pos; var frameOrigin = pos;
double minX = 0.0; var minX = double.PositiveInfinity;
double minY = 0.0; var minY = double.PositiveInfinity;
double maxX = 0.0; var maxX = double.NegativeInfinity;
double maxY = 0.0; var maxY = double.NegativeInfinity;
for (int i = 0; i < Codes.Count; ++i) for (int i = 0; i < Codes.Count; ++i)
{ {
@@ -318,18 +339,19 @@ namespace OpenNest.CNC
case CodeType.LinearMove: case CodeType.LinearMove:
{ {
var line = (LinearMove)code; var line = (LinearMove)code;
var pt = Mode == Mode.Absolute ? var pt =
frameOrigin + line.EndPoint : Mode == Mode.Absolute
line.EndPoint + pos; ? frameOrigin + line.EndPoint
: line.EndPoint + pos;
if (pt.X > maxX) if (pt.X > maxX)
maxX = pt.X; maxX = pt.X;
else if (pt.X < minX) if (pt.X < minX)
minX = pt.X; minX = pt.X;
if (pt.Y > maxY) if (pt.Y > maxY)
maxY = pt.Y; maxY = pt.Y;
else if (pt.Y < minY) if (pt.Y < minY)
minY = pt.Y; minY = pt.Y;
pos = pt; pos = pt;
@@ -340,18 +362,19 @@ namespace OpenNest.CNC
case CodeType.RapidMove: case CodeType.RapidMove:
{ {
var line = (RapidMove)code; var line = (RapidMove)code;
var pt = Mode == Mode.Absolute var pt =
Mode == Mode.Absolute
? frameOrigin + line.EndPoint ? frameOrigin + line.EndPoint
: line.EndPoint + pos; : line.EndPoint + pos;
if (pt.X > maxX) if (pt.X > maxX)
maxX = pt.X; maxX = pt.X;
else if (pt.X < minX) if (pt.X < minX)
minX = pt.X; minX = pt.X;
if (pt.Y > maxY) if (pt.Y > maxY)
maxY = pt.Y; maxY = pt.Y;
else if (pt.Y < minY) if (pt.Y < minY)
minY = pt.Y; minY = pt.Y;
pos = pt; pos = pt;
@@ -455,7 +478,8 @@ namespace OpenNest.CNC
// Sub-program frame origin in this program's frame // Sub-program frame origin in this program's frame
// is frameOrigin + Offset, regardless of current pos. // is frameOrigin + Offset, regardless of current pos.
pos = frameOrigin + subpgm.Offset; pos = frameOrigin + subpgm.Offset;
var box = subpgm.Program.BoundingBox(ref pos); if (!subpgm.Program.BoundingBox(ref pos, out var box))
break;
if (box.Left < minX) if (box.Left < minX)
minX = box.Left; minX = box.Left;
@@ -474,16 +498,19 @@ namespace OpenNest.CNC
} }
} }
return new Box(minX, minY, maxX - minX, maxY - minY); if (minX > maxX || minY > maxY)
{
result = new Box(0, 0, 0, 0);
return false;
}
result = new Box(minX, minY, maxX - minX, maxY - minY);
return true;
} }
public object Clone() public object Clone()
{ {
var pgm = new Program() var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
{
mode = this.mode,
Rotation = this.Rotation
};
var codes = new ICode[Length]; var codes = new ICode[Length];
@@ -495,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;
} }
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace OpenNest.CNC;
/// <summary>
/// A character range in generated G-code assigned by one highlighting rule.
/// </summary>
/// <param name="Index">Zero-based UTF-16 index of the first colored character.</param>
/// <param name="Length">Number of UTF-16 characters to color.</param>
/// <param name="RuleIndex">
/// Zero-based rule index: 0 comments, 1 motion modes (G90/G91), 2 rapid moves (G00),
/// 3 linear moves (G01), 4 arcs (G02/G03).
/// </param>
public readonly record struct HighlightSpan(int Index, int Length, int RuleIndex);
/// <summary>Computes cosmetic G-code highlight spans without changing the generated text.</summary>
public static class ProgramHighlighting
{
private static readonly Regex[] Rules =
{
new(@"^;.*$", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
new(@"^G9[01]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
new(@"^G00\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
new(@"^G01\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
new(@"^G0[23]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
};
/// <summary>
/// Materializes all matches in rule/application order (later rules overwrite earlier ones).
/// A timeout propagates before any result is published; no partial span list escapes.
/// The timeout is per regex match, not a deadline for the complete operation.
/// </summary>
/// <exception cref="RegexMatchTimeoutException">A rule exceeded its match budget.</exception>
public static IReadOnlyList<HighlightSpan> ComputeSpans(string text)
{
ArgumentNullException.ThrowIfNull(text);
var spans = new List<HighlightSpan>();
for (var ruleIndex = 0; ruleIndex < Rules.Length; ruleIndex++)
{
// MatchCollection is lazy: enumerate every rule before returning any spans.
foreach (Match match in Rules[ruleIndex].Matches(text))
spans.Add(new HighlightSpan(match.Index, match.Length, ruleIndex));
}
return spans.AsReadOnly();
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ namespace OpenNest.CNC
public List<string> EmitDeclarations() public List<string> EmitDeclarations()
{ {
return _variables.Values return _variables
.Where(v => v.Expression != null) .Values.Where(v => v.Expression != null)
.OrderBy(v => v.Number) .OrderBy(v => v.Number)
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})") .Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
.ToList(); .ToList();
+37 -8
View File
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC namespace OpenNest.CNC
{ {
@@ -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)
@@ -36,7 +58,13 @@ namespace OpenNest.CNC
return basePos; return basePos;
} }
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results) private static void Walk(
Program pgm,
Vector basePos,
ref Vector pos,
bool skipFirst,
List<Segment> results
)
{ {
var skipped = !skipFirst; var skipped = !skipFirst;
@@ -60,7 +88,8 @@ namespace OpenNest.CNC
} }
else if (code is Motion motion) else if (code is Motion motion)
{ {
var endpt = pgm.Mode == Mode.Incremental var endpt =
pgm.Mode == Mode.Incremental
? motion.EndPoint + pos ? motion.EndPoint + pos
: motion.EndPoint + basePos; : motion.EndPoint + basePos;
+2 -1
View File
@@ -30,7 +30,8 @@ namespace OpenNest.CNC
return new RapidMove(EndPoint) return new RapidMove(EndPoint)
{ {
Suppressed = Suppressed, Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null VariableRefs =
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
}; };
} }
+20 -6
View File
@@ -1,4 +1,4 @@
using System.Text; using System.Text;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
@@ -9,9 +9,7 @@ namespace OpenNest.CNC
private double rotation; private double rotation;
private Program program; private Program program;
public SubProgramCall() public SubProgramCall() { }
{
}
public SubProgramCall(Program program, double rotation) public SubProgramCall(Program program, double rotation)
{ {
@@ -81,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()
+7 -2
View File
@@ -8,8 +8,13 @@ namespace OpenNest.CNC
public bool Inline { get; } public bool Inline { get; }
public bool Global { get; } public bool Global { get; }
public VariableDefinition(string name, string expression, double value, public VariableDefinition(
bool inline = false, bool global = false) string name,
string expression,
double value,
bool inline = false,
bool global = false
)
{ {
Name = name; Name = name;
Expression = expression; Expression = expression;
+93 -4
View File
@@ -1,6 +1,6 @@
using System.Linq;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using System.Linq;
namespace OpenNest namespace OpenNest
{ {
@@ -14,6 +14,12 @@ namespace OpenNest
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary> /// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
public const double SnapToZero = 0.001; public const double SnapToZero = 0.001;
/// <summary>Centroid offsets below this fraction of the MBR extent count as symmetric.</summary>
private const double SymmetryTolerance = 1e-6;
/// <summary>Angular margin (radians) keeping axis-aligned centroid offsets off the edge of the preferred quadrant.</summary>
private const double PreferenceMargin = 0.001;
/// <summary> /// <summary>
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which /// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula /// computes the MBR itself) and by PartClassifier (which already has one). Single formula
@@ -44,8 +50,9 @@ namespace OpenNest
if (drawing?.Program == null) if (drawing?.Program == null)
return 0.0; return 0.0;
var entities = ConvertProgram.ToGeometry(drawing.Program) var entities = ConvertProgram
.Where(e => e.Layer != SpecialLayers.Rapid); .ToGeometry(drawing.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer));
var shapes = ShapeBuilder.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0) if (shapes.Count == 0)
@@ -72,7 +79,89 @@ namespace OpenNest
return 0.0; return 0.0;
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull); var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
return FromMbr(mbr); var angle = FromMbr(mbr);
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
return angle;
var quarterTurns = PreferredQuarterTurns(polygon, hull, angle);
if (quarterTurns == 0)
return angle;
return NormalizeSigned(angle + quarterTurns * System.Math.PI / 2.0);
}
/// <summary>
/// The MBR only fixes the frame modulo 90°, leaving four equivalent orientations. Nest
/// results are not 90°-symmetric, so pick one deterministically: the quarter-turn count
/// that puts the perimeter's centroid toward the lower-left of its MBR. Shapes with no
/// centroid offset (rectangles, circles) are symmetric and keep the MBR orientation.
/// </summary>
private static int PreferredQuarterTurns(Polygon polygon, Polygon hull, double angle)
{
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var vertex in hull.Vertices)
{
var rotated = vertex.Rotate(angle);
minX = System.Math.Min(minX, rotated.X);
minY = System.Math.Min(minY, rotated.Y);
maxX = System.Math.Max(maxX, rotated.X);
maxY = System.Math.Max(maxY, rotated.Y);
}
var centroid = Centroid(polygon).Rotate(angle);
var dx = centroid.X - (minX + maxX) / 2.0;
var dy = centroid.Y - (minY + maxY) / 2.0;
var extent = System.Math.Max(maxX - minX, maxY - minY);
if (System.Math.Sqrt(dx * dx + dy * dy) <= SymmetryTolerance * extent)
return 0;
// Choose k so the offset direction lands in [PI - margin, 3PI/2 - margin). The margin
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
// interval edge so floating-point noise cannot flip the choice.
var halfPi = System.Math.PI / 2.0;
var direction = System.Math.Atan2(dy, dx);
for (var turns = 0; turns < 4; turns++)
{
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
relative -= 2.0 * System.Math.PI * System.Math.Floor(relative / (2.0 * System.Math.PI));
if (relative < halfPi)
return turns;
}
return 0;
}
private static Vector Centroid(Polygon polygon)
{
var vertices = polygon.Vertices;
var doubleArea = 0.0;
var cx = 0.0;
var cy = 0.0;
for (var i = 0; i < vertices.Count; i++)
{
var p = vertices[i];
var q = vertices[(i + 1) % vertices.Count];
var cross = p.X * q.Y - q.X * p.Y;
doubleArea += cross;
cx += (p.X + q.X) * cross;
cy += (p.Y + q.Y) * cross;
}
if (System.Math.Abs(doubleArea) <= OpenNest.Math.Tolerance.Epsilon)
return new Vector(vertices.Average(v => v.X), vertices.Average(v => v.Y));
return new Vector(cx / (3.0 * doubleArea), cy / (3.0 * doubleArea));
}
private static double NormalizeSigned(double angle)
{
var twoPi = 2.0 * System.Math.PI;
angle -= twoPi * System.Math.Floor((angle + System.Math.PI) / twoPi);
return angle;
} }
} }
} }
@@ -2,7 +2,5 @@
namespace OpenNest.Collections namespace OpenNest.Collections
{ {
public class DrawingCollection : HashSet<Drawing> public class DrawingCollection : HashSet<Drawing> { }
{
}
} }
+17 -6
View File
@@ -1,7 +1,7 @@
using OpenNest.Geometry;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Converters namespace OpenNest.Converters
{ {
@@ -10,7 +10,7 @@ namespace OpenNest.Converters
Perimeter, Perimeter,
Hole, Hole,
Etch, Etch,
Open Open,
} }
public sealed class ContourInfo public sealed class ContourInfo
@@ -91,7 +91,8 @@ namespace OpenNest.Converters
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter) // Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
for (var i = 0; i < shapes.Count; i++) for (var i = 0; i < shapes.Count; i++)
{ {
if (i == perimeterIndex) continue; if (i == perimeterIndex)
continue;
var shape = shapes[i]; var shape = shapes[i];
var type = ClassifyShape(shape); var type = ClassifyShape(shape);
@@ -116,7 +117,13 @@ namespace OpenNest.Converters
} }
// Perimeter last // Perimeter last
result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter")); result.Add(
new ContourInfo(
shapes[perimeterIndex],
ContourClassification.Perimeter,
"Perimeter"
)
);
return result; return result;
} }
@@ -124,8 +131,12 @@ namespace OpenNest.Converters
private static ContourClassification ClassifyShape(Shape shape) private static ContourClassification ClassifyShape(Shape shape)
{ {
// Check etch layer — all entities must be on ETCH layer // Check etch layer — all entities must be on ETCH layer
if (shape.Entities.Count > 0 && if (
shape.Entities.All(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase))) shape.Entities.Count > 0
&& shape.Entities.All(e =>
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
)
)
return ContourClassification.Etch; return ContourClassification.Etch;
if (shape.IsClosed()) if (shape.IsClosed())
+38 -9
View File
@@ -1,7 +1,7 @@
using OpenNest.CNC; using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Converters namespace OpenNest.Converters
{ {
@@ -87,14 +87,24 @@ namespace OpenNest.Converters
lastpt = endpt; lastpt = endpt;
var layer = ClassifyLayer(arc);
var sweep = System.Math.Abs(arc.SweepAngle()); var sweep = System.Math.Abs(arc.SweepAngle());
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI)) if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{ {
pgm.LineTo(endpt); pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
} }
else else
{ {
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW); pgm.Codes.Add(
new ArcMove(
endpt,
arc.Center,
arc.IsReversed ? RotationType.CW : RotationType.CCW
)
{
Layer = layer,
}
);
} }
return lastpt; return lastpt;
@@ -107,7 +117,12 @@ namespace OpenNest.Converters
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt); pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, circle.Rotation); pgm.Codes.Add(
new ArcMove(startpt, circle.Center, circle.Rotation)
{
Layer = ClassifyLayer(circle),
}
);
lastpt = startpt; lastpt = startpt;
return lastpt; return lastpt;
@@ -118,13 +133,27 @@ namespace OpenNest.Converters
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(line.StartPoint); pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint); pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
move.Layer = LayerType.Scribe;
pgm.Codes.Add(move);
lastpt = line.EndPoint; lastpt = line.EndPoint;
return lastpt; return lastpt;
} }
// Engrave/etch/scribe geometry maps to Scribe so the post processor can treat it as a
// separate tool pass; everything else keeps the move's default Cut layer. SCRIBE is the
// name marks carry once saved (SpecialLayers.Scribe), so drawings rebuilt from stored
// entities must map it too or their marks silently become cut moves.
private static LayerType ClassifyLayer(Entity geo)
{
var name = geo.Layer?.Name;
if (
string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, SpecialLayers.Scribe.Name, System.StringComparison.OrdinalIgnoreCase)
)
return LayerType.Scribe;
return LayerType.Cut;
}
} }
} }
+84 -15
View File
@@ -1,7 +1,7 @@
using OpenNest.CNC; using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Converters namespace OpenNest.Converters
{ {
@@ -18,7 +18,12 @@ namespace OpenNest.Converters
return geometry; return geometry;
} }
private static void AddProgram(Program program, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) private static void AddProgram(
Program program,
ref Mode mode,
ref Vector curpos,
ref List<Entity> geometry
)
{ {
// Capture the frame origin at entry. Sub-program Offsets are relative // Capture the frame origin at entry. Sub-program Offsets are relative
// to this fixed origin, not to the current tool position. // to this fixed origin, not to the current tool position.
@@ -49,7 +54,10 @@ namespace OpenNest.Converters
// The sub-program's frame origin in this program's frame is // The sub-program's frame origin in this program's frame is
// frameOrigin + Offset — independent of current tool position. // frameOrigin + Offset — independent of current tool position.
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y); curpos = new Vector(
frameOrigin.X + subpgm.Offset.X,
frameOrigin.Y + subpgm.Offset.Y
);
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry); AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
mode = savedMode; mode = savedMode;
@@ -58,7 +66,12 @@ namespace OpenNest.Converters
} }
} }
private static void AddLinearMove(LinearMove linearMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) private static void AddLinearMove(
LinearMove linearMove,
ref Mode mode,
ref Vector curpos,
ref List<Entity> geometry
)
{ {
var pt = linearMove.EndPoint; var pt = linearMove.EndPoint;
@@ -66,16 +79,17 @@ namespace OpenNest.Converters
pt += curpos; pt += curpos;
var layer = ConvertLayer(linearMove.Layer); var layer = ConvertLayer(linearMove.Layer);
var line = new Line(curpos, pt) var line = new Line(curpos, pt) { Layer = layer, Color = layer.Color };
{
Layer = layer,
Color = layer.Color
};
geometry.Add(line); geometry.Add(line);
curpos = pt; curpos = pt;
} }
private static void AddRapidMove(RapidMove rapidMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) private static void AddRapidMove(
RapidMove rapidMove,
ref Mode mode,
ref Vector curpos,
ref List<Entity> geometry
)
{ {
var pt = rapidMove.EndPoint; var pt = rapidMove.EndPoint;
@@ -85,13 +99,18 @@ namespace OpenNest.Converters
var line = new Line(curpos, pt) var line = new Line(curpos, pt)
{ {
Layer = SpecialLayers.Rapid, Layer = SpecialLayers.Rapid,
Color = SpecialLayers.Rapid.Color Color = SpecialLayers.Rapid.Color,
}; };
geometry.Add(line); geometry.Add(line);
curpos = pt; curpos = pt;
} }
private static void AddArcMove(ArcMove arcMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry) private static void AddArcMove(
ArcMove arcMove,
ref Mode mode,
ref Vector curpos,
ref List<Entity> geometry
)
{ {
var center = arcMove.CenterPoint; var center = arcMove.CenterPoint;
var endpt = arcMove.EndPoint; var endpt = arcMove.EndPoint;
@@ -102,6 +121,8 @@ namespace OpenNest.Converters
center += curpos; center += curpos;
} }
center = FitCenterToEndpoints(center, curpos, endpt);
var startAngle = center.AngleTo(curpos); var startAngle = center.AngleTo(curpos);
var endAngle = center.AngleTo(endpt); var endAngle = center.AngleTo(endpt);
@@ -112,13 +133,61 @@ namespace OpenNest.Converters
var layer = ConvertLayer(arcMove.Layer); var layer = ConvertLayer(arcMove.Layer);
if (startAngle.IsEqualTo(endAngle)) if (startAngle.IsEqualTo(endAngle))
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation }); geometry.Add(
new Circle(center, radius)
{
Layer = layer,
Color = layer.Color,
Rotation = arcMove.Rotation,
}
);
else else
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color }); geometry.Add(
new Arc(
center,
radius,
startAngle,
endAngle,
arcMove.Rotation == RotationType.CW
)
{
Layer = layer,
Color = layer.Color,
}
);
curpos = endpt; curpos = endpt;
} }
/// <summary>
/// Programs can carry arc centers that are not quite equidistant from the
/// start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from
/// the end radius alone then leaves its start point off the previous move's
/// end, which breaks contour chaining. Project the center onto the chord's
/// perpendicular bisector so the arc passes through both endpoints exactly.
/// </summary>
private static Vector FitCenterToEndpoints(Vector center, Vector start, Vector end)
{
var startRadius = center.DistanceTo(start);
var endRadius = center.DistanceTo(end);
if (startRadius.IsEqualTo(endRadius))
return center;
var chord = end - start;
var chordLengthSq = chord.X * chord.X + chord.Y * chord.Y;
// Full circle (start == end): no chord to fit against.
if (chordLengthSq < Tolerance.Epsilon * Tolerance.Epsilon)
return center;
var mid = new Vector((start.X + end.X) * 0.5, (start.Y + end.Y) * 0.5);
var normal = new Vector(-chord.Y, chord.X);
var t = ((center.X - mid.X) * normal.X + (center.Y - mid.Y) * normal.Y) / chordLengthSq;
return new Vector(mid.X + normal.X * t, mid.Y + normal.Y * t);
}
private static Layer ConvertLayer(LayerType layer) private static Layer ConvertLayer(LayerType layer)
{ {
switch (layer) switch (layer)
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Converters
{
/// <summary>
/// Restores the Scribe layer on program moves that were saved as cuts. Programs built from
/// stored entities before ConvertGeometry recognized the SCRIBE layer name turned etch marks
/// into Cut moves, which nesting then treated as open cut geometry. The drawing's source
/// entities still carry the mark layer, so matching moves are reclassified from them.
/// </summary>
public static class ScribeLayerRepair
{
private const double MatchTolerance = 0.001;
/// <summary>
/// Reclassifies Cut moves in <paramref name="program"/> that lie on a mark entity in
/// <paramref name="sourceEntities"/>. Program coordinates are source coordinates shifted
/// by -<paramref name="sourceOffset"/>. Returns the number of moves reclassified.
/// </summary>
public static int Apply(Program program, IEnumerable<Entity> sourceEntities, Vector sourceOffset)
{
if (program == null || sourceEntities == null)
return 0;
var marks = sourceEntities.Where(e => IsMarkLayer(e.Layer)).ToList();
if (marks.Count == 0 || program.Codes.Any(c => c is SubProgramCall))
return 0;
// ToGeometry emits exactly one entity per rapid/linear/arc move of a flat program.
var motions = program.Codes.Where(c => c is RapidMove or LinearMove or ArcMove).ToList();
var geometry = ConvertProgram.ToGeometry(program);
if (geometry.Count != motions.Count)
return 0;
var repaired = 0;
for (var i = 0; i < motions.Count; i++)
{
var source = Translate(geometry[i], sourceOffset);
switch (motions[i])
{
case LinearMove line when line.Layer == LayerType.Cut && IsOnMark(source, marks):
line.Layer = LayerType.Scribe;
repaired++;
break;
case ArcMove arc when arc.Layer == LayerType.Cut && IsOnMark(source, marks):
arc.Layer = LayerType.Scribe;
repaired++;
break;
}
}
return repaired;
}
public static bool IsMarkLayer(Layer layer) =>
layer != null
&& (
layer == SpecialLayers.Scribe
|| string.Equals(layer.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(layer.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|| string.Equals(layer.Name, "ENGRAVE", StringComparison.OrdinalIgnoreCase)
);
private static List<Vector> Translate(Entity entity, Vector offset)
{
var points = entity switch
{
Line l => new List<Vector>
{
l.StartPoint,
l.EndPoint,
new Vector((l.StartPoint.X + l.EndPoint.X) / 2, (l.StartPoint.Y + l.EndPoint.Y) / 2),
},
Arc a => new List<Vector> { a.StartPoint(), a.EndPoint(), a.MidPoint() },
Circle c => new List<Vector>
{
new Vector(c.Center.X + c.Radius, c.Center.Y),
new Vector(c.Center.X - c.Radius, c.Center.Y),
new Vector(c.Center.X, c.Center.Y + c.Radius),
},
_ => new List<Vector>(),
};
return points.ConvertAll(p => new Vector(p.X + offset.X, p.Y + offset.Y));
}
// A move is a mark when every sample point lies on one single mark entity.
private static bool IsOnMark(List<Vector> points, List<Entity> marks) =>
points.Count > 0
&& marks.Any(m => points.All(p => m.ClosestPointTo(p).DistanceTo(p) <= MatchTolerance));
}
}
+128 -27
View File
@@ -1,18 +1,20 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest namespace OpenNest
{ {
public enum CutOffAxis public enum CutOffAxis
{ {
Horizontal, Horizontal,
Vertical Vertical,
} }
public class CutOff public class CutOff
{ {
private const double OffsetTolerance = 0.001;
public Vector Position { get; set; } public Vector Position { get; set; }
public CutOffAxis Axis { get; set; } public CutOffAxis Axis { get; set; }
public double? StartLimit { get; set; } public double? StartLimit { get; set; }
@@ -26,7 +28,11 @@ namespace OpenNest
Drawing = new Drawing(GetName()) { IsCutOff = true }; Drawing = new Drawing(GetName()) { IsCutOff = true };
} }
public void Regenerate(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache = null) public void Regenerate(
Plate plate,
CutOffSettings settings,
Dictionary<Part, Entity> cache = null
)
{ {
var segments = ComputeSegments(plate, settings, cache); var segments = ComputeSegments(plate, settings, cache);
var program = BuildProgram(segments, settings); var program = BuildProgram(segments, settings);
@@ -40,11 +46,17 @@ namespace OpenNest
return $"CutOff-{axisChar}-{coord:F2}"; return $"CutOff-{axisChar}-{coord:F2}";
} }
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache) private List<(double Start, double End)> ComputeSegments(
Plate plate,
CutOffSettings settings,
Dictionary<Part, Entity> cache
)
{ {
var bounds = plate.BoundingBox(includeParts: false); var bounds = plate.BoundingBox(includeParts: false);
double lineStart, lineEnd, cutPosition; double lineStart,
lineEnd,
cutPosition;
if (Axis == CutOffAxis.Vertical) if (Axis == CutOffAxis.Vertical)
{ {
@@ -68,7 +80,14 @@ namespace OpenNest
Entity perimeter = null; Entity perimeter = null;
cache?.TryGetValue(part, out perimeter); cache?.TryGetValue(part, out perimeter);
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance); var partExclusions = GetPartExclusions(
part,
perimeter,
cutPosition,
lineStart,
lineEnd,
settings.PartClearance
);
exclusions.AddRange(partExclusions); exclusions.AddRange(partExclusions);
} }
@@ -107,18 +126,37 @@ namespace OpenNest
private static readonly List<(double Start, double End)> EmptyExclusions = new(); private static readonly List<(double Start, double End)> EmptyExclusions = new();
private List<(double Start, double End)> GetPartExclusions( private List<(double Start, double End)> GetPartExclusions(
Part part, Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance) Part part,
Entity perimeter,
double cutPosition,
double lineStart,
double lineEnd,
double clearance
)
{ {
var bb = part.BoundingBox; var bb = part.BoundingBox;
var 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;
if (perimeter != null) if (perimeter != null)
{ {
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance); var perimeterExclusions = IntersectPerimeter(
perimeter,
cutPosition,
lineStart,
lineEnd,
clearance
);
if (perimeterExclusions != null) if (perimeterExclusions != null)
return perimeterExclusions; return perimeterExclusions;
} }
@@ -126,24 +164,68 @@ namespace OpenNest
return new List<(double Start, double End)> { (partStart, partEnd) }; return new List<(double Start, double End)> { (partStart, partEnd) };
} }
private List<(double Start, double End)> IntersectPerimeter( /// <summary>
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance) /// 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 target = OffsetOutward(perimeter, clearance) ?? perimeter; var bb = part.BoundingBox;
var usedOffset = target != perimeter; var magnitude = System.Math.Max(System.Math.Abs(bb.Left), System.Math.Abs(bb.Right));
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd)); 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;
}
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2) private List<(double Start, double End)> IntersectPerimeter(
Entity perimeter,
double cutPosition,
double lineStart,
double lineEnd,
double clearance
)
{
var offset = OffsetOutward(perimeter, clearance);
var usedOffset = offset != null;
var targets = offset ?? new List<Entity> { perimeter };
var cutLine = new Line(
MakePoint(cutPosition, lineStart),
MakePoint(cutPosition, lineEnd)
);
var pts = new List<Vector>();
foreach (var target in targets)
{
if (target.Intersects(cutLine, out var targetPts))
pts.AddRange(targetPts);
}
if (pts.Count < 2)
return null; return null;
var coords = pts var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
.OrderBy(c => c) .OrderBy(c => c)
.ToList(); .ToList();
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)
@@ -152,21 +234,37 @@ namespace OpenNest
return result; return result;
} }
private static Entity OffsetOutward(Entity perimeter, double clearance) /// <summary>
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
/// narrower than twice the clearance close up instead of leaving a gap the cut
/// could run into. Holes appear only where the perimeter curls back on itself.
/// </summary>
private static List<Entity> OffsetOutward(Entity perimeter, double clearance)
{ {
if (clearance <= 0) if (clearance <= 0)
return null; return null;
try var offset = perimeter switch
{
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
offset?.UpdateBounds();
return offset;
}
catch
{ {
Shape shape => ClipperBridge.OffsetPerimeter(
shape,
clearance,
OffsetTolerance,
circumscribe: true
),
Polygon polygon => ClipperBridge.OffsetPerimeter(
polygon,
clearance,
OffsetTolerance,
circumscribe: true
),
_ => null,
};
if (offset == null || offset.Outers.Count == 0)
return null; return null;
}
return offset.Outers.Concat(offset.Holes).Cast<Entity>().ToList();
} }
private Vector MakePoint(double cutCoord, double lineCoord) => private Vector MakePoint(double cutCoord, double lineCoord) =>
@@ -184,7 +282,10 @@ namespace OpenNest
? (bb.Y - clearance, bb.Y + bb.Width + clearance) ? (bb.Y - clearance, bb.Y + bb.Width + clearance)
: (bb.X - clearance, bb.X + bb.Length + clearance); : (bb.X - clearance, bb.X + bb.Length + clearance);
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings) private Program BuildProgram(
List<(double Start, double End)> segments,
CutOffSettings settings
)
{ {
var program = new Program(); var program = new Program();
+1 -1
View File
@@ -3,7 +3,7 @@ namespace OpenNest
public enum CutDirection public enum CutDirection
{ {
TowardOrigin, TowardOrigin,
AwayFromOrigin AwayFromOrigin,
} }
public class CutOffSettings public class CutOffSettings
+3 -2
View File
@@ -11,11 +11,12 @@ public class CutParameters
public string PostProcessor { get; set; } public string PostProcessor { get; set; }
public Units Units { get; set; } public Units Units { get; set; }
public static CutParameters Default => new() public static CutParameters Default =>
new()
{ {
Feedrate = 100, Feedrate = 100,
RapidTravelRate = 300, RapidTravelRate = 300,
PierceTime = TimeSpan.FromSeconds(0.5), PierceTime = TimeSpan.FromSeconds(0.5),
Units = OpenNest.Units.Inches Units = OpenNest.Units.Inches,
}; };
} }
@@ -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." : "."));
}
}
}
+10 -12
View File
@@ -1,12 +1,12 @@
using OpenNest.Bending; using System;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest namespace OpenNest
{ {
@@ -40,14 +40,10 @@ namespace OpenNest
} }
public Drawing() public Drawing()
: this(string.Empty, new Program()) : this(string.Empty, new Program()) { }
{
}
public Drawing(string name) public Drawing(string name)
: this(name, new Program()) : this(name, new Program()) { }
{
}
public Drawing(string name, Program pgm) public Drawing(string name, Program pgm)
{ {
@@ -127,7 +123,9 @@ namespace OpenNest
public void UpdateArea() public void UpdateArea()
{ {
var geometry = ConvertProgram.ToGeometry(Program).Where(entity => entity.Layer != SpecialLayers.Rapid); var geometry = ConvertProgram
.ToGeometry(Program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer));
var shapes = ShapeBuilder.GetShapes(geometry); var shapes = ShapeBuilder.GetShapes(geometry);
if (shapes.Count == 0) if (shapes.Count == 0)
+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;
}
}
+45 -33
View File
@@ -1,6 +1,6 @@
using OpenNest.Math; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -12,16 +12,18 @@ namespace OpenNest.Geometry
private Vector center; private Vector center;
private bool reversed; private bool reversed;
public Arc() public Arc() { }
{
}
public Arc(double x, double y, double r, double a1, double a2, bool reversed = false) public Arc(double x, double y, double r, double a1, double a2, bool reversed = false)
: this(new Vector(x, y), r, a1, a2, reversed) : this(new Vector(x, y), r, a1, a2, reversed) { }
{
}
public Arc(Vector center, double radius, double startAngle, double endAngle, bool reversed = false) public Arc(
Vector center,
double radius,
double startAngle,
double endAngle,
bool reversed = false
)
{ {
this.center = center; this.center = center;
this.radius = radius; this.radius = radius;
@@ -93,8 +95,7 @@ namespace OpenNest.Geometry
} }
} }
public bool IsFullCircle() => public bool IsFullCircle() => SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
/// <summary> /// <summary>
/// Angle in radians between start and end angles. /// Angle in radians between start and end angles.
@@ -130,10 +131,7 @@ namespace OpenNest.Geometry
public RotationType Rotation public RotationType Rotation
{ {
get { return IsReversed ? RotationType.CW : RotationType.CCW; } get { return IsReversed ? RotationType.CW : RotationType.CCW; }
set set { IsReversed = (value == RotationType.CW); }
{
IsReversed = (value == RotationType.CW);
}
} }
/// <summary> /// <summary>
@@ -144,7 +142,8 @@ namespace OpenNest.Geometry
{ {
return new Vector( return new Vector(
Center.X + Radius * System.Math.Cos(StartAngle), Center.X + Radius * System.Math.Cos(StartAngle),
Center.Y + Radius * System.Math.Sin(StartAngle)); Center.Y + Radius * System.Math.Sin(StartAngle)
);
} }
/// <summary> /// <summary>
@@ -155,7 +154,8 @@ namespace OpenNest.Geometry
{ {
return new Vector( return new Vector(
Center.X + Radius * System.Math.Cos(EndAngle), Center.X + Radius * System.Math.Cos(EndAngle),
Center.Y + Radius * System.Math.Sin(EndAngle)); Center.Y + Radius * System.Math.Sin(EndAngle)
);
} }
/// <summary> /// <summary>
@@ -166,7 +166,8 @@ namespace OpenNest.Geometry
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2); var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
return new Vector( return new Vector(
Center.X + Radius * System.Math.Cos(midAngle), Center.X + Radius * System.Math.Cos(midAngle),
Center.Y + Radius * System.Math.Sin(midAngle)); Center.Y + Radius * System.Math.Sin(midAngle)
);
} }
/// <summary> /// <summary>
@@ -231,7 +232,10 @@ namespace OpenNest.Geometry
return 1; return 1;
var maxAngle = 2.0 * System.Math.Acos(1.0 - tolerance / Radius); var maxAngle = 2.0 * System.Math.Acos(1.0 - tolerance / Radius);
return System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle)); return System.Math.Max(
1,
(int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle)
);
} }
/// <summary> /// <summary>
@@ -242,11 +246,10 @@ namespace OpenNest.Geometry
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false) public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
{ {
var points = new List<Vector>(); var points = new List<Vector>();
var stepAngle = reversed var stepAngle = reversed ? -SweepAngle() / segments : SweepAngle() / segments;
? -SweepAngle() / segments
: SweepAngle() / segments;
var r = circumscribe && segments > 0 var r =
circumscribe && segments > 0
? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0) ? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0)
: Radius; : Radius;
@@ -254,9 +257,12 @@ namespace OpenNest.Geometry
{ {
var angle = stepAngle * i + StartAngle; var angle = stepAngle * i + StartAngle;
points.Add(new Vector( points.Add(
new Vector(
System.Math.Cos(angle) * r + Center.X, System.Math.Cos(angle) * r + Center.X,
System.Math.Sin(angle) * r + Center.Y)); System.Math.Sin(angle) * r + Center.Y
)
);
} }
return points; return points;
@@ -437,20 +443,24 @@ namespace OpenNest.Geometry
boundingBox.Width = maxY - minY; boundingBox.Width = maxY - minY;
} }
/// <summary>
/// Offsets the arc to the given side of its travel direction. The center lies to
/// the left of a CCW arc and to the right of a CW (reversed) one, so the arc grows
/// on the other side and shrinks toward its center. Returns null when it shrinks
/// to nothing.
/// </summary>
public override Entity OffsetEntity(double distance, OffsetSide side) public override Entity OffsetEntity(double distance, OffsetSide side)
{ {
if (side == OffsetSide.Left && reversed) var grows = (side == OffsetSide.Left) == reversed;
{
if (grows)
return new Arc(center, radius + distance, startAngle, endAngle, reversed); return new Arc(center, radius + distance, startAngle, endAngle, reversed);
}
else
{
if (distance >= radius) if (distance >= radius)
return null; return null;
return new Arc(center, radius - distance, startAngle, endAngle, reversed); return new Arc(center, radius - distance, startAngle, endAngle, reversed);
} }
}
public override Entity OffsetEntity(double distance, Vector pt) public override Entity OffsetEntity(double distance, Vector pt)
{ {
@@ -470,7 +480,8 @@ namespace OpenNest.Geometry
{ {
return new Vector( return new Vector(
System.Math.Cos(angle) * Radius + Center.X, System.Math.Cos(angle) * Radius + Center.X,
System.Math.Sin(angle) * Radius + Center.Y); System.Math.Sin(angle) * Radius + Center.Y
);
} }
else else
{ {
@@ -500,7 +511,8 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts) public override bool Intersects(Arc arc, out List<Vector> pts)
{ {
return Intersect.Intersects(this, arc, out pts); ; return Intersect.Intersects(this, arc, out pts);
;
} }
/// <summary> /// <summary>
+55 -41
View File
@@ -14,7 +14,9 @@ namespace OpenNest.Geometry
/// the arc passes through both endpoints and departs P1 in the given direction. /// the arc passes through both endpoints and departs P1 in the given direction.
/// </summary> /// </summary>
internal static (Vector center, double radius, double deviation) FitWithStartTangent( internal static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent) List<Vector> points,
Vector tangent
)
{ {
if (points.Count < 3) if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue); return (Vector.Invalid, 0, double.MaxValue);
@@ -57,14 +59,22 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Fits a circular arc constrained to be tangent to the given directions at both /// Fits a circular arc that passes exactly through both the first and last points
/// the first and last points. The center lies at the intersection of the normals /// while matching the given endpoint tangents as closely as possible. For any
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives /// circle through two points, the tangents at those points make equal mirrored
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent); /// angles with the chord, so the achievable inscribed angle is the average of the
/// deviation includes any endpoint gap at Pn. /// two requested ones — when the requested tangents are consistent with a single
/// circular arc, both are matched exactly.
/// </summary> /// </summary>
internal static (Vector center, double radius, double deviation) FitWithDualTangent( internal static (
List<Vector> points, Vector startTangent, Vector endTangent) Vector center,
double radius,
double deviation
) FitThroughEndpointsWithTangents(
List<Vector> points,
Vector startTangent,
Vector endTangent
)
{ {
if (points.Count < 3) if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue); return (Vector.Invalid, 0, double.MaxValue);
@@ -72,48 +82,51 @@ namespace OpenNest.Geometry
var p1 = points[0]; var p1 = points[0];
var pn = points[^1]; var pn = points[^1];
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
if (stLen < 1e-10 || etLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
// Normal to start tangent at P1 (perpendicular)
var n1x = -startTangent.Y / stLen;
var n1y = startTangent.X / stLen;
// Normal to end tangent at Pn
var n2x = -endTangent.Y / etLen;
var n2y = endTangent.X / etLen;
// Solve: P1 + t1*N1 = Pn + t2*N2
var det = n1x * (-n2y) - (-n2x) * n1y;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var dx = pn.X - p1.X; var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y; var dy = pn.Y - p1.Y;
var t1 = (dx * (-n2y) - (-n2x) * dy) / det; var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
var cx = p1.X + t1 * n1x;
var cy = p1.Y + t1 * n1y;
// Use radius from P1 (guarantees exact start tangent and passes through P1)
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (r1 < 1e-10)
return (Vector.Invalid, 0, double.MaxValue); return (Vector.Invalid, 0, double.MaxValue);
// Measure endpoint gap at Pn var ux = dx / chordLen;
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y)); var uy = dy / chordLen;
var endpointDev = System.Math.Abs(r2 - r1);
var interiorDev = MaxRadialDeviation(points, cx, cy, r1); // Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev)); var theta1 = SignedAngle(ux, uy, startTangent);
var theta2 = -SignedAngle(ux, uy, endTangent);
var theta = (theta1 + theta2) / 2;
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
return (Vector.Invalid, 0, double.MaxValue);
var halfChord = chordLen / 2;
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
var d = -halfChord / System.Math.Tan(theta);
var cx = (p1.X + pn.X) / 2 + d * -uy;
var cy = (p1.Y + pn.Y) / 2 + d * ux;
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
private static double SignedAngle(double ux, double uy, Vector to)
{
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
if (len < 1e-10)
return 0;
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
} }
/// <summary> /// <summary>
/// Computes the maximum radial deviation of interior points from a circle. /// Computes the maximum radial deviation of interior points from a circle.
/// </summary> /// </summary>
internal static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius) internal static double MaxRadialDeviation(
List<Vector> points,
double cx,
double cy,
double radius
)
{ {
var maxDev = 0.0; var maxDev = 0.0;
for (var i = 1; i < points.Count - 1; i++) for (var i = 1; i < points.Count - 1; i++)
@@ -122,7 +135,8 @@ namespace OpenNest.Geometry
var py = points[i].Y - cy; var py = points[i].Y - cy;
var dist = System.Math.Sqrt(px * px + py * py); var dist = System.Math.Sqrt(px * px + py * py);
var dev = System.Math.Abs(dist - radius); var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev; if (dev > maxDev)
maxDev = dev;
} }
return maxDev; return maxDev;
} }
+24 -12
View File
@@ -17,10 +17,14 @@ namespace OpenNest.Geometry
foreach (var box in boxes) foreach (var box in boxes)
{ {
if (box.Left < minX) minX = box.Left; if (box.Left < minX)
if (box.Right > maxX) maxX = box.Right; minX = box.Left;
if (box.Bottom < minY) minY = box.Bottom; if (box.Right > maxX)
if (box.Top > maxY) maxY = box.Top; maxX = box.Right;
if (box.Bottom < minY)
minY = box.Bottom;
if (box.Top > maxY)
maxY = box.Top;
} }
return new Box(minX, minY, maxX - minX, maxY - minY); return new Box(minX, minY, maxX - minX, maxY - minY);
@@ -41,11 +45,15 @@ namespace OpenNest.Geometry
{ {
var vertex = pts[i]; var vertex = pts[i];
if (vertex.X < minX) minX = vertex.X; if (vertex.X < minX)
else if (vertex.X > maxX) maxX = vertex.X; minX = vertex.X;
else if (vertex.X > maxX)
maxX = vertex.X;
if (vertex.Y < minY) minY = vertex.Y; if (vertex.Y < minY)
else if (vertex.Y > maxY) maxY = vertex.Y; minY = vertex.Y;
else if (vertex.Y > maxY)
maxY = vertex.Y;
} }
return new Box(minX, minY, maxX - minX, maxY - minY); return new Box(minX, minY, maxX - minX, maxY - minY);
@@ -65,10 +73,14 @@ namespace OpenNest.Geometry
foreach (var box in items) foreach (var box in items)
{ {
if (box.Left < left) left = box.Left; if (box.Left < left)
if (box.Right > right) right = box.Right; left = box.Left;
if (box.Bottom < bottom) bottom = box.Bottom; if (box.Right > right)
if (box.Top > top) top = box.Top; right = box.Right;
if (box.Bottom < bottom)
bottom = box.Bottom;
if (box.Top > top)
top = box.Top;
} }
return new Box(left, bottom, right - left, top - bottom); return new Box(left, bottom, right - left, top - bottom);
+21 -13
View File
@@ -8,9 +8,7 @@ namespace OpenNest.Geometry
public static readonly Box Empty = new Box(); public static readonly Box Empty = new Box();
public Box() public Box()
: this(0, 0, 0, 0) : this(0, 0, 0, 0) { }
{
}
public Box(double x, double y, double w, double h) public Box(double x, double y, double w, double h)
{ {
@@ -117,10 +115,14 @@ namespace OpenNest.Geometry
public bool Intersects(Box box) public bool Intersects(Box box)
{ {
if (Left >= box.Right) return false; if (Left >= box.Right)
if (Right <= box.Left) return false; return false;
if (Top <= box.Bottom) return false; if (Right <= box.Left)
if (Bottom >= box.Top) return false; return false;
if (Top <= box.Bottom)
return false;
if (Bottom >= box.Top)
return false;
return true; return true;
} }
@@ -146,18 +148,24 @@ namespace OpenNest.Geometry
public bool Contains(Box box) public bool Contains(Box box)
{ {
if (box.Top > Top) return false; if (box.Top > Top)
if (box.Left < Left) return false; return false;
if (box.Right > Right) return false; if (box.Left < Left)
if (box.Bottom < Bottom) return false; return false;
if (box.Right > Right)
return false;
if (box.Bottom < Bottom)
return false;
return true; return true;
} }
public bool Contains(Vector pt) public bool Contains(Vector pt)
{ {
return pt.X >= Left - Tolerance.Epsilon && pt.X <= Right + Tolerance.Epsilon return pt.X >= Left - Tolerance.Epsilon
&& pt.Y >= Bottom - Tolerance.Epsilon && pt.Y <= Top + Tolerance.Epsilon; && pt.X <= Right + Tolerance.Epsilon
&& pt.Y >= Bottom - Tolerance.Epsilon
&& pt.Y <= Top + Tolerance.Epsilon;
} }
public bool IsHorizontalTo(Box box) public bool IsHorizontalTo(Box box)
+29 -30
View File
@@ -1,5 +1,5 @@
using OpenNest.Math; using System.Collections.Generic;
using System.Collections.Generic; using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
@@ -8,14 +8,10 @@ namespace OpenNest.Geometry
private Vector center; private Vector center;
private double radius; private double radius;
public Circle() public Circle() { }
{
}
public Circle(double x, double y, double radius) public Circle(double x, double y, double radius)
: this(new Vector(x, y), radius) : this(new Vector(x, y), radius) { }
{
}
public Circle(Vector center, double radius) public Circle(Vector center, double radius)
{ {
@@ -137,21 +133,22 @@ namespace OpenNest.Geometry
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false) public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
{ {
var points = new List<Vector>(); var points = new List<Vector>();
var stepAngle = Rotation == RotationType.CW var stepAngle =
? -Angle.TwoPI / segments Rotation == RotationType.CW ? -Angle.TwoPI / segments : Angle.TwoPI / segments;
: Angle.TwoPI / segments;
var r = circumscribe && segments > 0 var r =
? Radius / System.Math.Cos(stepAngle / 2.0) circumscribe && segments > 0 ? Radius / System.Math.Cos(stepAngle / 2.0) : Radius;
: Radius;
for (int i = 0; i <= segments; ++i) for (int i = 0; i <= segments; ++i)
{ {
var angle = stepAngle * i; var angle = stepAngle * i;
points.Add(new Vector( points.Add(
new Vector(
System.Math.Cos(angle) * r + Center.X, System.Math.Cos(angle) * r + Center.X,
System.Math.Sin(angle) * r + Center.Y)); System.Math.Sin(angle) * r + Center.Y
)
);
} }
return points; return points;
@@ -276,17 +273,18 @@ namespace OpenNest.Geometry
public override Entity OffsetEntity(double distance, OffsetSide side) public override Entity OffsetEntity(double distance, OffsetSide side)
{ {
if (side == OffsetSide.Left && Rotation == RotationType.CCW) // The center lies to the left of a CCW circle and to the right of a CW one.
var shrinks = (side == OffsetSide.Left) == (Rotation == RotationType.CCW);
if (shrinks)
{ {
return Radius <= distance ? null : new Circle(center, Radius - distance) return Radius <= distance
{ ? null
Layer = Layer, : new Circle(center, Radius - distance) { Layer = Layer, Rotation = Rotation };
Rotation = Rotation
};
} }
else else
{ {
return new Circle(center, Radius + distance) { Layer = Layer }; return new Circle(center, Radius + distance) { Layer = Layer, Rotation = Rotation };
} }
} }
@@ -294,11 +292,9 @@ namespace OpenNest.Geometry
{ {
if (ContainsPoint(pt)) if (ContainsPoint(pt))
{ {
return Radius <= distance ? null : new Circle(center, Radius - distance) return Radius <= distance
{ ? null
Layer = Layer, : new Circle(center, Radius - distance) { Layer = Layer, Rotation = Rotation };
Rotation = Rotation
};
} }
else else
{ {
@@ -317,7 +313,8 @@ namespace OpenNest.Geometry
return new Vector( return new Vector(
System.Math.Cos(angle) * Radius + Center.X, System.Math.Cos(angle) * Radius + Center.X,
System.Math.Sin(angle) * Radius + Center.Y); System.Math.Sin(angle) * Radius + Center.Y
);
} }
/// <summary> /// <summary>
@@ -350,7 +347,9 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle) public override bool Intersects(Circle circle)
{ {
var dist = Center.DistanceTo(circle.Center); var dist = Center.DistanceTo(circle.Center);
return (dist < (Radius + circle.Radius) && dist > System.Math.Abs(Radius - circle.Radius)); return (
dist < (Radius + circle.Radius) && dist > System.Math.Abs(Radius - circle.Radius)
);
} }
/// <summary> /// <summary>
+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
);
}
}
}
+423
View File
@@ -0,0 +1,423 @@
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// Region offsetting through Clipper2, for CPU-side preparation only: work done
/// once per drawing, rotation or spacing whose output is cached and fed to hot
/// loops. Per-pair tests (<see cref="Collision"/>) stay hand-rolled so they can
/// be ported to a GPU kernel.
/// </summary>
public static class ClipperBridge
{
/// <summary>
/// Decimal places Clipper keeps (1e-4 in either inches or mm).
/// </summary>
public const int Precision = 4;
private const double MiterLimit = 2.0;
private const double ConservativeJoinFactor = 0.25;
private const double ValidationJoinFactor = 0.1;
/// <summary>
/// Converts a polygon to a Clipper path, dropping the closing vertex and
/// orienting it positive (CCW) or negative (CW).
/// </summary>
public static PathD ToPath(Polygon polygon, bool positive)
{
var path = ToPath(polygon, new Vector());
if (path.Count >= 3 && Clipper.IsPositive(path) != positive)
path.Reverse();
return path;
}
/// <summary>
/// Converts a polygon to a Clipper path with an optional offset, dropping the
/// closing vertex and keeping the polygon's own winding.
/// </summary>
public static PathD ToPath(Polygon polygon, Vector offset)
{
var verts = polygon.Vertices;
var n = verts.Count;
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
n--;
var path = new PathD(n);
for (var i = 0; i < n; i++)
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
return path;
}
/// <summary>
/// Converts a Clipper path to a closed polygon with updated bounds.
/// </summary>
public static Polygon ToPolygon(PathD path)
{
var polygon = new Polygon();
foreach (var pt in path)
polygon.Vertices.Add(new Vector(pt.x, pt.y));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
/// <summary>
/// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
/// </summary>
public static PathsD ToRegion(ShapeProfile profile, double tolerance, bool circumscribe)
{
var region = new PathsD(profile.Cutouts.Count + 1);
AddShape(region, profile.Perimeter, tolerance, circumscribe, positive: true);
// A cutout is flattened the opposite way: circumscribing it would shrink the
// material around it, so inscribe instead to keep the region conservative.
foreach (var cutout in profile.Cutouts)
AddShape(region, cutout, tolerance, !circumscribe, positive: false);
return region;
}
/// <summary>
/// Offsets a part region outward by <paramref name="distance"/>: the perimeter
/// grows and the cutouts shrink. Features narrower than twice the distance
/// collapse, and cutouts that close up disappear. Joins are round, with chords
/// no more than <paramref name="tolerance"/> from the true arc.
/// </summary>
/// <param name="circumscribe">
/// When true, the result never under-estimates the offset: perimeter arcs are
/// flattened outside the true curve, cutout arcs inside it, and the inflation is
/// padded by the round-join chord error and Clipper's rounding.
/// </param>
public static OffsetRegion Offset(
ShapeProfile profile,
double distance,
double tolerance,
bool circumscribe = false
)
{
var region = ToRegion(profile, tolerance, circumscribe);
return Offset(region, distance, tolerance, circumscribe);
}
/// <summary>
/// Offsets a single closed shape outward, ignoring any cutouts. A perimeter that
/// curls back on itself (a C shape with a narrow mouth) can gain holes.
/// </summary>
public static OffsetRegion OffsetPerimeter(
Shape perimeter,
double distance,
double tolerance,
bool circumscribe = false
)
{
var polygon = Flatten(perimeter, tolerance, circumscribe);
return OffsetPerimeter(polygon, distance, tolerance, circumscribe);
}
/// <summary>
/// Offsets a closed polygon outward, whatever its winding.
/// </summary>
public static OffsetRegion OffsetPerimeter(
Polygon perimeter,
double distance,
double tolerance,
bool circumscribe = false
)
{
var region = new PathsD(1);
AddPolygon(region, perimeter, positive: true);
return Offset(region, distance, tolerance, circumscribe);
}
/// <summary>
/// Offsets an already-flattened region (outers positive, holes negative).
/// A distance of zero only unions the region, with no conservative padding.
/// </summary>
public static OffsetRegion Offset(
PathsD region,
double distance,
double tolerance,
bool circumscribe = false
)
{
// Round joins put their vertices on the true arc, so each chord sits inside
// it by up to the join tolerance. In conservative mode, joins use a finer
// tolerance and the inflation is padded by it (plus Clipper's rounding).
var delta = distance;
var joinTolerance = tolerance;
if (circumscribe && distance > 0)
{
joinTolerance = tolerance * ConservativeJoinFactor;
delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision);
}
return Inflate(region, delta, joinTolerance);
}
/// <summary>
/// Offset for checking a finished layout against its spacing. Arcs are flattened
/// as in conservative mode (perimeter arcs circumscribed, cutout arcs inscribed),
/// but round joins use a tenth of the tolerance and nothing is padded, so a layout
/// exactly at the spacing passes. The only under-estimate is the join chord error
/// at convex corners, at most a tenth of <paramref name="tolerance"/>.
/// </summary>
public static OffsetRegion OffsetForValidation(
ShapeProfile profile,
double distance,
double tolerance
)
{
var region = ToRegion(profile, tolerance, circumscribe: true);
return Inflate(region, distance, tolerance * ValidationJoinFactor);
}
private static OffsetRegion Inflate(PathsD region, double delta, double joinTolerance)
{
var inflated =
delta <= 0
? Union(region)
: Clipper.InflatePaths(
region,
delta,
JoinType.Round,
EndType.Polygon,
MiterLimit,
Precision,
joinTolerance
);
var result = new OffsetRegion(new List<Polygon>(), new List<Polygon>());
foreach (var path in inflated)
{
if (path.Count < 3)
continue;
if (Clipper.IsPositive(path))
result.Outers.Add(ToPolygon(path));
else
result.Holes.Add(ToPolygon(path));
}
return result;
}
/// <summary>
/// Miter-offsets a closed polygon by <paramref name="delta"/> (positive grows it,
/// negative shrinks it). Returns the largest resulting polygon (CCW), or null
/// when the polygon collapses.
/// </summary>
public static Polygon OffsetMiter(Polygon polygon, double delta)
{
var path = ToPath(polygon, positive: true);
if (path.Count < 3)
return null;
var inflated = Clipper.InflatePaths(
new PathsD { path },
delta,
JoinType.Miter,
EndType.Polygon,
MiterLimit,
Precision
);
PathD largest = null;
var largestArea = 0.0;
foreach (var candidate in inflated)
{
var area = Clipper.Area(candidate);
if (area > largestArea)
{
largest = candidate;
largestArea = area;
}
}
return largest == null ? null : ToPolygon(largest);
}
/// <summary>
/// Flattens a closed shape to a polygon whose chords stay within
/// <paramref name="tolerance"/> of every arc. Inscribed, the vertices lie on the
/// arcs. Circumscribed, arc endpoints stay on the arc and the interior vertices sit
/// on tangent intersections, so the polygon never falls inside the curve and never
/// pokes past the straight edges an arc meets.
/// </summary>
public static Polygon Flatten(Shape shape, double tolerance, bool circumscribe)
{
var polygon = new Polygon();
foreach (var entity in shape.Entities)
{
switch (entity)
{
case Line line:
polygon.Vertices.Add(line.StartPoint);
polygon.Vertices.Add(line.EndPoint);
break;
case Arc arc:
AddArc(polygon.Vertices, arc, tolerance, circumscribe);
break;
case Circle circle:
AddCircle(polygon.Vertices, circle, tolerance, circumscribe);
break;
}
}
polygon.Close();
polygon.Cleanup();
polygon.UpdateBounds();
return polygon;
}
private static void AddArc(List<Vector> points, Arc arc, double tolerance, bool circumscribe)
{
if (!circumscribe)
{
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)));
return;
}
var sweep = arc.SweepAngle();
var segments = CircumscribedSegments(arc.Radius, sweep, tolerance);
var step = (arc.IsReversed ? -sweep : sweep) / segments;
var r = arc.Radius / System.Math.Cos(System.Math.Abs(step) / 2);
points.Add(arc.StartPoint());
for (var i = 0; i < segments; i++)
{
var angle = arc.StartAngle + step * (i + 0.5);
points.Add(
new Vector(
arc.Center.X + r * System.Math.Cos(angle),
arc.Center.Y + r * System.Math.Sin(angle)
)
);
}
points.Add(arc.EndPoint());
}
private static void AddCircle(
List<Vector> points,
Circle circle,
double tolerance,
bool circumscribe
)
{
if (!circumscribe)
{
points.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance)));
return;
}
var segments = CircumscribedSegments(circle.Radius, Angle.TwoPI, tolerance);
var step = Angle.TwoPI / segments;
var r = circle.Radius / System.Math.Cos(step / 2);
for (var i = 0; i < segments; i++)
{
points.Add(
new Vector(
circle.Center.X + r * System.Math.Cos(step * i),
circle.Center.Y + r * System.Math.Sin(step * i)
)
);
}
}
/// <summary>
/// Segments for a circumscribed arc: a tangent-intersection vertex sits
/// radius / cos(step / 2) from the center, so keep that within the tolerance, and
/// keep each step at 90 degrees or less so the tangents meet close to the arc.
/// </summary>
private static int CircumscribedSegments(double radius, double sweep, double tolerance)
{
var maxHalfStep = System.Math.Acos(radius / (radius + tolerance));
var segments = (int)System.Math.Ceiling(System.Math.Abs(sweep) / (2 * maxHalfStep));
var quarters = (int)System.Math.Ceiling(System.Math.Abs(sweep) / Angle.HalfPI);
return System.Math.Max(1, System.Math.Max(segments, quarters));
}
private static PathsD Union(PathsD region)
{
var clipper = new ClipperD(Precision);
clipper.AddSubject(region);
var solution = new PathsD();
clipper.Execute(ClipType.Union, FillRule.NonZero, solution);
return solution;
}
private static void AddShape(
PathsD region,
Shape shape,
double tolerance,
bool circumscribe,
bool positive
)
{
AddPolygon(region, Flatten(shape, tolerance, circumscribe), positive);
}
private static void AddPolygon(PathsD region, Polygon polygon, bool positive)
{
if (polygon.Vertices.Count < 3)
return;
var path = ToPath(polygon, positive);
if (path.Count >= 3)
region.Add(path);
}
}
/// <summary>
/// Result of <see cref="ClipperBridge.Offset(ShapeProfile, double, double, bool)"/>:
/// outer boundaries (CCW) and holes (CW), as closed polygons.
/// </summary>
public sealed record OffsetRegion(List<Polygon> Outers, List<Polygon> Holes)
{
/// <summary>
/// The outer boundary with the largest area, or null when the region is empty.
/// </summary>
public Polygon LargestOuter()
{
Polygon best = null;
var bestArea = 0.0;
foreach (var outer in Outers)
{
var area = outer.Area();
if (best == null || area > bestArea)
{
best = outer;
bestArea = area;
}
}
return best;
}
}
}
+229 -142
View File
@@ -1,12 +1,33 @@
using OpenNest.Math; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
/// <summary>
/// Polygon overlap test with hole subtraction. This is the reference implementation
/// for a future GPU kernel, so it deliberately stays hand-rolled instead of using
/// Clipper (which is CPU-only and allocation-heavy; see <see cref="ClipperBridge"/>
/// for the CPU preparation that feeds it).
/// <para>
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
/// then cached and uploaded: the spacing offset (<see cref="ClipperBridge"/>),
/// triangulation (<see cref="ConvexDecomposition.Triangulate"/>) of the outline and
/// each hole, and the bounding box of every polygon and triangle. Per-pair work,
/// kernel-shaped (fixed-size, loop-only, no recursion): the bounding-box rejects,
/// Sutherland-Hodgman clipping of convex triangle pairs (<c>ClipConvex</c>), and
/// subtraction of hole triangles from the clipped regions (<c>SubtractTriangles</c>).
/// Inputs are closed, lines-only polygons; winding is normalized by triangulation.
/// </para>
/// </summary>
public static class Collision public static class Collision
{ {
public static CollisionResult Check(Polygon a, Polygon b, public static CollisionResult Check(
List<Polygon> holesA = null, List<Polygon> holesB = null) Polygon a,
Polygon b,
List<Polygon> holesA = null,
List<Polygon> holesB = null
)
{ {
// Step 1: Bounding box pre-filter // Step 1: Bounding box pre-filter
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox)) if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
@@ -15,50 +36,61 @@ namespace OpenNest.Geometry
// Step 2: Quick intersection test for crossing points // Step 2: Quick intersection test for crossing points
var intersectionPoints = FindCrossingPoints(a, b); var intersectionPoints = FindCrossingPoints(a, b);
// Step 3: Convex decomposition // Steps 3-5: Convex decomposition, triangle-pair clipping, hole subtraction
var trisA = TriangulateWithBounds(a); var regions = OverlapRegions(a, b, holesA, holesB);
var trisB = TriangulateWithBounds(b);
// Step 4: Clip all triangle pairs
var regions = new List<Polygon>();
foreach (var triA in trisA)
{
foreach (var triB in trisB)
{
if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox))
continue;
var clipped = ClipConvex(triA, triB);
if (clipped != null)
regions.Add(clipped);
}
}
// Step 5: Hole subtraction
if (regions.Count > 0)
regions = SubtractHoles(regions, holesA, holesB);
if (regions.Count == 0)
return new CollisionResult(false, regions, intersectionPoints);
// Step 6: Build result // Step 6: Build result
return new CollisionResult(true, regions, intersectionPoints); return new CollisionResult(regions.Count > 0, regions, intersectionPoints);
} }
public static bool HasOverlap(Polygon a, Polygon b, public static bool HasOverlap(
List<Polygon> holesA = null, List<Polygon> holesB = null) Polygon a,
Polygon b,
List<Polygon> holesA = null,
List<Polygon> holesB = null
)
{ {
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox)) if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
return false; return false;
// Full check is needed: crossing points alone miss containment cases // Clipping decides the verdict, including containment (one polygon entirely
// (one polygon entirely inside another has zero edge crossings). // inside another has zero edge crossings). Crossing points never affect it,
return Check(a, b, holesA, holesB).Overlaps; // so this overlap-only path skips them.
return OverlapRegions(a, b, holesA, holesB).Count > 0;
} }
public static List<CollisionResult> CheckAll(List<Polygon> polygons, /// <summary>
List<List<Polygon>> holes = null) /// <see cref="HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/> with the
/// per-polygon triangulation supplied by the caller. Triangulations are resolved lazily,
/// only after the bounding boxes overlap, and must come from <see cref="Triangulate"/> on
/// the same polygon; the verdict is then identical. The triangles are only read.
/// </summary>
internal static bool HasOverlap(
Polygon a,
Func<List<Polygon>> trianglesA,
Polygon b,
Func<List<Polygon>> trianglesB,
List<Polygon> holesA = null,
List<Polygon> holesB = null
)
{
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
return false;
return OverlapRegions(trianglesA(), trianglesB(), holesA, holesB).Count > 0;
}
/// <summary>
/// The triangulation <see cref="Check"/> and <see cref="HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
/// use for <paramref name="polygon"/>: ear-clipped triangles with bounds updated. Callers
/// that reuse it must not mutate the polygon or the triangles.
/// </summary>
internal static List<Polygon> Triangulate(Polygon polygon) => TriangulateWithBounds(polygon);
public static List<CollisionResult> CheckAll(
List<Polygon> polygons,
List<List<Polygon>> holes = null
)
{ {
var results = new List<CollisionResult>(); var results = new List<CollisionResult>();
@@ -78,8 +110,7 @@ namespace OpenNest.Geometry
return results; return results;
} }
public static bool HasAnyOverlap(List<Polygon> polygons, public static bool HasAnyOverlap(List<Polygon> polygons, List<List<Polygon>> holes = null)
List<List<Polygon>> holes = null)
{ {
for (var i = 0; i < polygons.Count; i++) for (var i = 0; i < polygons.Count; i++)
{ {
@@ -96,18 +127,57 @@ namespace OpenNest.Geometry
return false; return false;
} }
/// <summary>
/// Positive-area overlap regions left after hole subtraction: the verdict shared by
/// <see cref="Check"/> and <see cref="HasOverlap"/>. Callers apply the polygon-level
/// bounding-box pre-filter first.
/// </summary>
private static List<Polygon> OverlapRegions(
Polygon a,
Polygon b,
List<Polygon> holesA,
List<Polygon> holesB
) => OverlapRegions(TriangulateWithBounds(a), TriangulateWithBounds(b), holesA, holesB);
private static List<Polygon> OverlapRegions(
List<Polygon> trisA,
List<Polygon> trisB,
List<Polygon> holesA,
List<Polygon> holesB
)
{
var regions = new List<Polygon>();
foreach (var triA in trisA)
{
foreach (var triB in trisB)
{
if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox))
continue;
var clipped = ClipConvex(triA, triB);
if (clipped != null)
regions.Add(clipped);
}
}
if (regions.Count > 0)
regions = SubtractHoles(regions, holesA, holesB);
return regions;
}
private static bool BoundingBoxesOverlap(Box a, Box b) private static bool BoundingBoxesOverlap(Box a, Box b)
{ {
var overlapX = System.Math.Min(a.Right, b.Right) var overlapX = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left);
- System.Math.Max(a.Left, b.Left); var overlapY = System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom);
var overlapY = System.Math.Min(a.Top, b.Top)
- System.Math.Max(a.Bottom, b.Bottom);
return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon; return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon;
} }
private static List<Vector> FindCrossingPoints(Polygon a, Polygon b) private static List<Vector> FindCrossingPoints(Polygon a, Polygon b)
{ {
PerfCounters.CountCrossingPointScan();
if (!Intersect.Intersects(a, b, out var rawPts)) if (!Intersect.Intersects(a, b, out var rawPts))
return new List<Vector>(); return new List<Vector>();
@@ -149,6 +219,7 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
private static List<Polygon> TriangulateWithBounds(Polygon polygon) private static List<Polygon> TriangulateWithBounds(Polygon polygon)
{ {
PerfCounters.CountPolygonTriangulation();
var tris = ConvexDecomposition.Triangulate(polygon); var tris = ConvexDecomposition.Triangulate(polygon);
foreach (var tri in tris) foreach (var tri in tris)
tri.UpdateBounds(); tri.UpdateBounds();
@@ -161,67 +232,14 @@ namespace OpenNest.Geometry
/// </summary> /// </summary>
private static Polygon ClipConvex(Polygon subject, Polygon clip) private static Polygon ClipConvex(Polygon subject, Polygon clip)
{ {
var output = new List<Vector>(subject.Vertices); var output = OpenVertices(subject);
var clipVerts = OpenVertices(clip);
// Remove closing vertex if present for (var i = 0; i < clipVerts.Count && output.Count >= 3; i++)
if (output.Count > 1 && output[0].X == output[output.Count - 1].X
&& output[0].Y == output[output.Count - 1].Y)
output.RemoveAt(output.Count - 1);
var clipVerts = new List<Vector>(clip.Vertices);
if (clipVerts.Count > 1 && clipVerts[0].X == clipVerts[clipVerts.Count - 1].X
&& clipVerts[0].Y == clipVerts[clipVerts.Count - 1].Y)
clipVerts.RemoveAt(clipVerts.Count - 1);
for (var i = 0; i < clipVerts.Count; i++)
{ {
if (output.Count == 0) output = ClipHalfSpace(output, clipVerts[i], clipVerts[(i + 1) % clipVerts.Count], true);
return null;
var edgeStart = clipVerts[i];
var edgeEnd = clipVerts[(i + 1) % clipVerts.Count];
var input = output;
output = new List<Vector>();
for (var j = 0; j < input.Count; j++)
{
var current = input[j];
var next = input[(j + 1) % input.Count];
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
if (currentInside)
{
output.Add(current);
if (!nextInside)
{
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
if (ix.IsValid())
output.Add(ix);
}
}
else if (nextInside)
{
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
if (ix.IsValid())
output.Add(ix);
}
}
} }
if (output.Count < 3) return PositiveAreaPolygon(output);
return null;
var result = new Polygon();
result.Vertices.AddRange(output);
result.Close();
result.UpdateBounds();
// Reject degenerate slivers
if (result.Area() < Tolerance.Epsilon)
return null;
return result;
} }
/// <summary> /// <summary>
@@ -234,33 +252,20 @@ namespace OpenNest.Geometry
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X); - (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
} }
/// <summary>
/// Intersection of lines (a1->a2) and (b1->b2). Returns Vector.Invalid if parallel.
/// </summary>
private static Vector LineIntersection(Vector a1, Vector a2, Vector b1, Vector b2)
{
var d1x = a2.X - a1.X;
var d1y = a2.Y - a1.Y;
var d2x = b2.X - b1.X;
var d2y = b2.Y - b1.Y;
var cross = d1x * d2y - d1y * d2x;
if (System.Math.Abs(cross) < Tolerance.Epsilon)
return Vector.Invalid;
var t = ((b1.X - a1.X) * d2y - (b1.Y - a1.Y) * d2x) / cross;
return new Vector(a1.X + t * d1x, a1.Y + t * d1y);
}
/// <summary> /// <summary>
/// Subtracts holes from overlap regions. /// Subtracts holes from overlap regions.
/// </summary> /// </summary>
private static List<Polygon> SubtractHoles(List<Polygon> regions, private static List<Polygon> SubtractHoles(
List<Polygon> holesA, List<Polygon> holesB) List<Polygon> regions,
List<Polygon> holesA,
List<Polygon> holesB
)
{ {
var allHoles = new List<Polygon>(); var allHoles = new List<Polygon>();
if (holesA != null) allHoles.AddRange(holesA); if (holesA != null)
if (holesB != null) allHoles.AddRange(holesB); allHoles.AddRange(holesA);
if (holesB != null)
allHoles.AddRange(holesB);
if (allHoles.Count == 0) if (allHoles.Count == 0)
return regions; return regions;
@@ -286,8 +291,9 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Subtracts hole triangles from a region. Conservative: partial overlaps /// Subtracts hole triangles from a convex region. At each edge, emit the outside
/// keep the full piece triangle (acceptable for visual shading). /// portion and carry only the inside remainder to the next edge. The emitted
/// pieces are disjoint and convex, so no repeated triangulation is needed.
/// </summary> /// </summary>
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris) private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
{ {
@@ -295,36 +301,117 @@ namespace OpenNest.Geometry
foreach (var holeTri in holeTris) foreach (var holeTri in holeTris)
{ {
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
continue;
var next = new List<Polygon>(); var next = new List<Polygon>();
foreach (var piece in current) foreach (var piece in current)
{ {
var pieceTris = TriangulateWithBounds(piece); // Subtraction must also remove thin fragments created by clipping.
// The pair-level length tolerance would skip some of these even
// when their area is large enough to count as an overlap.
var a = piece.BoundingBox;
var b = holeTri.BoundingBox;
if (a.Right <= b.Left || b.Right <= a.Left || a.Top <= b.Bottom || b.Top <= a.Bottom)
{
next.Add(piece);
continue;
}
foreach (var pieceTri in pieceTris) var remainder = OpenVertices(piece);
var holeVerts = OpenVertices(holeTri);
for (var i = 0; i < holeVerts.Count && remainder.Count >= 3; i++)
{ {
var inside = ClipConvex(pieceTri, holeTri); var start = holeVerts[i];
if (inside == null) var end = holeVerts[(i + 1) % holeVerts.Count];
{ var outside = PositiveAreaPolygon(ClipHalfSpace(remainder, start, end, false));
// No overlap with hole - keep if (outside != null)
next.Add(pieceTri); next.Add(outside);
} remainder = ClipHalfSpace(remainder, start, end, true);
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
{
// Partial overlap - keep the piece (conservative)
next.Add(pieceTri);
}
// else: fully inside hole - discard
} }
} }
current = next; current = next;
if (current.Count == 0)
break;
} }
return current; return current;
} }
/// <summary>
/// Clips an open vertex list against one half-space. Classification and
/// interpolation use the same signed cross products: intersections always
/// lie on the input segment. An epsilon-shifted inside test combined with
/// intersections on the unshifted line can extrapolate and create material.
/// Apply the area tolerance only to the resulting polygons, not to edge signs.
/// </summary>
private static List<Vector> ClipHalfSpace(
List<Vector> vertices,
Vector edgeStart,
Vector edgeEnd,
bool inside
)
{
var kept = new List<Vector>();
for (var i = 0; i < vertices.Count; i++)
{
var current = vertices[i];
var next = vertices[(i + 1) % vertices.Count];
var currentDistance = Cross(edgeStart, edgeEnd, current);
var nextDistance = Cross(edgeStart, edgeEnd, next);
if (inside ? currentDistance >= 0 : currentDistance <= 0)
AddDistinct(kept, current);
// Only strict opposite signs cross the line. Boundary endpoints
// are already kept, and near-parallel crossings need no cutoff.
if ((currentDistance < 0 && nextDistance > 0) || (currentDistance > 0 && nextDistance < 0))
{
var t = currentDistance / (currentDistance - nextDistance);
AddDistinct(kept, new Vector(
current.X + t * (next.X - current.X),
current.Y + t * (next.Y - current.Y)));
}
}
if (kept.Count > 1 && SamePoint(kept[0], kept[kept.Count - 1]))
kept.RemoveAt(kept.Count - 1);
return kept;
}
private static bool SamePoint(Vector a, Vector b) => a.X == b.X && a.Y == b.Y;
private static void AddDistinct(List<Vector> vertices, Vector point)
{
if (vertices.Count == 0 || !SamePoint(vertices[vertices.Count - 1], point))
vertices.Add(point);
}
private static List<Vector> OpenVertices(Polygon polygon)
{
var vertices = new List<Vector>(polygon.Vertices);
if (vertices.Count > 1 && SamePoint(vertices[0], vertices[vertices.Count - 1]))
vertices.RemoveAt(vertices.Count - 1);
return vertices;
}
private static Polygon PositiveAreaPolygon(List<Vector> vertices)
{
if (vertices.Count < 3)
return null;
// Measure relative to a vertex to avoid cancellation of world-coordinate
// products when a small clipped fragment is far from the origin.
var twiceArea = 0.0;
for (var i = 1; i + 1 < vertices.Count; i++)
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
if (System.Math.Abs(twiceArea) <= 2 * Tolerance.Epsilon)
return null;
var polygon = new Polygon();
polygon.Vertices.AddRange(vertices);
// Polygon.Close uses fuzzy Vector equality; clipping needs an exact
// closing vertex even when the last edge is shorter than Epsilon.
polygon.Vertices.Add(vertices[0]);
polygon.UpdateBounds();
return polygon;
}
} }
} }
+10 -2
View File
@@ -5,9 +5,17 @@ namespace OpenNest.Geometry
{ {
public class CollisionResult public class CollisionResult
{ {
public static readonly CollisionResult None = new(false, new List<Polygon>(), new List<Vector>()); public static readonly CollisionResult None = new(
false,
new List<Polygon>(),
new List<Vector>()
);
public CollisionResult(bool overlaps, List<Polygon> overlapRegions, List<Vector> intersectionPoints) public CollisionResult(
bool overlaps,
List<Polygon> overlapRegions,
List<Vector> intersectionPoints
)
{ {
Overlaps = overlaps; Overlaps = overlaps;
OverlapRegions = overlapRegions; OverlapRegions = overlapRegions;
+18 -11
View File
@@ -19,8 +19,11 @@ namespace OpenNest.Geometry
var verts = new List<Vector>(polygon.Vertices); var verts = new List<Vector>(polygon.Vertices);
// Remove closing vertex if polygon is closed. // Remove closing vertex if polygon is closed.
if (verts.Count > 1 && verts[0].X == verts[verts.Count - 1].X if (
&& verts[0].Y == verts[verts.Count - 1].Y) verts.Count > 1
&& verts[0].X == verts[verts.Count - 1].X
&& verts[0].Y == verts[verts.Count - 1].Y
)
verts.RemoveAt(verts.Count - 1); verts.RemoveAt(verts.Count - 1);
if (verts.Count < 3) if (verts.Count < 3)
@@ -84,8 +87,14 @@ namespace OpenNest.Geometry
/// Tests whether the vertex at curr forms an ear (a convex vertex whose /// Tests whether the vertex at curr forms an ear (a convex vertex whose
/// triangle contains no other polygon vertices). /// triangle contains no other polygon vertices).
/// </summary> /// </summary>
private static bool IsEar(Vector prev, Vector curr, Vector next, private static bool IsEar(
List<Vector> verts, List<int> indices, int n) Vector prev,
Vector curr,
Vector next,
List<Vector> verts,
List<int> indices,
int n
)
{ {
// Must be convex (CCW turn). // Must be convex (CCW turn).
if (Cross(prev, curr, next) <= 0) if (Cross(prev, curr, next) <= 0)
@@ -139,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;
} }
+8 -2
View File
@@ -20,7 +20,10 @@ namespace OpenNest.Geometry
foreach (var p in sorted) foreach (var p in sorted)
{ {
while (lower.Count >= 2 && Cross(lower[lower.Count - 2], lower[lower.Count - 1], p) <= 0) while (
lower.Count >= 2
&& Cross(lower[lower.Count - 2], lower[lower.Count - 1], p) <= 0
)
lower.RemoveAt(lower.Count - 1); lower.RemoveAt(lower.Count - 1);
lower.Add(p); lower.Add(p);
@@ -32,7 +35,10 @@ namespace OpenNest.Geometry
{ {
var p = sorted[i]; var p = sorted[i];
while (upper.Count >= 2 && Cross(upper[upper.Count - 2], upper[upper.Count - 1], p) <= 0) while (
upper.Count >= 2
&& Cross(upper[upper.Count - 2], upper[upper.Count - 1], p) <= 0
)
upper.RemoveAt(upper.Count - 1); upper.RemoveAt(upper.Count - 1);
upper.Add(p); upper.Add(p);
+401
View File
@@ -0,0 +1,401 @@
#nullable enable
using System;
namespace OpenNest.Geometry
{
/// <summary>
/// Immutable flat-array polygon with a uniform edge grid, used as an outer-shell
/// clearance prefilter. Two closed polygons share positive area only when an edge pair
/// crosses/touches or one polygon's vertex lies strictly inside the other; neither
/// happening certifies the two closed regions (hence any materials inside them) are
/// clear. <see cref="Relate"/> returns Clear only in that certified case and Unknown
/// for uncertain contacts, so it can only ever skip the exact <see cref="Collision"/>
/// gate when the exact gate would also find no overlap - the exact gate triangulates
/// both polygons per call and dominates runtime on finely flattened arc geometry.
/// <para>
/// A <see cref="EdgeGridPolygonTemplate"/> holds the shared geometry; <see cref="Translated"/>
/// produces a placement in world coordinates in O(1) - translation leaves the grid and
/// all cell indices unchanged, only the predicate coordinates shift.
/// </para>
/// </summary>
public sealed class EdgeGridPolygon
{
/// <summary>Vertex-on-segment / collinearity tolerance for conservative touches.</summary>
private const double TouchEps = 1e-9;
private readonly EdgeGridPolygonTemplate _template;
/// <summary>Translation applied to the shared template geometry.</summary>
private readonly double Dx;
private readonly double Dy;
private EdgeGridPolygon(EdgeGridPolygonTemplate template, double dx, double dy)
{
_template = template;
Dx = dx;
Dy = dy;
}
private double MinX => _template.MinX + Dx;
private double MinY => _template.MinY + Dy;
private double MaxX => _template.MaxX + Dx;
private double MaxY => _template.MaxY + Dy;
/// <summary>
/// Builds from a closed <see cref="Polygon"/> (last vertex may repeat the first).
/// Returns null when the polygon has no usable ring - callers treat that as
/// "no information" and fall through to the exact gate.
/// </summary>
public static EdgeGridPolygon? From(Polygon polygon)
{
var template = EdgeGridPolygonTemplate.Build(polygon);
return template == null ? null : new EdgeGridPolygon(template, 0, 0);
}
/// <summary>Returns a placement sharing immutable geometry, with an added translation.</summary>
public EdgeGridPolygon Translated(double dx, double dy) => new(_template, Dx + dx, Dy + dy);
private double X(int i) => _template.X[i] + Dx;
private double Y(int i) => _template.Y[i] + Dy;
/// <summary>
/// Certifies disjoint filled perimeters. Any crossing, containment or uncertain
/// boundary contact returns Unknown and must defer to the exact collision test.
/// Holes need not be supplied: removing material cannot invalidate Clear.
/// </summary>
public static ShellRelation Relate(EdgeGridPolygon a, EdgeGridPolygon b)
{
if (
a.MaxX <= b.MinX
|| b.MaxX <= a.MinX
|| a.MaxY <= b.MinY
|| b.MaxY <= a.MinY
)
return ShellRelation.Clear; // disjoint bounding boxes
// One walk per direction reports the strongest edge relation: a transversal
// crossing shares a positive-area wedge (overlap); a mere touch shares zero
// area but may hide a crossing in near-degenerate coordinates (unknown).
var edge = EdgeRelation(a, b);
if (edge < 2)
{
var back = EdgeRelation(b, a);
if (back > edge)
edge = back;
}
if (edge == 2)
return ShellRelation.Unknown;
// Only fully disjoint boundaries can certify clearance. Point touches and
// collinear/near-degenerate contacts always defer to the reference test.
switch (edge)
{
case 0:
if (ContainsPointStrictly(a, b.X(0), b.Y(0)))
return ShellRelation.Unknown;
if (ContainsPointStrictly(b, a.X(0), a.Y(0)))
return ShellRelation.Unknown;
return ShellRelation.Clear;
default:
return ShellRelation.Unknown;
}
}
/// <summary>
/// Classifies whether any edge of <paramref name="q"/> crosses or touches the boundary of
/// <paramref name="p"/>. Walks p's grid using each query edge's own bbox cells.
/// p's grid lives in p's LOCAL frame (the template's own coordinates), so the
/// query edge is converted by subtracting p's translation first.
/// </summary>
private static int EdgeRelation(EdgeGridPolygon p, EdgeGridPolygon q)
{
var t = p._template;
var n = t.Count;
Span<int> seen = n <= 1024 ? stackalloc int[n] : new int[n];
seen.Clear();
var head = t.Head;
var nodeEdge = t.NodeEdge;
var nodeNext = t.NodeNext;
var no = q._template.Count;
var strongest = 0;
for (var e = 0; e < no; e++)
{
// Stamp per QUERY edge: a grid edge may need testing against every query
// edge; the dedupe only collapses cells an individual query edge crosses
// more than once.
var stamp = e + 1;
var i2 = (e + 1) % no;
var p0x = q.X(e) - p.Dx;
var p0y = q.Y(e) - p.Dy;
var p1x = q.X(i2) - p.Dx;
var p1y = q.Y(i2) - p.Dy;
var c0 = ColLow(t, p0x, p1x);
if (c0 > ColHigh(t, p0x, p1x))
continue;
var c1 = ColHigh(t, p0x, p1x);
var r0 = RowLow(t, p0y, p1y);
if (r0 > RowHigh(t, p0y, p1y))
continue;
var r1 = RowHigh(t, p0y, p1y);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
for (var nIdx = head[r * t.Cols + c]; nIdx >= 0; nIdx = nodeNext[nIdx])
{
var ea = nodeEdge[nIdx];
if (seen[ea] == stamp)
continue;
seen[ea] = stamp;
var a2 = (ea + 1) % n;
var relation = SegmentRelation(
t.X[ea], t.Y[ea], t.X[a2], t.Y[a2], p0x, p0y, p1x, p1y
);
if (relation == 2)
return 2; // transversal crossing
if (relation > strongest)
strongest = relation;
}
}
return strongest;
}
/// <summary>
/// Segment-pair relation: 2 = transversal crossing (strict sign flips on both
/// orientations - the regions share a positive-area wedge); 1 = a clean endpoint
/// touch (zero shared area by itself; callers decide via interior-vertex tests);
/// 3 = collinear or near-degenerate contact (a shared boundary segment can hide
/// either a same-side positive overlap or an opposite-side tangency, so it must
/// defer to the exact gate); 0 = disjoint.
/// </summary>
private static int SegmentRelation(
double ax, double ay, double bx, double by, double cx, double cy, double dx, double dy
)
{
var rx = bx - ax;
var ry = by - ay;
var sx = dx - cx;
var sy = dy - cy;
var d1 = rx * (cy - ay) - ry * (cx - ax);
var d2 = rx * (dy - ay) - ry * (dx - ax);
var d3 = sx * (ay - cy) - sy * (ax - cx);
var d4 = sx * (by - cy) - sy * (bx - cx);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
return 2; // proper crossing
// A near-zero orientation means the configuration is collinear or too close to
// classify; only exact-zero orientations get the clean point-touch verdict.
var scale = System.Math.Max(
1e-30,
System.Math.Max(System.Math.Abs(rx) + System.Math.Abs(ry), System.Math.Abs(sx) + System.Math.Abs(sy))
);
var eps = TouchEps * scale;
var nearDegenerate =
(System.Math.Abs(d1) <= eps && d1 != 0)
|| (System.Math.Abs(d2) <= eps && d2 != 0)
|| (System.Math.Abs(d3) <= eps && d3 != 0)
|| (System.Math.Abs(d4) <= eps && d4 != 0);
var exactDegenerate = d1 == 0 || d2 == 0 || d3 == 0 || d4 == 0;
var touch =
(d1 == 0 && PointOnSegment(cx, cy, ax, ay, bx, by))
|| (d2 == 0 && PointOnSegment(dx, dy, ax, ay, bx, by))
|| (d3 == 0 && PointOnSegment(ax, ay, cx, cy, dx, dy))
|| (d4 == 0 && PointOnSegment(bx, by, cx, cy, dx, dy));
if (nearDegenerate)
return 3;
if (exactDegenerate)
// Collinear: contact along a segment (or too close to tell) must defer to
// the exact gate; collinear but disjoint edges simply do not touch.
return touch ? 3 : 0;
if (touch)
return 1;
return 0;
}
private static bool PointOnSegment(
double px, double py, double ax, double ay, double bx, double by
) =>
System.Math.Min(ax, bx) - TouchEps <= px
&& px <= System.Math.Max(ax, bx) + TouchEps
&& System.Math.Min(ay, by) - TouchEps <= py
&& py <= System.Math.Max(ay, by) + TouchEps;
/// <summary>Strict ray-cast containment (boundary touches are excluded upstream).</summary>
private static bool ContainsPointStrictly(EdgeGridPolygon poly, double px, double py)
{
var t = poly._template;
var inside = false;
var n = t.Count;
for (var i = 0; i < n; i++)
{
var j = (i + 1) % n;
var yi = poly.Y(i);
var yj = poly.Y(j);
if ((yi > py) != (yj > py))
{
var xAt = poly.X(i) + (py - yi) / (yj - yi) * (poly.X(j) - poly.X(i));
if (px < xAt)
inside = !inside;
}
}
return inside;
}
private static int ColLow(EdgeGridPolygonTemplate t, double a, double b) =>
System.Math.Clamp((int)System.Math.Floor((System.Math.Min(a, b) - t.MinX) / t.CellSize), 0, t.Cols);
private static int ColHigh(EdgeGridPolygonTemplate t, double a, double b) =>
System.Math.Clamp((int)System.Math.Floor((System.Math.Max(a, b) - t.MinX) / t.CellSize), -1, t.Cols - 1);
private static int RowLow(EdgeGridPolygonTemplate t, double a, double b) =>
System.Math.Clamp((int)System.Math.Floor((System.Math.Min(a, b) - t.MinY) / t.CellSize), 0, t.Rows);
private static int RowHigh(EdgeGridPolygonTemplate t, double a, double b) =>
System.Math.Clamp((int)System.Math.Floor((System.Math.Max(a, b) - t.MinY) / t.CellSize), -1, t.Rows - 1);
/// <summary>
/// Shared, immutable grid geometry for <see cref="EdgeGridPolygon"/>; the grid is defined
/// relative to the shape's own local coordinates, so translated instances reuse it.
/// Per-query deduplication scratch is local, so placements may be queried concurrently.
/// </summary>
private sealed class EdgeGridPolygonTemplate
{
internal readonly double[] X;
internal readonly double[] Y;
internal readonly int Count;
internal readonly double MinX;
internal readonly double MinY;
internal readonly double MaxX;
internal readonly double MaxY;
internal readonly double CellSize;
internal readonly int Cols;
internal readonly int Rows;
internal readonly int[] Head;
/// <summary>
/// Grid nodes as parallel (edge, next) arrays: an edge spanning several cells gets
/// one node PER cell - a single next-per-edge chain would corrupt the other cells'
/// chains and silently drop edges from the walk.
/// </summary>
internal readonly int[] NodeEdge;
internal readonly int[] NodeNext;
private EdgeGridPolygonTemplate(
double[] x,
double[] y,
int count,
double minX,
double minY,
double maxX,
double maxY
)
{
X = x;
Y = y;
Count = count;
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
var extentX = System.Math.Max(maxX - minX, 1e-9);
var extentY = System.Math.Max(maxY - minY, 1e-9);
CellSize = System.Math.Max(System.Math.Max(extentX, extentY) / 16.0, 1e-9);
Cols = System.Math.Clamp((int)System.Math.Ceiling(extentX / CellSize) + 1, 1, 48);
Rows = System.Math.Clamp((int)System.Math.Ceiling(extentY / CellSize) + 1, 1, 48);
Head = new int[Cols * Rows];
Array.Fill(Head, -1);
// Pass 1: count nodes; pass 2: fill (edge, next) node arrays.
var cellsPerEdge = new int[count];
var total = 0;
for (var e = 0; e < count; e++)
{
var i2 = (e + 1) % count;
var c0 = ClampCol(System.Math.Min(x[e], x[i2]) - minX);
var c1 = ClampCol(System.Math.Max(x[e], x[i2]) - minX);
var r0 = ClampRow(System.Math.Min(y[e], y[i2]) - minY);
var r1 = ClampRow(System.Math.Max(y[e], y[i2]) - minY);
cellsPerEdge[e] = (c1 - c0 + 1) * (r1 - r0 + 1);
total += cellsPerEdge[e];
}
NodeEdge = new int[total];
NodeNext = new int[total];
var node = 0;
for (var e = 0; e < count; e++)
{
var i2 = (e + 1) % count;
var c0 = ClampCol(System.Math.Min(x[e], x[i2]) - minX);
var c1 = ClampCol(System.Math.Max(x[e], x[i2]) - minX);
var r0 = ClampRow(System.Math.Min(y[e], y[i2]) - minY);
var r1 = ClampRow(System.Math.Max(y[e], y[i2]) - minY);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
{
var cell = r * Cols + c;
NodeEdge[node] = e;
NodeNext[node] = Head[cell];
Head[cell] = node;
node++;
}
}
}
private int ClampCol(double dx) =>
System.Math.Clamp((int)System.Math.Floor(dx / CellSize), 0, Cols - 1);
private int ClampRow(double dy) =>
System.Math.Clamp((int)System.Math.Floor(dy / CellSize), 0, Rows - 1);
internal static EdgeGridPolygonTemplate? Build(Polygon polygon)
{
var vertices = polygon.Vertices;
var n = vertices.Count;
if (n >= 2 && vertices[0].X == vertices[n - 1].X && vertices[0].Y == vertices[n - 1].Y)
n--;
if (n < 3)
return null;
var xs = new double[n];
var ys = new double[n];
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var i = 0; i < n; i++)
{
var vx = vertices[i].X;
var vy = vertices[i].Y;
xs[i] = vx;
ys[i] = vy;
if (vx < minX)
minX = vx;
if (vx > maxX)
maxX = vx;
if (vy < minY)
minY = vy;
if (vy > maxY)
maxY = vy;
}
return new EdgeGridPolygonTemplate(xs, ys, n, minX, minY, maxX, maxY);
}
}
}
/// <summary>Conservative result of an outer-perimeter prefilter.</summary>
public enum ShellRelation
{
/// <summary>Filled perimeters, and therefore their material, are disjoint.</summary>
Clear,
/// <summary>Run an exact collision test; the prefilter cannot certify clearance.</summary>
Unknown,
}
}

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