mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 03:51:05 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf240ab6df | ||
|
|
3d4ac5ddf7 | ||
|
|
1ab39c3cd0 | ||
|
|
56eaf4829a | ||
|
|
a10e204268 | ||
|
|
67daeafc98 | ||
|
|
391a27b2f9 | ||
|
|
cf32d1cab5 | ||
|
|
04a6e0eb50 | ||
|
|
a0713289fa | ||
|
|
94898225e8 | ||
|
|
40e4e559dd | ||
|
|
b9c5240a43 | ||
|
|
13d75854c3 | ||
|
|
61ad85768d | ||
|
|
274651b810 | ||
|
|
1ed66bbf20 | ||
|
|
b9092a5142 | ||
|
|
98a046a112 | ||
|
|
b97f67af41 | ||
|
|
5620c9b842 | ||
|
|
be8d1443e3 | ||
|
|
3d1270a860 | ||
|
|
add94dff6c | ||
|
|
e82f26b976 | ||
|
|
c7df3f5e23 | ||
|
|
3bc71a7e87 | ||
|
|
d35178c3b5 | ||
|
|
a1458a355b | ||
|
|
baa5f8481a | ||
|
|
9bf7760c1d | ||
|
|
50cb34305c | ||
|
|
2b3f8e7b52 | ||
|
|
5dc13a03ae | ||
|
|
93cf8007bc | ||
|
|
6676f9f6f1 | ||
|
|
4676c97c2c | ||
|
|
5dff83cfb8 | ||
|
|
3a7073d38c | ||
|
|
00acaeaaf1 | ||
|
|
cd2bea054b | ||
|
|
a2fb945669 | ||
|
|
b11225135e | ||
|
|
d384e63068 | ||
|
|
c997b2e588 | ||
|
|
0baa737162 | ||
|
|
e6c87df372 | ||
|
|
13ab8e02f8 | ||
|
|
ee8be3d79e | ||
|
|
fb505ef0ff | ||
|
|
d317a90f1e | ||
|
|
f26c75ada5 | ||
|
|
f009fab31d | ||
|
|
d548ba4e6f | ||
|
|
87b6d7ee90 | ||
|
|
9d68945e75 | ||
|
|
c875edcdd1 | ||
|
|
66afea5a51 | ||
|
|
d1aa056b9b | ||
|
|
bbe0f17a64 | ||
|
|
78ca08efe1 | ||
|
|
d3cb23f117 | ||
|
|
095b4d6e72 | ||
|
|
530456a91b | ||
|
|
dd2c00aed4 | ||
|
|
f01b2cec67 | ||
|
|
0e4fd5f90f | ||
|
|
278252fe79 | ||
|
|
b115b9dcb0 | ||
|
|
9f2e3d5291 | ||
|
|
05aac952ba | ||
|
|
859d10b31d | ||
|
|
de845a5818 | ||
|
|
a430da89d0 | ||
|
|
f3a6af7f09 | ||
|
|
2174416dcc | ||
|
|
6e2e27d6fc | ||
|
|
d87302a189 | ||
|
|
27a03b9caf | ||
|
|
0186ccfbff | ||
|
|
ba39c3dc55 | ||
|
|
6cdda38d35 | ||
|
|
3672697cb2 | ||
|
|
af65cb178b | ||
|
|
4176e9d89a | ||
|
|
766584633e | ||
|
|
8f73704bef | ||
|
|
5a374aa3f4 | ||
|
|
42d99f296c | ||
|
|
7a1a554804 | ||
|
|
3541249441 | ||
|
|
93d118585f | ||
|
|
9b46db4891 | ||
|
|
3cae257fb1 | ||
|
|
e6b1e0308f | ||
|
|
d87d113ceb | ||
|
|
686bb4d749 | ||
|
|
9d6c10c563 | ||
|
|
6f555345e3 | ||
|
|
6fd14c5452 | ||
|
|
ca09c33c38 | ||
|
|
adc12e175d | ||
|
|
ac161f93c4 | ||
|
|
bc363cc904 | ||
|
|
4a1587d6bd | ||
|
|
6ac56c4a3f | ||
|
|
0cef0ae297 | ||
|
|
b324baa69a | ||
|
|
170b517a28 | ||
|
|
9360f59077 | ||
|
|
2f91f1b036 | ||
|
|
6c691cc23b | ||
|
|
9816326429 | ||
|
|
a18f8c9b56 | ||
|
|
8d17cf8c59 | ||
|
|
7949d41b6d | ||
|
|
d56f67a99a | ||
|
|
36ba3c2d6b | ||
|
|
d9b8f054cc | ||
|
|
734346eeec | ||
|
|
0f06748f28 | ||
|
|
1bd0b258af | ||
|
|
2aecafb356 | ||
|
|
f93b0d6273 | ||
|
|
a1bd44d0fa | ||
|
|
175782d8f8 | ||
|
|
4dbb4527e2 | ||
|
|
15654c0690 | ||
|
|
8b63c042b5 | ||
|
|
0c1eb3ad65 | ||
|
|
3c577d4ac7 | ||
|
|
76133193df | ||
|
|
0ee5b9d93e | ||
|
|
7a5c06bc99 | ||
|
|
844f360732 | ||
|
|
2d5bf6f42c | ||
|
|
6e66a1efe7 | ||
|
|
063362a674 | ||
|
|
4e0699de7a | ||
|
|
9f627c1a9d | ||
|
|
7600d75f41 | ||
|
|
90a6d9e4d2 | ||
|
|
19adfbcfbd | ||
|
|
2ea201140b | ||
|
|
6bf0658d68 | ||
|
|
4e95a09706 | ||
|
|
37ddf1b7b2 | ||
|
|
75195e8ba4 | ||
|
|
274a1a1dea | ||
|
|
91b693be90 | ||
|
|
648c0f1efc | ||
|
|
017e06de2d | ||
|
|
21e1aee342 | ||
|
|
571776716f | ||
|
|
405a867a77 | ||
|
|
c3fa8e79e0 | ||
|
|
658c1c9ee3 | ||
|
|
697b6492c7 | ||
|
|
f05533f69c | ||
|
|
7d254ce18d | ||
|
|
b305b56876 | ||
|
|
f4dd8d2a85 | ||
|
|
9f0d4f37ce | ||
|
|
d028d82f0a | ||
|
|
ee3bc3aabe | ||
|
|
12f2ce5deb | ||
|
|
1672b62717 | ||
|
|
83b606c486 | ||
|
|
80d01839bd | ||
|
|
dc0aab06fc | ||
|
|
f04d9f298b | ||
|
|
8663463945 | ||
|
|
1c05f65571 | ||
|
|
fb92b75c06 | ||
|
|
a62f9a359e | ||
|
|
f99ba97b28 | ||
|
|
04c9f748f9 | ||
|
|
1db0ef04f0 | ||
|
|
855d686a6a | ||
|
|
b7779904f6 | ||
|
|
f9e3c361b4 | ||
|
|
8834abc854 | ||
|
|
720c3d9b7a | ||
|
|
e8c0eea0b0 | ||
|
|
bf8ca19ce5 | ||
|
|
21d38beb53 | ||
|
|
18be8a2c81 | ||
|
|
efc2b73709 | ||
|
|
1d745d20ba | ||
|
|
29f48789dd | ||
|
|
5180ce1506 | ||
|
|
74f32f0516 | ||
|
|
6424512e8c | ||
|
|
27f8c9799b | ||
|
|
775ece5d21 | ||
|
|
cad1083287 | ||
|
|
4183af4142 | ||
|
|
73be301ebf | ||
|
|
fdc6f074ee | ||
|
|
dc4b07ddf5 | ||
|
|
6c76617905 | ||
|
|
43e08965b9 | ||
|
|
ddd1e422be | ||
|
|
b9a387b517 | ||
|
|
9f2ece37ed | ||
|
|
81cccfc610 | ||
|
|
c107ab13f7 | ||
|
|
57450ecea8 | ||
|
|
b245290664 | ||
|
|
6d5d963654 | ||
|
|
178954e2df | ||
|
|
f63fee7392 | ||
|
|
5857e7fc69 | ||
|
|
15055aef4d | ||
|
|
c57e0ded83 | ||
|
|
f2cc471a33 | ||
|
|
07cba5719f | ||
|
|
9df9a802cd | ||
|
|
1bcead4b3a | ||
|
|
d9273c889f | ||
|
|
4226c0f902 | ||
|
|
6908e00969 | ||
|
|
e38980e54b | ||
|
|
a1914e147a | ||
|
|
64ebda2f6f | ||
|
|
360f58c101 | ||
|
|
6fc1a0ebdc | ||
|
|
6b995ac40a | ||
|
|
7ed40d4bb8 | ||
|
|
04d538b0a4 | ||
|
|
65754957cc | ||
|
|
53db543d6b | ||
|
|
2b208c9b6a | ||
|
|
b46310e01b | ||
|
|
f19a825c4e | ||
|
|
af50da7609 | ||
|
|
3bf63b74f9 | ||
|
|
d24e660286 | ||
|
|
9b5885e5a2 | ||
|
|
8f0c0692fd | ||
|
|
3f0a2703fd | ||
|
|
83ae1a48f0 | ||
|
|
92d8964339 | ||
|
|
df2f1b796d | ||
|
|
3e9e336072 | ||
|
|
c18db3f896 | ||
|
|
9a293bf221 | ||
|
|
bde332fb10 | ||
|
|
d7187f65c7 | ||
|
|
3014ab3480 | ||
|
|
104b0aef99 | ||
|
|
74544c689a | ||
|
|
3e58a9e93f | ||
|
|
87849e5326 | ||
|
|
83c77269a1 | ||
|
|
ec058d270c | ||
|
|
bafb311735 | ||
|
|
37700b3ef9 | ||
|
|
5125a5f2f3 | ||
|
|
fe7083f4c4 | ||
|
|
1e37c661ff | ||
|
|
76b49677c4 | ||
|
|
aea51b5e1d | ||
|
|
5c9c7df038 | ||
|
|
7069f155f5 | ||
|
|
99f64840b2 | ||
|
|
5674764447 | ||
|
|
81e3e58f13 | ||
|
|
cc85a9e068 | ||
|
|
040776807a | ||
|
|
fbca54637b | ||
|
|
c374e02614 | ||
|
|
de9a087027 | ||
|
|
dc87911b2f | ||
|
|
d001abcf69 | ||
|
|
c16092c293 | ||
|
|
c04562f56d | ||
|
|
b66af8658b | ||
|
|
311178c15a | ||
|
|
b36db41769 | ||
|
|
e5c82e2c92 | ||
|
|
522de2761c | ||
|
|
49017c4a91 | ||
|
|
c1d5a827bc | ||
|
|
937448242f | ||
|
|
f74da248b3 | ||
|
|
c67577d463 | ||
|
|
565dc90762 | ||
|
|
18d63ffd61 | ||
|
|
e3bcc77145 | ||
|
|
df94824054 | ||
|
|
9bd5d0dfa7 | ||
|
|
d6ae706098 | ||
|
|
f9b2e1282a | ||
|
|
943b63937d | ||
|
|
6ed7ab7392 | ||
|
|
4738a0390e | ||
|
|
322dc3eda6 | ||
|
|
ddc4710dfd | ||
|
|
97a468ee2c | ||
|
|
09dd19ca66 | ||
|
|
fae37dd1d0 | ||
|
|
94ed8a0f8f | ||
|
|
028832debc | ||
|
|
51ad700bec | ||
|
|
0216a9e9e2 | ||
|
|
a7c8e8541e | ||
|
|
5f1eeb00eb | ||
|
|
914c197181 | ||
|
|
5598804942 | ||
|
|
d216bcd406 | ||
|
|
11471e3c71 | ||
|
|
ed7600dc90 | ||
|
|
a47b414392 | ||
|
|
3d512c794c | ||
|
|
08acb092a7 | ||
|
|
dc142cc88d | ||
|
|
6799e08356 | ||
|
|
ca6966906f | ||
|
|
c28ccd7568 | ||
|
|
90ae3b6a84 | ||
|
|
72776886cb | ||
|
|
d59c03f5ab | ||
|
|
bb108e9341 | ||
|
|
80c647bf07 | ||
|
|
24be90ace9 | ||
|
|
4feada094c | ||
|
|
4e846007a2 | ||
|
|
8ebb48694f | ||
|
|
afa50c5a12 | ||
|
|
ef6ca80e6d | ||
|
|
0c99455489 | ||
|
|
c34ac2bc0e | ||
|
|
076b794dda | ||
|
|
5dbf887b68 | ||
|
|
17e504fb70 | ||
|
|
69304d3851 | ||
|
|
1c6d02c480 | ||
|
|
d328c5f691 | ||
|
|
e897258a85 | ||
|
|
1b9d8a36d8 | ||
|
|
b750475228 | ||
|
|
138c86526a | ||
|
|
07e682a654 | ||
|
|
0b29c683b9 | ||
|
|
71bd964344 | ||
|
|
1d50c6ce60 | ||
|
|
962ae8c825 | ||
|
|
d3e325f36c | ||
|
|
575412a464 | ||
|
|
60fb009329 | ||
|
|
41737a4f4c | ||
|
|
f1d1df3f17 | ||
|
|
81404fc673 | ||
|
|
a5b6c8a00e | ||
|
|
2a6e5504cd | ||
|
|
3d5c73a353 | ||
|
|
8b94e49711 | ||
|
|
2623517219 |
No files matched your search
@@ -28,7 +28,7 @@ dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
|
||||
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`.
|
||||
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. 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. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
|
||||
|
||||
@@ -37,18 +37,19 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
||||
- `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.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. 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.
|
||||
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. 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.
|
||||
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
||||
- 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.
|
||||
|
||||
@@ -56,9 +57,13 @@ 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)
|
||||
- [Lead-in placement](docs/geometry/lead-in-placement.md) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
||||
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
|
||||
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
||||
- [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)
|
||||
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
|
||||
- [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.
|
||||
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
|
||||
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
|
||||
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
|
||||
public class NestRequestPlate
|
||||
{
|
||||
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
|
||||
public double? Cost { get; init; }
|
||||
|
||||
public string Id { get; init; }
|
||||
public Size Size { get; init; }
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
@@ -13,10 +14,14 @@ namespace OpenNest.Api;
|
||||
/// <summary>Stable fulfillment metadata for one requested part identity.</summary>
|
||||
public sealed record NestPartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
|
||||
|
||||
/// <summary>Copies of one requested part identity the proposal leaves unplaced, and why.</summary>
|
||||
public sealed record NestUnplacedRequestPart(string PartId, int Count, NestUnplacedReason Reason);
|
||||
|
||||
/// <summary>Physical-sheet usage for one stock identity.</summary>
|
||||
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 layout (Nest.Plates index) to its stock identity.
|
||||
/// Its Plate.Quantity gives the physical copies; schema 3 and older used one sheet per entry.</summary>
|
||||
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
||||
|
||||
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
||||
@@ -24,10 +29,11 @@ public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
|
||||
|
||||
public class NestResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 3;
|
||||
public const int CurrentSchemaVersion = 5;
|
||||
|
||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
/// <summary>Physical sheets: sum of Plate.Quantity, not materialized layout entries.</summary>
|
||||
public int SheetCount { get; init; }
|
||||
|
||||
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
||||
@@ -42,8 +48,12 @@ public class NestResponse
|
||||
public NestValidationStatus? ValidationStatus { get; init; }
|
||||
public IReadOnlyList<string> Violations { get; init; } = [];
|
||||
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
/// <summary>Every unplaced copy by requested part and reason; empty when everything was
|
||||
/// placed. Archives before schema 5 did not record it and load empty.</summary>
|
||||
public IReadOnlyList<NestUnplacedRequestPart> Unplaced { get; init; } = [];
|
||||
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
public NestCostSummary Costs { get; init; }
|
||||
public Nest Nest { get; init; }
|
||||
public NestRequest Request { get; init; }
|
||||
|
||||
@@ -86,6 +96,8 @@ public class NestResponse
|
||||
Fulfillment = Fulfillment is null
|
||||
? []
|
||||
: new List<NestPartFulfillment>(Fulfillment),
|
||||
Unplaced = Unplaced is null ? [] : new List<NestUnplacedRequestPart>(Unplaced),
|
||||
Costs = Costs,
|
||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||
PlateStockMappings = PlateStockMappings is null
|
||||
? []
|
||||
@@ -97,7 +109,7 @@ public class NestResponse
|
||||
|
||||
var nestEntry = zip.CreateEntry("nest.nest");
|
||||
using var nestMs = new MemoryStream();
|
||||
new NestWriter(Nest).Write(nestMs);
|
||||
new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(nestMs);
|
||||
nestMs.Position = 0;
|
||||
await using (var stream = nestEntry.Open())
|
||||
{
|
||||
@@ -154,7 +166,7 @@ public class NestResponse
|
||||
await stream.CopyToAsync(nestMs);
|
||||
}
|
||||
nestMs.Position = 0;
|
||||
nest = new NestReader(nestMs).Read();
|
||||
nest = new NestReader(nestMs) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
}
|
||||
|
||||
return new NestResponse
|
||||
@@ -169,8 +181,10 @@ public class NestResponse
|
||||
ValidationStatus = archive.ValidationStatus,
|
||||
Violations = archive.Violations ?? [],
|
||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||
Unplaced = hasSchemaVersion && archive.SchemaVersion >= 5 ? archive.Unplaced ?? [] : [],
|
||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||
Costs = archive.Costs,
|
||||
Nest = nest,
|
||||
Request = request,
|
||||
};
|
||||
@@ -178,6 +192,7 @@ public class NestResponse
|
||||
|
||||
private sealed class NestResponseArchiveDto
|
||||
{
|
||||
public NestCostSummary Costs { get; init; }
|
||||
public int SchemaVersion { get; init; }
|
||||
public int SheetCount { get; init; }
|
||||
public double Utilization { get; init; }
|
||||
@@ -188,6 +203,7 @@ public class NestResponse
|
||||
public NestValidationStatus? ValidationStatus { get; init; }
|
||||
public List<string> Violations { get; init; } = [];
|
||||
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
public List<NestUnplacedRequestPart> Unplaced { get; init; } = [];
|
||||
public List<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class NestRunner
|
||||
if (requestParts.Count == 0)
|
||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||
|
||||
var stock = CreateStock(request);
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = IdentifyParts(requestParts);
|
||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
@@ -82,7 +83,6 @@ public static class NestRunner
|
||||
});
|
||||
}
|
||||
|
||||
var stock = CreateStock(request);
|
||||
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
@@ -91,6 +91,9 @@ public static class NestRunner
|
||||
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||
if (request.Plates != null)
|
||||
nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption
|
||||
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
|
||||
foreach (var item in items)
|
||||
nest.Drawings.Add(item.Drawing);
|
||||
foreach (var proposed in result.Plates)
|
||||
@@ -100,7 +103,7 @@ public static class NestRunner
|
||||
PartSpacing = proposed.Stock.PartSpacing,
|
||||
EdgeSpacing = proposed.Stock.EdgeSpacing,
|
||||
Quadrant = proposed.Stock.Quadrant,
|
||||
Quantity = 1,
|
||||
Quantity = proposed.Quantity,
|
||||
};
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
nest.Plates.Add(plate);
|
||||
@@ -108,17 +111,19 @@ public static class NestRunner
|
||||
|
||||
// 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 counts = result.Plates.SelectMany(p => p.Parts.Select(part => (part.BaseDrawing, p.Quantity)))
|
||||
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity));
|
||||
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 unplaced = result.Unplaced.Select(u => new NestUnplacedRequestPart(
|
||||
parts[items.FindIndex(item => ReferenceEquals(item.Drawing, u.Drawing))].Id, u.Count, u.Reason)).ToArray();
|
||||
var usage = stock.Select(s =>
|
||||
{
|
||||
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
|
||||
var used = result.Plates.Where(p => p.Stock.Id == s.Id).Sum(p => p.Quantity);
|
||||
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);
|
||||
@@ -133,7 +138,7 @@ public static class NestRunner
|
||||
return Task.FromResult(
|
||||
new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
SheetCount = nest.Plates.Sum(p => p.Quantity),
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
@@ -143,7 +148,9 @@ public static class NestRunner
|
||||
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
|
||||
Violations = result.Violations,
|
||||
Fulfillment = fulfillment,
|
||||
Unplaced = unplaced,
|
||||
StockUsage = usage,
|
||||
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
|
||||
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||
Nest = nest,
|
||||
@@ -209,11 +216,13 @@ public static class NestRunner
|
||||
plate.Quantity,
|
||||
plate.PartSpacing,
|
||||
plate.EdgeSpacing,
|
||||
plate.Quadrant
|
||||
plate.Quadrant,
|
||||
plate.Cost
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
NestJobValidator.ValidateStockCosts(stock);
|
||||
return stock;
|
||||
}
|
||||
|
||||
@@ -242,11 +251,11 @@ public static class NestRunner
|
||||
|
||||
private static double CalculateUtilization(Nest nest)
|
||||
{
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area() * plate.Quantity);
|
||||
if (sheetArea == 0)
|
||||
return 0;
|
||||
var placedArea = nest.Plates.Sum(plate =>
|
||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
|
||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) * plate.Quantity
|
||||
);
|
||||
return placedArea / sheetArea;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
@@ -290,7 +291,7 @@ namespace OpenNest.Benchmark
|
||||
// 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();
|
||||
var source = new OpenNest.IO.NestReader(job.SourceFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
materialized.Nest.Name = source.Name;
|
||||
materialized.Nest.Units = source.Units;
|
||||
materialized.Nest.Material = source.Material;
|
||||
@@ -316,7 +317,7 @@ namespace OpenNest.Benchmark
|
||||
throw new InvalidOperationException(
|
||||
"Output must not overwrite the source nest."
|
||||
);
|
||||
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
|
||||
new OpenNest.IO.NestWriter(materialized.Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(path);
|
||||
var report = new
|
||||
{
|
||||
Source = job.SourceFile,
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
@@ -39,7 +40,7 @@ namespace OpenNest.Benchmark
|
||||
|
||||
try
|
||||
{
|
||||
nest = new NestReader(file).Read();
|
||||
nest = new NestReader(file) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+357
-99
@@ -5,10 +5,15 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using OpenNest;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
@@ -75,6 +80,20 @@ static class NestConsole
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!options.AutoNest && (options.MaxSheets != 1 || options.Stock != null))
|
||||
{
|
||||
Console.Error.WriteLine("Error: --max-sheets and --stock require --autonest.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.MaxSheets < 1 || options.Stock is { Count: 0 })
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --max-sheets needs a whole number >= 1 and --stock needs WxL[:cost] sizes separated by commas (for example 60x120,48x96:85)."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -90,19 +109,37 @@ static class NestConsole
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
PlateFillService.ResolveStrategy(options.Engine);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
var engine = EngineNameResolver.Classify(options.Engine);
|
||||
if (!engine.IsFillStrategy)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
|
||||
engine.IsJobEngine
|
||||
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.PlanCutting && (options.CuttingParamsFile != null || options.KeepPartOrder
|
||||
|| options.AcceptUnverifiedPlan || options.AllPlates))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --cutting-params, --keep-part-order, --accept-unverified-plan and --all-plates require --plan-cutting."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|
||||
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|
||||
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.InputFiles.Count == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -118,42 +155,74 @@ static class NestConsole
|
||||
}
|
||||
}
|
||||
|
||||
// Read explicit settings before nesting, so a bad file fails without wasting the fill.
|
||||
CuttingParameters cuttingParameters = null;
|
||||
if (options.CuttingParamsFile != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
cuttingParameters = CuttingParametersSerializer.DeserializeExplicit(
|
||||
File.ReadAllText(options.CuttingParamsFile));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException
|
||||
or NotSupportedException)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: cannot read cutting settings '{options.CuttingParamsFile}': {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
var nest = LoadOrCreateNest(options);
|
||||
|
||||
if (nest == null)
|
||||
return 1;
|
||||
|
||||
var plate = nest.Plates[options.PlateIndex];
|
||||
IReadOnlyList<Plate> plates = new[] { plate };
|
||||
var overlapCount = 0;
|
||||
|
||||
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
|
||||
if (options.NoFill)
|
||||
{
|
||||
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
|
||||
return 2;
|
||||
Console.WriteLine($"Nest: {nest.Name}");
|
||||
overlapCount = CheckOverlaps(nest, plates, options);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
ApplyTemplate(plate, options);
|
||||
ApplyOverrides(plate, options);
|
||||
|
||||
var drawing = ResolveDrawing(nest, options);
|
||||
|
||||
if (drawing == null)
|
||||
return 1;
|
||||
|
||||
var existingCount = plate.Parts.Count;
|
||||
|
||||
if (!options.AutoNest && !options.KeepParts)
|
||||
plate.Parts.Clear();
|
||||
|
||||
PrintHeader(nest, plate, drawing, existingCount, options);
|
||||
|
||||
var (success, elapsed, accepted, filled) = Fill(nest, plate, drawing, options, token);
|
||||
if (!accepted)
|
||||
return 2; // No save or post may run after a rejected proposal.
|
||||
|
||||
plates = filled;
|
||||
overlapCount = CheckOverlaps(nest, plates, options);
|
||||
|
||||
PrintResults(success, nest, plates, elapsed);
|
||||
}
|
||||
|
||||
ApplyTemplate(plate, options);
|
||||
ApplyOverrides(plate, options);
|
||||
|
||||
var drawing = ResolveDrawing(nest, options);
|
||||
|
||||
if (drawing == null)
|
||||
return 1;
|
||||
|
||||
var existingCount = plate.Parts.Count;
|
||||
|
||||
if (!options.AutoNest && !options.KeepParts)
|
||||
plate.Parts.Clear();
|
||||
|
||||
PrintHeader(nest, plate, drawing, existingCount, options);
|
||||
|
||||
var (success, elapsed, accepted) = Fill(nest, plate, drawing, options, token);
|
||||
if (!accepted)
|
||||
return 2; // No save or post may run after a rejected proposal.
|
||||
|
||||
var overlapCount = CheckOverlaps(plate, options);
|
||||
|
||||
PrintResults(success, plate, elapsed);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (options.PlanCutting && !PlanCutting(nest, options.AllPlates ? nest.Plates : plates,
|
||||
cuttingParameters, options, token))
|
||||
return 2; // Like a rejected nest: nothing is saved or posted.
|
||||
if (!SaveAndPost(nest, options))
|
||||
return 1;
|
||||
|
||||
@@ -225,6 +294,14 @@ static class NestConsole
|
||||
case "--autonest":
|
||||
o.AutoNest = true;
|
||||
break;
|
||||
case "--max-sheets" when i + 1 < args.Length:
|
||||
o.MaxSheets = int.TryParse(args[++i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sheets)
|
||||
? sheets
|
||||
: 0;
|
||||
break;
|
||||
case "--stock" when i + 1 < args.Length:
|
||||
o.Stock = ParseStock(args[++i]);
|
||||
break;
|
||||
case "--engine" when i + 1 < args.Length:
|
||||
o.Engine = args[++i];
|
||||
break;
|
||||
@@ -243,6 +320,24 @@ static class NestConsole
|
||||
case "--list-posts":
|
||||
o.ListPosts = true;
|
||||
break;
|
||||
case "--no-fill":
|
||||
o.NoFill = true;
|
||||
break;
|
||||
case "--plan-cutting":
|
||||
o.PlanCutting = true;
|
||||
break;
|
||||
case "--cutting-params" when i + 1 < args.Length:
|
||||
o.CuttingParamsFile = args[++i];
|
||||
break;
|
||||
case "--keep-part-order":
|
||||
o.KeepPartOrder = true;
|
||||
break;
|
||||
case "--accept-unverified-plan":
|
||||
o.AcceptUnverifiedPlan = true;
|
||||
break;
|
||||
case "--all-plates":
|
||||
o.AllPlates = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
PrintUsage();
|
||||
@@ -257,23 +352,54 @@ static class NestConsole
|
||||
return o;
|
||||
}
|
||||
|
||||
/// <summary>Parses "WxL[:cost],..."; any malformed entry returns an empty list, which validation rejects.</summary>
|
||||
static List<PlateOption> ParseStock(string value)
|
||||
{
|
||||
var stock = new List<PlateOption>();
|
||||
foreach (var entry in value.Split(',', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var parts = entry.Split(':', StringSplitOptions.TrimEntries);
|
||||
var cost = 0.0;
|
||||
if (
|
||||
parts.Length > 2
|
||||
|| !Size.TryParse(parts[0], out var size)
|
||||
|| !double.IsFinite(size.Width)
|
||||
|| !double.IsFinite(size.Length)
|
||||
|| size.Width <= 0
|
||||
|| size.Length <= 0
|
||||
|| (
|
||||
parts.Length == 2
|
||||
&& (
|
||||
!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out cost)
|
||||
|| !double.IsFinite(cost)
|
||||
|| cost <= 0
|
||||
)
|
||||
)
|
||||
)
|
||||
return new List<PlateOption>();
|
||||
|
||||
stock.Add(new PlateOption { Width = size.Width, Length = size.Length, Cost = cost });
|
||||
}
|
||||
|
||||
return stock;
|
||||
}
|
||||
|
||||
static bool IsCadFile(string path) =>
|
||||
path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static Nest LoadOrCreateNest(Options options)
|
||||
{
|
||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
var dxfFiles = options
|
||||
.InputFiles.Where(f =>
|
||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
.ToList();
|
||||
var dxfFiles = options.InputFiles.Where(IsCadFile).ToList();
|
||||
|
||||
// If we have a nest file, load it and optionally add DXFs.
|
||||
if (nestFile != null)
|
||||
{
|
||||
var nest = new NestReader(nestFile).Read();
|
||||
var nest = new NestReader(nestFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
|
||||
if (nest.Plates.Count == 0)
|
||||
{
|
||||
@@ -318,9 +444,11 @@ static class NestConsole
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the plate as the desktop does: Quantity = 1 so part counts and the
|
||||
// drawings' nested quantities include it.
|
||||
var newNest = new Nest { Name = "DXF Import" };
|
||||
var plate = new Plate { Size = options.PlateSize.Value };
|
||||
newNest.Plates.Add(plate);
|
||||
newNest.PlateDefaults.Size = options.PlateSize.Value;
|
||||
newNest.CreatePlate();
|
||||
|
||||
foreach (var dxf in dxfFiles)
|
||||
{
|
||||
@@ -377,7 +505,7 @@ static class NestConsole
|
||||
return;
|
||||
}
|
||||
|
||||
var templateNest = new NestReader(options.TemplateFile).Read();
|
||||
var templateNest = new NestReader(options.TemplateFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
var templatePlate = templateNest.PlateDefaults.CreateNew();
|
||||
plate.Quadrant = templatePlate.Quadrant;
|
||||
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
||||
@@ -446,7 +574,7 @@ static class NestConsole
|
||||
);
|
||||
}
|
||||
|
||||
static (bool success, long elapsedMs, bool accepted) Fill(
|
||||
static (bool success, long elapsedMs, bool accepted, IReadOnlyList<Plate> plates) Fill(
|
||||
Nest nest,
|
||||
Plate plate,
|
||||
Drawing drawing,
|
||||
@@ -457,6 +585,7 @@ static class NestConsole
|
||||
var sw = Stopwatch.StartNew();
|
||||
bool success;
|
||||
var accepted = true;
|
||||
IReadOnlyList<Plate> plates = new[] { plate };
|
||||
|
||||
if (options.AutoNest)
|
||||
{
|
||||
@@ -477,15 +606,17 @@ static class NestConsole
|
||||
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||
);
|
||||
|
||||
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
|
||||
var outcome = AutoNestJob(nest, plate, nestItems, options, token);
|
||||
accepted = outcome.accepted;
|
||||
success = outcome.committed > 0;
|
||||
if (accepted)
|
||||
plates = outcome.plates;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-plate fill: explicit placement strategy through the public service;
|
||||
// the process-global engine registry is never consulted.
|
||||
var strategy = ResolveFillStrategy(options.Engine);
|
||||
var strategy = EngineNameResolver.Classify(options.Engine).FillStrategy;
|
||||
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
@@ -503,95 +634,201 @@ static class NestConsole
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return (success, sw.ElapsedMilliseconds, accepted);
|
||||
return (success, sw.ElapsedMilliseconds, accepted, plates);
|
||||
}
|
||||
|
||||
static (bool accepted, int committed) AutoNestJob(
|
||||
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
|
||||
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
|
||||
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
|
||||
{
|
||||
var rejected = (false, 0, (IReadOnlyList<Plate>)Array.Empty<Plate>());
|
||||
// One sheet of the target's size keeps the original single-sheet stock exactly;
|
||||
// otherwise the target is the template for every offered size, unlimited per size
|
||||
// and capped by --max-sheets physical sheets in total.
|
||||
var singleSheet = options.MaxSheets == 1 && options.Stock == null;
|
||||
var stock = singleSheet
|
||||
? NestStockBuilder.SinglePlate(plate)
|
||||
: NestStockBuilder.FromTemplate(plate, options.Stock);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
|
||||
options.Engine, nestItems, stock,
|
||||
singleSheet ? null : new NestJobOptions(maxPlates: options.MaxSheets)), token: token);
|
||||
foreach (var violation in result.Violations)
|
||||
Console.Error.WriteLine($"Violation: {violation}");
|
||||
foreach (var unplaced in result.Unplaced)
|
||||
Console.Error.WriteLine($"Unplaced: {unplaced.Describe()}");
|
||||
|
||||
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
|
||||
var tooManySheets = result.Raw.Plates.Count > options.MaxSheets;
|
||||
if (!result.CanKeep || tooManySheets || (!result.IsValid && !options.AllowInvalid))
|
||||
{
|
||||
Console.Error.WriteLine(result.Plates.Count > 1
|
||||
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
|
||||
Console.Error.WriteLine(tooManySheets
|
||||
? $"Error: result uses {result.Raw.Plates.Count} sheets, more than --max-sheets {options.MaxSheets}, 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);
|
||||
return rejected;
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
var proposed = result.Plates.SingleOrDefault();
|
||||
var committed = proposed?.Parts.Count ?? 0;
|
||||
var committed = result.Plates.Sum(p => p.Parts.Count * p.Quantity);
|
||||
if (committed == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
|
||||
return (false, 0);
|
||||
return rejected;
|
||||
}
|
||||
|
||||
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);
|
||||
var costs = result.IsValid && options.Stock != null ? NestCostSummary.FromAccepted(result) : null;
|
||||
|
||||
// The first layout replaces the target's parts; further layouts become new sheets
|
||||
// right after it, carrying the target's cutting settings. Other plates are unchanged.
|
||||
var insertAt = nest.Plates.IndexOf(plate) + 1;
|
||||
var targetUsed = false;
|
||||
var applied = NestPipelineCommit.ApplyToPlates(result, () =>
|
||||
{
|
||||
if (!targetUsed)
|
||||
{
|
||||
targetUsed = true;
|
||||
plate.Parts.Clear();
|
||||
return plate;
|
||||
}
|
||||
|
||||
var created = new Plate(plate.Size)
|
||||
{
|
||||
GrainAngle = plate.GrainAngle,
|
||||
CuttingParameters = plate.CuttingParameters,
|
||||
};
|
||||
nest.Plates.Insert(insertAt++, created);
|
||||
return created;
|
||||
}, options.AllowInvalid, token);
|
||||
|
||||
Console.WriteLine($"Engine: {options.Engine} — committed {committed} placements on {applied.Sum(p => p.Quantity)} sheet(s)");
|
||||
if (costs != null)
|
||||
Console.WriteLine($"Cost ({costs.Basis}): gross {costs.GrossTotal:F2}, salvage credit {costs.SalvageCredit:F2}, net {costs.NetScore:F2}");
|
||||
return (true, committed, applied);
|
||||
}
|
||||
|
||||
static string ResolveFillStrategy(string engineName)
|
||||
static int CheckOverlaps(Nest nest, IReadOnlyList<Plate> plates, Options options)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (!options.CheckOverlaps || plate.Parts.Count == 0)
|
||||
if (!options.CheckOverlaps)
|
||||
return 0;
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||
Console.WriteLine(
|
||||
hasOverlaps
|
||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: "Overlap check: PASS"
|
||||
);
|
||||
var total = 0;
|
||||
foreach (var plate in plates.Where(p => p.Parts.Count > 0))
|
||||
{
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||
var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : "";
|
||||
Console.WriteLine(
|
||||
hasOverlaps
|
||||
? $"{label}OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: $"{label}Overlap check: PASS"
|
||||
);
|
||||
total += overlapPts.Count;
|
||||
}
|
||||
|
||||
return overlapPts.Count;
|
||||
return total;
|
||||
}
|
||||
|
||||
static void PrintResults(bool success, Plate plate, long elapsedMs)
|
||||
static void PrintResults(bool success, Nest nest, IReadOnlyList<Plate> plates, long elapsedMs)
|
||||
{
|
||||
if (plates.Count > 1)
|
||||
{
|
||||
foreach (var plate in plates)
|
||||
Console.WriteLine(
|
||||
$"Plate {nest.Plates.IndexOf(plate)}: {plate.Size.Width:F1} x {plate.Size.Length:F1} x{plate.Quantity}, {plate.Parts.Count} parts, utilization {plate.Utilization():P1}"
|
||||
);
|
||||
}
|
||||
|
||||
var area = plates.Sum(p => p.Size.Width * p.Size.Length * p.Quantity);
|
||||
var utilization = area > 0
|
||||
? plates.Sum(p => p.Utilization() * p.Size.Width * p.Size.Length * p.Quantity) / area
|
||||
: 0;
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
Result: {(success ? "success" : "failed")}
|
||||
Parts placed: {plate.Parts.Count}
|
||||
Utilization: {plate.Utilization():P1}
|
||||
Parts placed: {plates.Sum(p => p.Parts.Count * p.Quantity)}
|
||||
Utilization: {utilization:P1}
|
||||
Time: {elapsedMs}ms
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plans and applies cutting (part order, contour order, entries and lead-ins) on the
|
||||
/// occupied plates in scope, all or nothing. Settings are the explicit file or the plates'
|
||||
/// own saved settings; none are invented. Unverified plans apply only with per-invocation
|
||||
/// acceptance, which is never consent to post.
|
||||
/// </summary>
|
||||
static bool PlanCutting(Nest nest, IEnumerable<Plate> plates, CuttingParameters explicitParameters,
|
||||
Options options, CancellationToken token)
|
||||
{
|
||||
var scope = plates.Where(p => p.Parts.Count > 0).ToArray();
|
||||
if (scope.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: no occupied plates to plan cutting for. Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = explicitParameters ?? SavedCuttingParameters(scope);
|
||||
if (parameters == null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --plan-cutting needs confirmed cutting settings: pass --cutting-params <settings.json>, or plan plates that all carry the same saved settings. Nothing saved."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine("---");
|
||||
Console.WriteLine($"Cutting settings: {(explicitParameters != null ? options.CuttingParamsFile : "saved with the plates")}");
|
||||
var batch = CuttingPlanBatch.Capture(scope, parameters, options.KeepPartOrder,
|
||||
scope.Select(p => nest.Plates.IndexOf(p)).ToArray(), token);
|
||||
var proposal = batch.Plan(new PlanningProgress(scope.Length), token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var line in proposal.Describe(UnitsHelper.GetShortString(nest.Units),
|
||||
"Pass --cutting-params settings that select"))
|
||||
Console.WriteLine(line);
|
||||
|
||||
if (!(proposal.CanApply || options.AcceptUnverifiedPlan && proposal.CanApplyWithWarnings))
|
||||
{
|
||||
Console.Error.WriteLine(proposal.RequiresWarningAcceptance
|
||||
? "Cutting plan not applied: it is unverified. Review the warnings above; to apply it anyway, explicitly use --accept-unverified-plan for this invocation. Nothing saved."
|
||||
: "Cutting plan not applied: every plate must be ready. Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var commit = proposal.Apply(options.AcceptUnverifiedPlan, token);
|
||||
if (commit.Status != CuttingCommitStatus.Applied)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: cutting plan not applied ({commit.Status}): {commit.Message} Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Cutting plan applied to {scope.Length} plate(s).");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The plates' saved settings when every plate carries the same ones; otherwise null.</summary>
|
||||
static CuttingParameters SavedCuttingParameters(IReadOnlyList<Plate> plates)
|
||||
{
|
||||
var first = plates[0].CuttingParameters;
|
||||
if (first == null)
|
||||
return null;
|
||||
var json = CuttingParametersSerializer.Serialize(first);
|
||||
return plates.All(p => p.CuttingParameters != null
|
||||
&& CuttingParametersSerializer.Serialize(p.CuttingParameters) == json) ? first : null;
|
||||
}
|
||||
|
||||
/// <summary>Reports synchronously, so phase lines print in order with the summary.</summary>
|
||||
sealed class PlanningProgress(int plateCount) : IProgress<CuttingPlanProgress>
|
||||
{
|
||||
public void Report(CuttingPlanProgress value) => Console.WriteLine(
|
||||
(plateCount > 1 ? $"Planning plate {value.PlateNumber}: " : "Planning: ") + value.Phase switch
|
||||
{
|
||||
CuttingPlanPhase.Reordering => "finding a part order...",
|
||||
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
|
||||
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
|
||||
_ => value.Phase + "...",
|
||||
});
|
||||
}
|
||||
|
||||
static void Save(Nest nest, Options options)
|
||||
{
|
||||
if (options.NoSave)
|
||||
@@ -599,7 +836,7 @@ static class NestConsole
|
||||
|
||||
var outputFile = NestOutputPath(options);
|
||||
|
||||
new NestWriter(nest).Write(outputFile);
|
||||
new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(outputFile);
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
}
|
||||
|
||||
@@ -760,11 +997,24 @@ static class NestConsole
|
||||
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
||||
--output <path> Output nest file path (default: <input>-result.nest)
|
||||
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
||||
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
|
||||
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
|
||||
--max-sheets <n> With --autonest: physical sheets the job may use (default: 1); extra
|
||||
layouts are inserted as new plates after the target plate
|
||||
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
|
||||
target plate's size); spacing, quadrant and clamps follow the target
|
||||
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...).
|
||||
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant)
|
||||
--keep-parts Don't clear existing parts before filling
|
||||
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
|
||||
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
|
||||
contour order, entries, lead-ins); applied only when every plate is
|
||||
ready or --accept-unverified-plan is given (else exit 2)
|
||||
--cutting-params <path> Cutting settings JSON for --plan-cutting (default: the plates' own
|
||||
saved settings, which must agree; never invented)
|
||||
--keep-part-order With --plan-cutting: keep the current whole-part order
|
||||
--all-plates With --plan-cutting: plan every occupied plate, not just the target
|
||||
--accept-unverified-plan Explicitly apply an unverified cutting plan for this invocation
|
||||
--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
|
||||
@@ -791,6 +1041,8 @@ static class NestConsole
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public bool AllowInvalid;
|
||||
public int MaxSheets = 1;
|
||||
public List<PlateOption> Stock;
|
||||
public string Engine = "Default";
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
@@ -798,6 +1050,12 @@ static class NestConsole
|
||||
public string PostOutput;
|
||||
public string PostsDir;
|
||||
public bool ListPosts;
|
||||
public bool NoFill;
|
||||
public bool PlanCutting;
|
||||
public string CuttingParamsFile;
|
||||
public bool KeepPartOrder;
|
||||
public bool AcceptUnverifiedPlan;
|
||||
public bool AllPlates;
|
||||
public double? RepairBendsMillimeters;
|
||||
public BendRepairUnits CadUnits;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Existing cutting work on a set of plates: applied lead-ins and cut-offs.</summary>
|
||||
/// <param name="LeadInParts">Real parts carrying applied lead-ins (locked ones included).</param>
|
||||
/// <param name="LockedLeadInParts">Of those, parts whose lead-ins are locked.</param>
|
||||
/// <param name="CutOffs">Cut-off definitions.</param>
|
||||
/// <param name="PlateNumbers">Display numbers of the plates holding any of it, ascending.</param>
|
||||
public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs,
|
||||
IReadOnlyList<int> PlateNumbers)
|
||||
{
|
||||
public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0;
|
||||
|
||||
/// <summary>Operator text for the cutting planner's warning banner; empty when nothing exists.</summary>
|
||||
public string Describe()
|
||||
{
|
||||
if (!HasExistingWork)
|
||||
return string.Empty;
|
||||
var found = new List<string>();
|
||||
if (LeadInParts > 0)
|
||||
found.Add($"lead-ins on {Count(LeadInParts, "part")}"
|
||||
+ (LockedLeadInParts > 0 ? $" ({LockedLeadInParts} locked)" : string.Empty));
|
||||
if (CutOffs > 0)
|
||||
found.Add(Count(CutOffs, "cut-off"));
|
||||
var plates = PlateNumbers.Count == 1 ? "plate " : "plates ";
|
||||
var kept = new List<string>();
|
||||
if (LockedLeadInParts > 0)
|
||||
kept.Add("locked lead-ins");
|
||||
if (CutOffs > 0)
|
||||
kept.Add("existing cut-offs");
|
||||
return $"ALREADY APPLIED: {string.Join(" and ", found)} on {plates}{string.Join(", ", PlateNumbers)}."
|
||||
+ (LeadInParts > LockedLeadInParts ? " Planning recalculates unlocked lead-ins." : string.Empty)
|
||||
+ (kept.Count > 0 ? $" Planning keeps {string.Join(" and ", kept)} unchanged." : string.Empty)
|
||||
+ " Clear them to recalculate everything from scratch.";
|
||||
}
|
||||
|
||||
private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}";
|
||||
}
|
||||
|
||||
/// <summary>Finds and clears applied lead-ins and cut-offs so a plan can recalculate them.</summary>
|
||||
public static class AppliedCuttingWork
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts applied lead-ins on real parts and cut-off definitions. Definitions in
|
||||
/// <paramref name="ignoredCutOffs"/> (for example ones staged by the caller) are not counted.
|
||||
/// </summary>
|
||||
public static AppliedCuttingWorkSummary Summarize(IReadOnlyList<Plate> plates,
|
||||
IReadOnlyList<int> plateNumbers = null, IEnumerable<CutOff> ignoredCutOffs = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
if (plateNumbers != null && plateNumbers.Count != plates.Count)
|
||||
throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
|
||||
var ignored = new HashSet<CutOff>(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance);
|
||||
int leads = 0, locked = 0, cutOffs = 0;
|
||||
var numbers = new SortedSet<int>();
|
||||
for (var index = 0; index < plates.Count; index++)
|
||||
{
|
||||
var plate = plates[index] ?? throw new ArgumentException("Plates must not be null.", nameof(plates));
|
||||
var plateLeads = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray();
|
||||
var plateCutOffs = plate.CutOffs.Count(c => !ignored.Contains(c));
|
||||
leads += plateLeads.Length;
|
||||
locked += plateLeads.Count(p => p.LeadInsLocked);
|
||||
cutOffs += plateCutOffs;
|
||||
if (plateLeads.Length > 0 || plateCutOffs > 0)
|
||||
numbers.Add(plateNumbers?[index] ?? index + 1);
|
||||
}
|
||||
return new(leads, locked, cutOffs, numbers.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes every applied lead-in (locked ones too) from real parts and every cut-off with
|
||||
/// its cut-off parts, and forgets each plate's recorded cutting parameters. Part poses,
|
||||
/// order and quantities are unchanged. Returns what was cleared.
|
||||
/// </summary>
|
||||
public static AppliedCuttingWorkSummary Clear(IReadOnlyList<Plate> plates, IReadOnlyList<int> plateNumbers = null)
|
||||
{
|
||||
var cleared = Summarize(plates, plateNumbers);
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray())
|
||||
part.RemoveLeadIns();
|
||||
plate.CutOffs.Clear();
|
||||
for (var index = plate.Parts.Count - 1; index >= 0; index--)
|
||||
if (plate.Parts[index].BaseDrawing.IsCutOff)
|
||||
plate.Parts.RemoveAt(index);
|
||||
plate.CuttingParameters = null;
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch
|
||||
/// 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) => Stage(plates, options, settings).Plans;
|
||||
|
||||
/// <summary>
|
||||
/// Applies like <see cref="Apply"/>, but keeps the recovery state so the caller can take the
|
||||
/// new cut-offs back off again (<see cref="AutomaticCutOffStaging.Revert"/>) when the work they
|
||||
/// were added for is abandoned. A blocked batch changes nothing and has nothing to revert.
|
||||
/// </summary>
|
||||
public static AutomaticCutOffStaging Stage(IReadOnlyList<Plate> plates,
|
||||
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||
{
|
||||
var plans = Create(plates, options, settings);
|
||||
if (plans.Any(plan => plan.HasBlockingDiagnostics))
|
||||
return plans;
|
||||
return new AutomaticCutOffStaging(plans, []);
|
||||
|
||||
var restoreActions = new List<(int PlateNumber, Action Restore)>();
|
||||
try
|
||||
@@ -66,21 +74,7 @@ public static class AutomaticCutOffBatch
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
var rollbackErrors = RestoreAll(restoreActions);
|
||||
if (rollbackErrors.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
|
||||
@@ -91,7 +85,27 @@ public static class AutomaticCutOffBatch
|
||||
throw new InvalidOperationException(
|
||||
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
|
||||
}
|
||||
return plans;
|
||||
return new AutomaticCutOffStaging(plans, restoreActions);
|
||||
}
|
||||
|
||||
internal static List<Exception> RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
|
||||
{
|
||||
var errors = 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.
|
||||
errors.Add(new InvalidOperationException(
|
||||
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
|
||||
@@ -117,3 +131,52 @@ public static class AutomaticCutOffBatch
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cut-offs added by <see cref="AutomaticCutOffBatch.Stage"/> that are still provisional: either
|
||||
/// <see cref="Keep"/> them once the work they belong to is committed, or <see cref="Revert"/> them.
|
||||
/// Owner-thread only, like the plates it changed.
|
||||
/// </summary>
|
||||
public sealed class AutomaticCutOffStaging
|
||||
{
|
||||
private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions;
|
||||
|
||||
internal AutomaticCutOffStaging(IReadOnlyList<AutomaticCutOffPlan> plans,
|
||||
IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
|
||||
{
|
||||
Plans = plans;
|
||||
this.restoreActions = restoreActions;
|
||||
Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>The fresh plans, one per plate in the order given.</summary>
|
||||
public IReadOnlyList<AutomaticCutOffPlan> Plans { get; }
|
||||
|
||||
/// <summary>True when any plate blocked the batch; then nothing was added.</summary>
|
||||
public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics);
|
||||
|
||||
/// <summary>The definitions this staging added (empty when blocked or nothing was needed).</summary>
|
||||
public IReadOnlyList<CutOff> Definitions { get; }
|
||||
|
||||
/// <summary>True while the added cut-offs can still be reverted.</summary>
|
||||
public bool CanRevert => restoreActions.Count > 0;
|
||||
|
||||
/// <summary>Makes the added cut-offs permanent; a later <see cref="Revert"/> does nothing.</summary>
|
||||
public void Keep() => restoreActions = [];
|
||||
|
||||
/// <summary>
|
||||
/// Removes the added cut-offs and restores each touched plate's previous cut-off programs and
|
||||
/// sequence. Real parts are not touched. Runs once; throws if any plate could not be restored.
|
||||
/// </summary>
|
||||
public void Revert()
|
||||
{
|
||||
var actions = restoreActions;
|
||||
restoreActions = [];
|
||||
var errors = AutomaticCutOffBatch.RestoreAll(actions);
|
||||
if (errors.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
"Removing the added cut-offs failed: " + string.Join("; ", errors.Select(e => e.Message))
|
||||
+ "\nThe nest may be incomplete; review it before saving or cutting.",
|
||||
new AggregateException(errors));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions
|
||||
/// The desktop default is 12 inches (304.8 mm).
|
||||
/// </summary>
|
||||
public double MinimumTailLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum strip length in model units. Null or equal to Spacing preserves the fixed grid.
|
||||
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
|
||||
/// </summary>
|
||||
public double? MinimumStripLength { get; set; }
|
||||
}
|
||||
|
||||
public enum AutomaticCutOffDiagnosticCode
|
||||
@@ -29,8 +35,10 @@ public enum AutomaticCutOffDiagnosticCode
|
||||
LimitedCutOffConflict,
|
||||
EmptyCut,
|
||||
SegmentedCut,
|
||||
ClampBlockedCut,
|
||||
NoSafeTailSeparator,
|
||||
TailBelowMinimum,
|
||||
ShiftedCut,
|
||||
}
|
||||
|
||||
public sealed record AutomaticCutOffDiagnostic(
|
||||
@@ -69,6 +77,12 @@ public sealed class AutomaticCutOffPlan
|
||||
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||
|
||||
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
|
||||
public double StripCutLength { get; internal set; }
|
||||
|
||||
/// <summary>Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path.</summary>
|
||||
public double FixedGridCutLength { get; internal set; }
|
||||
|
||||
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
|
||||
}
|
||||
|
||||
@@ -78,6 +92,7 @@ public static class AutomaticCutOffPlanner
|
||||
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||
public const double MinimumSpacing = 2 * GeometryTolerance;
|
||||
public const int MaximumCandidateCount = 10000;
|
||||
public const int MaximumShiftCandidateCount = 20000;
|
||||
|
||||
/// <summary>
|
||||
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
|
||||
@@ -93,6 +108,8 @@ public static class AutomaticCutOffPlanner
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ValidateInputs(plate, options, settings);
|
||||
// Stored zones use the sheet's lower-left frame; cut positions are signed.
|
||||
var clamps = ClampZoneFrame.ToPlacement(plate);
|
||||
var bounds = plate.BoundingBox(false);
|
||||
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
|
||||
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
||||
@@ -118,7 +135,11 @@ public static class AutomaticCutOffPlanner
|
||||
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
||||
+ GeometryTolerance;
|
||||
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||
var usedSpan = hasTailCandidate ? separator : length;
|
||||
// A tail too short to keep stays attached to the skeleton, so the strip grid must
|
||||
// continue to the sheet end; stopping at the skipped separator would leave a final
|
||||
// strip longer than the spacing.
|
||||
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength;
|
||||
var usedSpan = keepsTail ? 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));
|
||||
@@ -130,34 +151,79 @@ public static class AutomaticCutOffPlanner
|
||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||
var separated = false;
|
||||
var separatorX = (double?)null;
|
||||
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
|
||||
var stripCutLength = 0.0;
|
||||
var fixedGridCutLength = 0.0;
|
||||
var measurements = new Dictionary<double, (double Length, int SegmentCount)>();
|
||||
var candidates = new HashSet<double>();
|
||||
|
||||
// Multiplication by an integer avoids drift from repeated floating-point addition.
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
if (shifted)
|
||||
{
|
||||
var distance = index * options.Spacing;
|
||||
if (distance >= usedSpan - GeometryTolerance)
|
||||
break;
|
||||
AddCandidate(sign * distance, false);
|
||||
// Full-span definitions fix the strip boundaries, even when their emitted
|
||||
// programs are segmented. Partial cuts remain obstacles, not boundaries.
|
||||
var boundaries = plate.CutOffs
|
||||
.Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings))
|
||||
.Select(c => sign * c.Position.X)
|
||||
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
|
||||
.Distinct().OrderBy(distance => distance).Append(usedSpan);
|
||||
var gaps = new List<(double Start, double End, int Count)>();
|
||||
var start = 0.0;
|
||||
var nominalCount = 0;
|
||||
foreach (var end in boundaries)
|
||||
{
|
||||
var count = 0;
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
{
|
||||
if (start + index * options.Spacing >= end - GeometryTolerance)
|
||||
break;
|
||||
count++;
|
||||
}
|
||||
gaps.Add((start, end, count));
|
||||
nominalCount += count;
|
||||
start = end;
|
||||
}
|
||||
// The origin-based lattice alone fits within 20,000 positions, but its
|
||||
// off-lattice nominal lines need reserved slots too. Budget both before
|
||||
// constructing any row so a valid, dense grid is never refused halfway.
|
||||
Require(nominalCount < MaximumShiftCandidateCount,
|
||||
"Too many nominal strip lines for the shift search.", nameof(options));
|
||||
var step = System.Math.Max(options.Spacing / 700,
|
||||
usedSpan / (MaximumShiftCandidateCount - nominalCount));
|
||||
foreach (var gap in gaps)
|
||||
AddShiftedGap(gap.Start, gap.End, gap.Count, step);
|
||||
}
|
||||
if (hasTailCandidate)
|
||||
else
|
||||
{
|
||||
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));
|
||||
// Keep the legacy loop and certification unchanged when shifting is disabled.
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
{
|
||||
var distance = index * options.Spacing;
|
||||
if (distance >= usedSpan - GeometryTolerance)
|
||||
break;
|
||||
AddCandidate(sign * distance, false);
|
||||
}
|
||||
}
|
||||
if (keepsTail)
|
||||
AddCandidate(sign * separator, true);
|
||||
else if (hasTailCandidate)
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
||||
"The proposed tail is shorter than the minimum tail length; the final separator was skipped "
|
||||
+ "and strip lines continue to the sheet end. 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();
|
||||
stripCutLength = 0;
|
||||
separated = false;
|
||||
separatorX = null;
|
||||
}
|
||||
// The legacy placement loop stays untouched. Its detached programs are already
|
||||
// regenerated, so reporting their actual new strip cutting needs no new search.
|
||||
if (!shifted)
|
||||
fixedGridCutLength = stripCutLength;
|
||||
|
||||
return new AutomaticCutOffPlan
|
||||
{
|
||||
@@ -168,12 +234,74 @@ public static class AutomaticCutOffPlanner
|
||||
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
|
||||
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
|
||||
TailSeparatorX = separatorX,
|
||||
StripCutLength = stripCutLength,
|
||||
FixedGridCutLength = fixedGridCutLength,
|
||||
};
|
||||
|
||||
void AddShiftedGap(double start, double end, int count, double step)
|
||||
{
|
||||
var nominal = new List<double>(count);
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
var distance = start + index * options.Spacing;
|
||||
nominal.Add(distance);
|
||||
fixedGridCutLength += Measure(distance).Length;
|
||||
}
|
||||
if (count == 0)
|
||||
return;
|
||||
var positions = AutomaticCutOffStripSearch.FindPositions(start, end, count,
|
||||
options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray();
|
||||
for (var index = 0; index < positions.Length; index++)
|
||||
{
|
||||
var distance = positions[index];
|
||||
AddCandidate(sign * distance, false);
|
||||
if (!Near(distance, nominal[index]))
|
||||
{
|
||||
var difference = Measure(nominal[index]).Length - Measure(distance).Length;
|
||||
var comparison = difference > 0 ? $"{difference:G8} model units less" :
|
||||
difference < 0 ? $"{-difference:G8} model units more" : "the same length";
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut,
|
||||
$"Moved from {nominal[index]:G8} to {distance:G8} model units; "
|
||||
+ $"this line cuts {comparison}.", X: sign * distance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Allowed(double distance)
|
||||
{
|
||||
RegisterCandidate(distance);
|
||||
// Use the certification path's exact Near predicate, including its ULP
|
||||
// allowance, so a selected line cannot rediscover a limited-cut conflict.
|
||||
var x = sign * distance;
|
||||
return !plate.CutOffs.Any(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)) &&
|
||||
!clamps.Any(zone => CrossesClamp(zone, x));
|
||||
}
|
||||
|
||||
void RegisterCandidate(double distance)
|
||||
{
|
||||
if (candidates.Add(distance))
|
||||
Require(candidates.Count <= MaximumShiftCandidateCount,
|
||||
$"Shift search would generate more than {MaximumShiftCandidateCount} distinct positions. "
|
||||
+ "Increase spacing or the minimum strip length, or use the fixed grid.", nameof(options));
|
||||
}
|
||||
|
||||
(double Length, int SegmentCount) Measure(double distance)
|
||||
{
|
||||
RegisterCandidate(distance);
|
||||
if (!measurements.TryGetValue(distance, out var measured))
|
||||
{
|
||||
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
|
||||
.MeasureSegments(plate, settings, cache);
|
||||
measurements.Add(distance, measured);
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -185,6 +313,24 @@ public static class AutomaticCutOffPlanner
|
||||
return;
|
||||
}
|
||||
|
||||
// A full-width cut crosses every active front-edge clamp at its X. Test the
|
||||
// proposed X and every equivalent existing definition, because a tolerance-close
|
||||
// existing line is regenerated and certified at its own X. Keep the whole
|
||||
// footprint when it hangs off the sheet. A same-line manual limit conflict above
|
||||
// still blocks the whole plan, clamp or not.
|
||||
var blocked = clamps.FirstOrDefault(zone =>
|
||||
matches.Select(c => c.Position.X).Prepend(x).Any(position => CrossesClamp(zone, position)));
|
||||
if (blocked != null)
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
|
||||
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
|
||||
X: 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();
|
||||
@@ -202,7 +348,7 @@ public static class AutomaticCutOffPlanner
|
||||
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.",
|
||||
"The line has no usable cut segments after part clearance and minimum-length filtering.",
|
||||
X: x));
|
||||
|
||||
if (isSeparator)
|
||||
@@ -218,16 +364,33 @@ public static class AutomaticCutOffPlanner
|
||||
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.",
|
||||
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
|
||||
X: x));
|
||||
|
||||
if (usable && existing == null)
|
||||
{
|
||||
definitions.Add(candidate);
|
||||
if (!isSeparator)
|
||||
{
|
||||
if (shifted)
|
||||
stripCutLength += Measure(System.Math.Abs(x)).Length;
|
||||
else
|
||||
for (var index = 0; index < program.Codes.Count; index += 2)
|
||||
stripCutLength += System.Math.Abs(
|
||||
((LinearMove)program.Codes[index + 1]).EndPoint.Y -
|
||||
((RapidMove)program.Codes[index]).EndPoint.Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Share the exact placement-frame predicate with candidate exclusion and final
|
||||
// certification. Infeasible gaps still use nominal lines through AddCandidate.
|
||||
bool CrossesClamp(ClampZone zone, double x) =>
|
||||
x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom;
|
||||
|
||||
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.",
|
||||
"No safe full-width tail separator survives the current settings. Review the tail manually.",
|
||||
X: x));
|
||||
}
|
||||
|
||||
@@ -235,6 +398,10 @@ public static class AutomaticCutOffPlanner
|
||||
{
|
||||
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
||||
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
|
||||
Require(!options.MinimumStripLength.HasValue ||
|
||||
(double.IsFinite(options.MinimumStripLength.Value) && options.MinimumStripLength.Value > MinimumSpacing &&
|
||||
options.MinimumStripLength.Value <= options.Spacing),
|
||||
$"Minimum strip length must be finite, greater than {MinimumSpacing} and no greater than spacing.", 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 &&
|
||||
@@ -242,6 +409,9 @@ public static class AutomaticCutOffPlanner
|
||||
"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(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
||||
Nonnegative(plate.EdgeSpacing.Bottom)),
|
||||
"Clamp zones and bottom-edge clearance must be finite and valid.", 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));
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Exact layered search over sorted positions; each transition uses a sliding minimum.</summary>
|
||||
internal static class AutomaticCutOffStripSearch
|
||||
{
|
||||
internal static double[] FindPositions(double start, double end, int count, double spacing,
|
||||
double minimum, double step, Func<double, bool> allowed,
|
||||
Func<double, (double Length, int SegmentCount)> measure)
|
||||
{
|
||||
var rows = new List<double[]>(count);
|
||||
var nominal = new double[count];
|
||||
for (var line = 1; line <= count; line++)
|
||||
{
|
||||
var today = start + line * spacing;
|
||||
nominal[line - 1] = today;
|
||||
var low = System.Math.Max(start + line * minimum, end - (count - line + 1) * spacing);
|
||||
var high = System.Math.Min(today, end - AutomaticCutOffPlanner.GeometryTolerance);
|
||||
var positions = new List<double>();
|
||||
// Integer indices keep candidates on the origin-based lattice without cumulative drift.
|
||||
var first = (long)System.Math.Ceiling(low / step);
|
||||
var last = (long)System.Math.Floor(high / step);
|
||||
for (var index = first; index <= last; index++)
|
||||
{
|
||||
var position = index * step;
|
||||
if (position >= low && position <= high && allowed(position))
|
||||
positions.Add(position);
|
||||
}
|
||||
if (allowed(today))
|
||||
positions.Add(today);
|
||||
positions.Sort();
|
||||
for (var index = positions.Count - 1; index > 0; index--)
|
||||
if (positions[index] == positions[index - 1])
|
||||
positions.RemoveAt(index);
|
||||
if (positions.Count == 0)
|
||||
return null;
|
||||
rows.Add(positions.ToArray());
|
||||
}
|
||||
return Solve(start, rows, nominal, spacing, minimum, measure);
|
||||
}
|
||||
|
||||
// Kept independent of geometry so tests can compare against exhaustive, non-greedy oracles.
|
||||
internal static double[] Solve(double start, IReadOnlyList<double[]> rows,
|
||||
IReadOnlyList<double> nominal, double spacing, double minimum,
|
||||
Func<double, (double Length, int SegmentCount)> measure)
|
||||
{
|
||||
var previous = new List<State> { new() { Position = start } };
|
||||
for (var line = 0; line < rows.Count; line++)
|
||||
{
|
||||
var current = new List<State>(rows[line].Length);
|
||||
var deque = new int[previous.Count];
|
||||
var head = 0;
|
||||
var tail = 0;
|
||||
var added = 0;
|
||||
foreach (var position in rows[line])
|
||||
{
|
||||
var upper = position - minimum + AutomaticCutOffPlanner.GeometryTolerance;
|
||||
var lower = position - spacing - AutomaticCutOffPlanner.GeometryTolerance;
|
||||
while (added < previous.Count && previous[added].Position <= upper)
|
||||
{
|
||||
while (tail > head && Compare(previous[added], previous[deque[tail - 1]]) < 0)
|
||||
tail--;
|
||||
deque[tail++] = added++;
|
||||
}
|
||||
while (head < tail && previous[deque[head]].Position < lower)
|
||||
head++;
|
||||
if (head == tail)
|
||||
continue;
|
||||
var parent = previous[deque[head]];
|
||||
var cost = measure(position);
|
||||
current.Add(new State
|
||||
{
|
||||
Position = position,
|
||||
Length = parent.Length + RoundedMicroUnits(cost.Length),
|
||||
Pierces = parent.Pierces + cost.SegmentCount,
|
||||
Deviation = parent.Deviation + System.Math.Abs(nominal[line] - position),
|
||||
Parent = parent,
|
||||
});
|
||||
}
|
||||
if (current.Count == 0)
|
||||
return null;
|
||||
AssignLexicographicRanks(current, previous.Count);
|
||||
previous = current;
|
||||
}
|
||||
State best = null;
|
||||
foreach (var state in previous)
|
||||
if (best == null || Compare(state, best) < 0)
|
||||
best = state;
|
||||
var result = new double[rows.Count];
|
||||
for (var line = result.Length - 1; line >= 0; line--)
|
||||
{
|
||||
result[line] = best.Position;
|
||||
best = best.Parent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static BigInteger RoundedMicroUnits(double length)
|
||||
{
|
||||
if (!double.IsFinite(length) || length < 0)
|
||||
throw new ArgumentException("Cut-off segment length must be finite and nonnegative.", nameof(length));
|
||||
var rounded = System.Math.Round(length, 6);
|
||||
// A shortest round-trip decimal may spell a different integer near 1e16
|
||||
// (for example 26833294515438512d prints as 26833294515438510).
|
||||
// An already-integral double is exact; never round it through text.
|
||||
if (rounded == System.Math.Truncate(rounded))
|
||||
return new BigInteger(rounded) * 1_000_000;
|
||||
// Compare sums of the specified decimal-rounded model units, not binary
|
||||
// approximations such as 0.1 + 0.2 versus 0.3. The round-trip string
|
||||
// retains micro-unit digits for fractional scores.
|
||||
var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture),
|
||||
NumberStyles.Float, CultureInfo.InvariantCulture);
|
||||
return new BigInteger(decimal.Round(value * 1_000_000m, 0));
|
||||
}
|
||||
|
||||
private static int Compare(State left, State right)
|
||||
{
|
||||
var comparison = left.Length.CompareTo(right.Length);
|
||||
if (comparison == 0)
|
||||
comparison = left.Pierces.CompareTo(right.Pierces);
|
||||
if (comparison == 0)
|
||||
comparison = left.Deviation.CompareTo(right.Deviation);
|
||||
return comparison != 0 ? comparison : left.LexicographicRank.CompareTo(right.LexicographicRank);
|
||||
}
|
||||
|
||||
private static void AssignLexicographicRanks(List<State> states, int parentCount)
|
||||
{
|
||||
// Stable counting sort by prefix rank. Positions are already sorted, so within a
|
||||
// prefix their order is lexicographic. Ranking and transitions are linear per row.
|
||||
var offsets = new int[parentCount];
|
||||
foreach (var state in states)
|
||||
offsets[state.Parent.LexicographicRank]++;
|
||||
var total = 0;
|
||||
for (var index = 0; index < offsets.Length; index++)
|
||||
{
|
||||
var count = offsets[index];
|
||||
offsets[index] = total;
|
||||
total += count;
|
||||
}
|
||||
foreach (var state in states)
|
||||
state.LexicographicRank = offsets[state.Parent.LexicographicRank]++;
|
||||
}
|
||||
|
||||
private sealed class State
|
||||
{
|
||||
public double Position;
|
||||
public BigInteger Length;
|
||||
public int Pierces;
|
||||
public double Deviation;
|
||||
public State Parent;
|
||||
public int LexicographicRank;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.BestFit
|
||||
{
|
||||
/// <summary>One saved best-fit pair result, as persisted in a .nest archive.</summary>
|
||||
public sealed record SavedPairResult(
|
||||
double Part1Rotation,
|
||||
double Part2Rotation,
|
||||
double Part2OffsetX,
|
||||
double Part2OffsetY,
|
||||
int StrategyType,
|
||||
int TestNumber,
|
||||
double CandidateSpacing,
|
||||
double RotatedArea,
|
||||
double BoundingWidth,
|
||||
double BoundingHeight,
|
||||
double OptimalRotation,
|
||||
bool Keep,
|
||||
string Reason,
|
||||
double TrueArea,
|
||||
IReadOnlyList<double> HullAngles
|
||||
);
|
||||
|
||||
/// <summary>The saved best-fit results for one drawing at one plate size and spacing.</summary>
|
||||
public sealed record SavedPairSet(
|
||||
double PlateWidth,
|
||||
double PlateHeight,
|
||||
double Spacing,
|
||||
IReadOnlyList<SavedPairResult> Results
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Persists best-fit pair caches across .nest round trips. Implementations own their
|
||||
/// cache; readers and writers never assume one exists, and a file without saved sets is
|
||||
/// always valid because the data can be recomputed.
|
||||
/// </summary>
|
||||
public interface IBestFitStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets to save for <paramref name="drawing"/>, limited to the given
|
||||
/// (plate width, plate length, part spacing) keys.
|
||||
/// </summary>
|
||||
IReadOnlyList<SavedPairSet> Export(
|
||||
Drawing drawing,
|
||||
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
|
||||
);
|
||||
|
||||
/// <summary>Restores saved sets for <paramref name="drawing"/>.</summary>
|
||||
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// Scrap drawn on a part (for example a forming backgauge tab) that stays attached by tabs and
|
||||
/// is snapped off later. It is identified by its joining line: a chain of source entities whose
|
||||
/// ends touch the outline around part and scrap and which splits that region in two. By
|
||||
/// default the smaller side is the scrap. Building the drawing cuts the joining line as open
|
||||
/// break cuts between tabs, entered from the scrap side. All values are in the source frame.
|
||||
/// </summary>
|
||||
public sealed class Breakoff
|
||||
{
|
||||
public const double DefaultTabWidth = 0.0625;
|
||||
public const int DefaultTabCount = 4;
|
||||
public const int MinTabCount = 2;
|
||||
|
||||
/// <summary>Source entity IDs of the joining line between part and scrap.</summary>
|
||||
public List<Guid> EdgeIds { get; set; } = new();
|
||||
|
||||
/// <summary>True when the larger side is the scrap instead of the smaller one.</summary>
|
||||
public bool Flip { get; set; }
|
||||
|
||||
/// <summary>Uncut length of each tab along the joining line.</summary>
|
||||
public double TabWidth { get; set; } = DefaultTabWidth;
|
||||
|
||||
/// <summary>
|
||||
/// Tab centers as distances along the joining line from its start, ascending; the first
|
||||
/// and last tab sit at the ends. Empty means <see cref="DefaultTabCount"/> evenly spaced.
|
||||
/// </summary>
|
||||
public List<double> TabCenters { get; set; } = new();
|
||||
|
||||
public Breakoff Clone() =>
|
||||
new()
|
||||
{
|
||||
EdgeIds = new List<Guid>(EdgeIds),
|
||||
Flip = Flip,
|
||||
TabWidth = TabWidth,
|
||||
TabCenters = new List<double>(TabCenters),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
|
||||
/// break layer between tabs, each travelling with the scrap on its left so the cutting
|
||||
/// strategies pierce and lead in from the scrap.
|
||||
/// </summary>
|
||||
public static class BreakoffBuilder
|
||||
{
|
||||
/// <summary>How close the joining line's ends must be to the outline.</summary>
|
||||
public const double JoinTolerance = 0.001;
|
||||
|
||||
private const double ArcTolerance = 0.0005;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="entities"/> with every valid breakoff applied: its joining line replaced
|
||||
/// by break cuts and its outline's joints made exact. Inputs are not modified; generated
|
||||
/// entities are new. Breakoffs that cannot be resolved are skipped and described in
|
||||
/// <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
public static List<Entity> Apply(IReadOnlyList<Entity> entities, IReadOnlyList<Breakoff> breakoffs,
|
||||
out List<string> errors)
|
||||
{
|
||||
errors = new List<string>();
|
||||
var working = entities.ToList();
|
||||
if (breakoffs == null || breakoffs.Count == 0)
|
||||
return working;
|
||||
|
||||
var allEdges = breakoffs.SelectMany(b => b.EdgeIds).ToHashSet();
|
||||
for (var i = 0; i < breakoffs.Count; i++)
|
||||
{
|
||||
var layout = Layout(working, breakoffs[i], allEdges);
|
||||
if (!layout.IsValid)
|
||||
{
|
||||
errors.Add($"Breakoff {i + 1}: {layout.Error}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var replaced = new HashSet<Entity>(layout.ReplacedEntities, ReferenceEqualityComparer.Instance);
|
||||
working.RemoveAll(replaced.Contains);
|
||||
working.AddRange(layout.Outline.Select(e => e.Clone()));
|
||||
working.AddRange(layout.Cuts.SelectMany(c => c).Select(e => e.Clone()));
|
||||
}
|
||||
return working;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="breakoff"/> against <paramref name="entities"/> (the entities that
|
||||
/// will be cut). <paramref name="otherEdgeIds"/> are joining lines of other breakoffs on the
|
||||
/// same drawing, which are ignored while finding the outline.
|
||||
/// </summary>
|
||||
public static BreakoffLayout Layout(IReadOnlyList<Entity> entities, Breakoff breakoff,
|
||||
IEnumerable<Guid> otherEdgeIds = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
ArgumentNullException.ThrowIfNull(breakoff);
|
||||
if (!(breakoff.TabWidth > 0) || !double.IsFinite(breakoff.TabWidth))
|
||||
return Fail("The tab width must be greater than zero.");
|
||||
|
||||
var edgeIds = breakoff.EdgeIds.ToHashSet();
|
||||
var edgeSources = entities.Where(e => edgeIds.Contains(e.Id)).ToList();
|
||||
if (edgeIds.Count == 0 || edgeSources.Count != edgeIds.Count)
|
||||
return Fail("Its joining line is hidden or missing.");
|
||||
|
||||
var line = BreakoffPath.ChainOpen(edgeSources, JoinTolerance);
|
||||
if (line == null)
|
||||
return Fail("Its joining line must be one open chain.");
|
||||
|
||||
// Measure tabs from a fixed end so positions survive re-chaining.
|
||||
var a = BreakoffPath.Start(line[0]);
|
||||
var b = BreakoffPath.End(line[^1]);
|
||||
if (b.X < a.X - JoinTolerance || (System.Math.Abs(b.X - a.X) <= JoinTolerance && b.Y < a.Y))
|
||||
{
|
||||
line = BreakoffPath.Reversed(line);
|
||||
(a, b) = (b, a);
|
||||
}
|
||||
|
||||
var excluded = new HashSet<Guid>(otherEdgeIds ?? Enumerable.Empty<Guid>());
|
||||
excluded.UnionWith(edgeIds);
|
||||
var sources = new Dictionary<Entity, Entity>(ReferenceEqualityComparer.Instance);
|
||||
var candidates = new List<Entity>();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (excluded.Contains(entity.Id) || IsMark(entity) || entity is not (Line or Arc))
|
||||
continue;
|
||||
var copy = BreakoffPath.Copy(entity);
|
||||
sources[copy] = entity;
|
||||
candidates.Add(copy);
|
||||
}
|
||||
|
||||
Shape outline = null;
|
||||
var outlineArea = 0.0;
|
||||
foreach (var shape in ShapeBuilder.GetShapes(WithoutDanglingEnds(candidates)))
|
||||
{
|
||||
if (shape.Entities.Count < 2 || !shape.IsClosed()
|
||||
|| shape.ClosestPointTo(a).DistanceTo(a) > JoinTolerance
|
||||
|| shape.ClosestPointTo(b).DistanceTo(b) > JoinTolerance)
|
||||
continue;
|
||||
var area = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(shape.Entities, ArcTolerance)));
|
||||
if (outline == null || area > outlineArea)
|
||||
{
|
||||
outline = shape;
|
||||
outlineArea = area;
|
||||
}
|
||||
}
|
||||
if (outline == null)
|
||||
return Fail("Both ends of the joining line must touch a closed outline.");
|
||||
|
||||
var loop = CloseExactly(outline.Entities);
|
||||
var total = BreakoffPath.Length(loop);
|
||||
var atA = BreakoffPath.DistanceOf(loop, a);
|
||||
var rotated = BreakoffPath.Sub(loop, atA, total).Concat(BreakoffPath.Sub(loop, 0, atA)).ToList();
|
||||
var atB = BreakoffPath.DistanceOf(rotated, b);
|
||||
if (atB <= JoinTolerance || atB >= total - JoinTolerance)
|
||||
return Fail("The joining line must touch the outline at two different points.");
|
||||
var toB = BreakoffPath.Sub(rotated, 0, atB);
|
||||
var backToA = BreakoffPath.Sub(rotated, atB, total);
|
||||
|
||||
// The two sides: the outline from A to B closed back along the line, and the line
|
||||
// closed back along the rest of the outline.
|
||||
var sideTo = toB.Concat(BreakoffPath.Reversed(line)).ToList();
|
||||
var sideBack = line.Concat(backToA).ToList();
|
||||
var sideToArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideTo, ArcTolerance)));
|
||||
var sideBackArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideBack, ArcTolerance)));
|
||||
if (System.Math.Abs(sideToArea + sideBackArea - outlineArea) > 0.01 * outlineArea)
|
||||
return Fail("The joining line must run inside the outline.");
|
||||
|
||||
var scrap = (sideToArea <= sideBackArea) != breakoff.Flip ? sideTo : sideBack;
|
||||
var scrapPoints = BreakoffPath.ToPoints(scrap, ArcTolerance);
|
||||
|
||||
var length = BreakoffPath.Length(line);
|
||||
var tabWidth = breakoff.TabWidth;
|
||||
var centers = breakoff.TabCenters.Count == 0
|
||||
? EvenTabCenters(length, Breakoff.DefaultTabCount, tabWidth)
|
||||
: breakoff.TabCenters.OrderBy(c => c).ToList();
|
||||
if (centers.Count < Breakoff.MinTabCount || length <= tabWidth * centers.Count)
|
||||
return Fail("The joining line is too short for its tabs.");
|
||||
if (centers[0] < tabWidth / 2 - JoinTolerance || centers[^1] > length - tabWidth / 2 + JoinTolerance)
|
||||
return Fail("A tab runs off the joining line; space the tabs evenly again.");
|
||||
for (var i = 1; i < centers.Count; i++)
|
||||
if (centers[i] - centers[i - 1] <= tabWidth + JoinTolerance)
|
||||
return Fail("Two tabs overlap; space the tabs evenly again.");
|
||||
|
||||
var side = SideOf(line, length, scrapPoints);
|
||||
if (side == 0)
|
||||
return Fail("Could not tell which side of the joining line is scrap.");
|
||||
|
||||
var cuts = new List<IReadOnlyList<Entity>>();
|
||||
for (var i = 0; i + 1 < centers.Count; i++)
|
||||
{
|
||||
var cut = BreakoffPath.Sub(line, centers[i] + tabWidth / 2, centers[i + 1] - tabWidth / 2);
|
||||
if (side < 0)
|
||||
cut = BreakoffPath.Reversed(cut);
|
||||
foreach (var e in cut)
|
||||
{
|
||||
e.Id = Guid.NewGuid();
|
||||
e.Layer = SpecialLayers.Break;
|
||||
}
|
||||
cuts.Add(cut);
|
||||
}
|
||||
|
||||
var replaced = outline.Entities.Select(e => sources[e]).ToList();
|
||||
replaced.AddRange(edgeSources);
|
||||
return new BreakoffLayout
|
||||
{
|
||||
BreakLine = line,
|
||||
Length = length,
|
||||
Outline = loop,
|
||||
Scrap = scrap,
|
||||
Cuts = cuts,
|
||||
TabCenters = centers,
|
||||
TabWidth = tabWidth,
|
||||
ReplacedEntities = replaced,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary><paramref name="count"/> tab centers spaced evenly with the first and last at the ends.</summary>
|
||||
public static List<double> EvenTabCenters(double length, int count, double tabWidth)
|
||||
{
|
||||
count = System.Math.Max(count, Breakoff.MinTabCount);
|
||||
var half = tabWidth / 2;
|
||||
var span = length - tabWidth;
|
||||
return Enumerable.Range(0, count).Select(i => half + span * i / (count - 1)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The chain of visible lines and arcs through <paramref name="picked"/>, grown until it
|
||||
/// reaches a point where it does not continue as a single path (such as where the joining
|
||||
/// line meets the outline). Empty when <paramref name="picked"/> cannot be a joining line.
|
||||
/// </summary>
|
||||
public static List<Entity> FindEdgeChain(IReadOnlyList<Entity> entities, Entity picked)
|
||||
{
|
||||
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
|
||||
.ToList();
|
||||
if (!pool.Contains(picked))
|
||||
return new List<Entity>();
|
||||
|
||||
var chain = new List<Entity> { picked };
|
||||
Grow(BreakoffPath.End(picked));
|
||||
Grow(BreakoffPath.Start(picked));
|
||||
return chain;
|
||||
|
||||
void Grow(Vector tip)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var touching = pool.Where(e => !chain.Contains(e)
|
||||
&& (BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|
||||
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance)).ToList();
|
||||
if (touching.Count != 1)
|
||||
return;
|
||||
var next = touching[0];
|
||||
chain.Add(next);
|
||||
tip = BreakoffPath.Start(next).DistanceTo(tip) <= JoinTolerance
|
||||
? BreakoffPath.End(next)
|
||||
: BreakoffPath.Start(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits each visible line or arc of <paramref name="entities"/> where another one ends on
|
||||
/// its interior (a T-junction), so a joining line can end exactly where it meets other
|
||||
/// geometry. The first piece keeps the entity's ID. Returns the IDs of every split entity's
|
||||
/// pieces, keyed by its original ID.
|
||||
/// </summary>
|
||||
public static Dictionary<Guid, List<Guid>> SplitAtJunctions(List<Entity> entities)
|
||||
{
|
||||
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
|
||||
.ToList();
|
||||
var ends = pool.SelectMany(e => new[] { BreakoffPath.Start(e), BreakoffPath.End(e) }).ToList();
|
||||
var split = new Dictionary<Guid, List<Guid>>();
|
||||
foreach (var host in pool)
|
||||
{
|
||||
var box = host.BoundingBox;
|
||||
var cuts = new List<double>();
|
||||
foreach (var point in ends)
|
||||
{
|
||||
if (point.X < box.Left - JoinTolerance || point.X > box.Right + JoinTolerance
|
||||
|| point.Y < box.Bottom - JoinTolerance || point.Y > box.Top + JoinTolerance)
|
||||
continue;
|
||||
if (point.DistanceTo(BreakoffPath.Start(host)) <= JoinTolerance
|
||||
|| point.DistanceTo(BreakoffPath.End(host)) <= JoinTolerance
|
||||
|| host.ClosestPointTo(point).DistanceTo(point) > JoinTolerance)
|
||||
continue;
|
||||
var at = BreakoffPath.DistanceOf(new[] { host }, point);
|
||||
if (cuts.All(c => System.Math.Abs(c - at) > JoinTolerance))
|
||||
cuts.Add(at);
|
||||
}
|
||||
if (cuts.Count == 0)
|
||||
continue;
|
||||
|
||||
cuts.Sort();
|
||||
var bounds = new List<double> { 0 };
|
||||
bounds.AddRange(cuts);
|
||||
bounds.Add(host.Length);
|
||||
var pieces = new List<Entity>();
|
||||
for (var i = 0; i + 1 < bounds.Count; i++)
|
||||
pieces.AddRange(BreakoffPath.Sub(new[] { host }, bounds[i], bounds[i + 1]));
|
||||
pieces[0].Id = host.Id;
|
||||
foreach (var piece in pieces)
|
||||
piece.IsVisible = host.IsVisible;
|
||||
var index = entities.IndexOf(host);
|
||||
entities.RemoveAt(index);
|
||||
entities.InsertRange(index, pieces);
|
||||
split[host.Id] = pieces.Select(p => p.Id).ToList();
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
private static BreakoffLayout Fail(string error) => new(error);
|
||||
|
||||
// A stray open line ending on the outline (an unmarked etch line, say) would otherwise be
|
||||
// chained into it and keep the loop from closing. Nothing with a free end can be part of a
|
||||
// closed outline, so such entities are dropped, repeatedly, before chaining.
|
||||
private static List<Entity> WithoutDanglingEnds(List<Entity> entities)
|
||||
{
|
||||
// Cluster endpoints that meet within the chain tolerance, using a grid of that size.
|
||||
const double cell = Tolerance.ChainTolerance;
|
||||
var grid = new Dictionary<(long, long), List<(Vector Point, int Cluster)>>();
|
||||
var clusterOf = new int[entities.Count, 2];
|
||||
var sizes = new List<int>();
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
for (var side = 0; side < 2; side++)
|
||||
{
|
||||
var point = side == 0 ? BreakoffPath.Start(entities[i]) : BreakoffPath.End(entities[i]);
|
||||
var gx = (long)System.Math.Floor(point.X / cell);
|
||||
var gy = (long)System.Math.Floor(point.Y / cell);
|
||||
var cluster = -1;
|
||||
for (var dx = -1; dx <= 1 && cluster < 0; dx++)
|
||||
for (var dy = -1; dy <= 1 && cluster < 0; dy++)
|
||||
if (grid.TryGetValue((gx + dx, gy + dy), out var near))
|
||||
foreach (var (other, id) in near)
|
||||
if (other.DistanceTo(point) <= cell)
|
||||
{
|
||||
cluster = id;
|
||||
break;
|
||||
}
|
||||
if (cluster < 0)
|
||||
{
|
||||
cluster = sizes.Count;
|
||||
sizes.Add(0);
|
||||
}
|
||||
if (!grid.TryGetValue((gx, gy), out var bucket))
|
||||
grid[(gx, gy)] = bucket = new List<(Vector, int)>();
|
||||
bucket.Add((point, cluster));
|
||||
sizes[cluster]++;
|
||||
clusterOf[i, side] = cluster;
|
||||
}
|
||||
|
||||
// Peel entities with a free end until none is left; closed loops never peel.
|
||||
var members = new List<int>[sizes.Count];
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
for (var side = 0; side < 2; side++)
|
||||
(members[clusterOf[i, side]] ??= new List<int>()).Add(i);
|
||||
var removed = new bool[entities.Count];
|
||||
var queue = new Queue<int>(Enumerable.Range(0, entities.Count)
|
||||
.Where(i => sizes[clusterOf[i, 0]] < 2 || sizes[clusterOf[i, 1]] < 2));
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var i = queue.Dequeue();
|
||||
if (removed[i])
|
||||
continue;
|
||||
removed[i] = true;
|
||||
for (var side = 0; side < 2; side++)
|
||||
{
|
||||
var cluster = clusterOf[i, side];
|
||||
if (--sizes[cluster] == 1)
|
||||
foreach (var other in members[cluster])
|
||||
if (!removed[other])
|
||||
queue.Enqueue(other);
|
||||
}
|
||||
}
|
||||
return entities.Where((_, i) => !removed[i]).ToList();
|
||||
}
|
||||
|
||||
internal static bool IsMark(Entity e) =>
|
||||
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(e.Layer?.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// The planner closes contours far tighter than the import chain tolerance, and the outline
|
||||
// is re-chained from any entity when the program is built, so every joint must be exact.
|
||||
internal static List<Entity> CloseExactly(IReadOnlyList<Entity> loop)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
for (var i = 0; i < loop.Count; i++)
|
||||
{
|
||||
var current = loop[i];
|
||||
var next = loop[(i + 1) % loop.Count];
|
||||
result.Add(current);
|
||||
var end = BreakoffPath.End(current);
|
||||
var start = BreakoffPath.Start(next);
|
||||
if (end.DistanceTo(start) == 0)
|
||||
continue;
|
||||
if (next is Line nextLine)
|
||||
nextLine.StartPoint = end;
|
||||
else if (current is Line currentLine)
|
||||
currentLine.EndPoint = start;
|
||||
else if (end.DistanceTo(start) > 1e-8)
|
||||
result.Add(new Line(end, start) { Layer = current.Layer, Color = current.Color, LineTypeName = current.LineTypeName });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Vector Normal(IReadOnlyList<Entity> path, double length, double distance)
|
||||
{
|
||||
const double nudge = 1e-6;
|
||||
var before = BreakoffPath.TangentAt(path, System.Math.Max(distance - nudge, 0));
|
||||
var after = BreakoffPath.TangentAt(path, System.Math.Min(distance + nudge, length));
|
||||
var t = new Vector(before.X + after.X, before.Y + after.Y);
|
||||
var size = System.Math.Sqrt(t.X * t.X + t.Y * t.Y);
|
||||
t = size <= Tolerance.Epsilon ? after : new Vector(t.X / size, t.Y / size);
|
||||
return new Vector(-t.Y, t.X);
|
||||
}
|
||||
|
||||
// +1 when the scrap lies left of the line's travel, -1 when right, 0 when unclear.
|
||||
private static int SideOf(IReadOnlyList<Entity> line, double length, IReadOnlyList<Vector> scrap)
|
||||
{
|
||||
var probe = System.Math.Min(0.01, length * 0.01);
|
||||
var votes = 0;
|
||||
foreach (var fraction in new[] { 0.25, 0.5, 0.75 })
|
||||
{
|
||||
var d = length * fraction;
|
||||
var p = BreakoffPath.PointAt(line, d);
|
||||
var n = Normal(line, length, d);
|
||||
var left = BreakoffPath.Contains(scrap, p + n * probe);
|
||||
var right = BreakoffPath.Contains(scrap, p - n * probe);
|
||||
if (left != right)
|
||||
votes += left ? 1 : -1;
|
||||
}
|
||||
return System.Math.Sign(votes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="Breakoff"/> resolved against a drawing's entities. When <see cref="Error"/> is
|
||||
/// set nothing else is meaningful and the breakoff is not applied.
|
||||
/// </summary>
|
||||
public sealed class BreakoffLayout
|
||||
{
|
||||
internal BreakoffLayout(string error) => Error = error;
|
||||
|
||||
internal BreakoffLayout() { }
|
||||
|
||||
public string Error { get; }
|
||||
|
||||
public bool IsValid => Error == null;
|
||||
|
||||
/// <summary>The joining line in travel order from its start.</summary>
|
||||
public IReadOnlyList<Entity> BreakLine { get; internal set; }
|
||||
|
||||
public double Length { get; internal set; }
|
||||
|
||||
/// <summary>The closed outline around part and scrap, with its joints made exact.</summary>
|
||||
public IReadOnlyList<Entity> Outline { get; internal set; }
|
||||
|
||||
/// <summary>The scrap side's closed boundary (part of the outline plus the joining line).</summary>
|
||||
public IReadOnlyList<Entity> Scrap { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// One open cut per section of the joining line between tabs, on the break layer and
|
||||
/// travel-ordered with the scrap to its left, so each is entered from the scrap.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyList<Entity>> Cuts { get; internal set; }
|
||||
|
||||
/// <summary>Tab centers used, as distances along <see cref="BreakLine"/>.</summary>
|
||||
public IReadOnlyList<double> TabCenters { get; internal set; }
|
||||
|
||||
public double TabWidth { get; internal set; }
|
||||
|
||||
/// <summary>Entities the outline and cuts replace: the original outline and joining line.</summary>
|
||||
internal IReadOnlyList<Entity> ReplacedEntities { get; set; }
|
||||
|
||||
public Vector PointAt(double distance) => BreakoffPath.PointAt(BreakLine, distance);
|
||||
|
||||
/// <summary>The distance along the joining line closest to <paramref name="point"/>.</summary>
|
||||
public double DistanceOf(Vector point) => BreakoffPath.DistanceOf(BreakLine, point);
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the
|
||||
/// joining line and keeps a gap from its neighbours.
|
||||
/// </summary>
|
||||
public double ClampTabCenter(int index, double center)
|
||||
{
|
||||
var half = TabWidth / 2;
|
||||
var min = index == 0 ? half : TabCenters[index - 1] + TabWidth + MinGap;
|
||||
var max = index == TabCenters.Count - 1 ? Length - half : TabCenters[index + 1] - TabWidth - MinGap;
|
||||
return max < min ? TabCenters[index] : System.Math.Clamp(center, min, max);
|
||||
}
|
||||
|
||||
// Wider than the builder's overlap tolerance, so a clamped drag always resolves.
|
||||
internal double MinGap => System.Math.Max(TabWidth / 2, 2 * BreakoffBuilder.JoinTolerance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// Distance-based operations on a chain of lines and arcs, each oriented in travel order
|
||||
/// (one's end is the next one's start). Splits and copies keep the source entity's style.
|
||||
/// </summary>
|
||||
internal static class BreakoffPath
|
||||
{
|
||||
public static Vector Start(Entity e) => e is Line l ? l.StartPoint : ((Arc)e).StartPoint();
|
||||
|
||||
public static Vector End(Entity e) => e is Line l ? l.EndPoint : ((Arc)e).EndPoint();
|
||||
|
||||
public static double Length(IReadOnlyList<Entity> path) => path.Sum(e => e.Length);
|
||||
|
||||
public static Vector PointAt(IReadOnlyList<Entity> path, double distance) =>
|
||||
Locate(path, distance, out var entity, out var local) ? PointOn(entity, local) : End(path[^1]);
|
||||
|
||||
public static Vector TangentAt(IReadOnlyList<Entity> path, double distance)
|
||||
{
|
||||
Locate(path, distance, out var entity, out var local);
|
||||
return TangentOn(entity, local);
|
||||
}
|
||||
|
||||
/// <summary>The distance along the path of the path point closest to <paramref name="point"/>.</summary>
|
||||
public static double DistanceOf(IReadOnlyList<Entity> path, Vector point)
|
||||
{
|
||||
var best = double.MaxValue;
|
||||
var bestDistance = 0.0;
|
||||
var walked = 0.0;
|
||||
foreach (var e in path)
|
||||
{
|
||||
var closest = e.ClosestPointTo(point);
|
||||
var gap = closest.DistanceTo(point);
|
||||
if (gap < best)
|
||||
{
|
||||
best = gap;
|
||||
bestDistance = walked + LocalDistance(e, closest);
|
||||
}
|
||||
walked += e.Length;
|
||||
}
|
||||
return bestDistance;
|
||||
}
|
||||
|
||||
/// <summary>Copies of the part of <paramref name="path"/> from <paramref name="from"/> to <paramref name="to"/>.</summary>
|
||||
public static List<Entity> Sub(IReadOnlyList<Entity> path, double from, double to)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
var walked = 0.0;
|
||||
foreach (var e in path)
|
||||
{
|
||||
var length = e.Length;
|
||||
var a = System.Math.Max(from - walked, 0);
|
||||
var b = System.Math.Min(to - walked, length);
|
||||
walked += length;
|
||||
if (b - a <= Tolerance.Epsilon)
|
||||
continue;
|
||||
result.Add(Piece(e, a, b));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Copies of <paramref name="path"/> in reverse travel order.</summary>
|
||||
public static List<Entity> Reversed(IEnumerable<Entity> path)
|
||||
{
|
||||
var result = path.Select(Copy).ToList();
|
||||
result.Reverse();
|
||||
foreach (var e in result)
|
||||
e.Reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Entity Copy(Entity e)
|
||||
{
|
||||
var copy = e.Clone();
|
||||
copy.Id = e.Id;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>Points along the path, arcs subdivided so no chord deviates more than <paramref name="tolerance"/>.</summary>
|
||||
public static List<Vector> ToPoints(IEnumerable<Entity> path, double tolerance)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
foreach (var e in path)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
points.Add(Start(e));
|
||||
if (e is Arc arc)
|
||||
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)).Skip(1));
|
||||
else
|
||||
points.Add(End(e));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/// <summary>Signed area (counterclockwise positive) of a closed point loop.</summary>
|
||||
public static double SignedArea(IReadOnlyList<Vector> loop)
|
||||
{
|
||||
var sum = 0.0;
|
||||
for (var i = 0; i < loop.Count; i++)
|
||||
{
|
||||
var a = loop[i];
|
||||
var b = loop[(i + 1) % loop.Count];
|
||||
sum += a.X * b.Y - b.X * a.Y;
|
||||
}
|
||||
return sum / 2;
|
||||
}
|
||||
|
||||
public static bool Contains(IReadOnlyList<Vector> loop, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (int i = 0, j = loop.Count - 1; i < loop.Count; j = i++)
|
||||
{
|
||||
var a = loop[i];
|
||||
var b = loop[j];
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static bool Locate(IReadOnlyList<Entity> path, double distance, out Entity entity, out double local)
|
||||
{
|
||||
var walked = 0.0;
|
||||
foreach (var e in path)
|
||||
{
|
||||
var length = e.Length;
|
||||
if (distance <= walked + length)
|
||||
{
|
||||
entity = e;
|
||||
local = System.Math.Clamp(distance - walked, 0, length);
|
||||
return true;
|
||||
}
|
||||
walked += length;
|
||||
}
|
||||
entity = path[^1];
|
||||
local = entity.Length;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Vector PointOn(Entity e, double local)
|
||||
{
|
||||
if (e is Line line)
|
||||
{
|
||||
var t = line.Length <= 0 ? 0 : local / line.Length;
|
||||
return line.StartPoint + (line.EndPoint - line.StartPoint) * t;
|
||||
}
|
||||
var arc = (Arc)e;
|
||||
var angle = ArcAngleAt(arc, local);
|
||||
return new Vector(arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
||||
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
|
||||
}
|
||||
|
||||
private static Vector TangentOn(Entity e, double local)
|
||||
{
|
||||
if (e is Line line)
|
||||
{
|
||||
var d = line.EndPoint - line.StartPoint;
|
||||
var length = line.Length;
|
||||
return length <= 0 ? new Vector(1, 0) : new Vector(d.X / length, d.Y / length);
|
||||
}
|
||||
var arc = (Arc)e;
|
||||
var angle = ArcAngleAt(arc, local);
|
||||
return arc.IsReversed
|
||||
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
||||
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
||||
}
|
||||
|
||||
private static double ArcAngleAt(Arc arc, double local) =>
|
||||
arc.StartAngle + (arc.IsReversed ? -1 : 1) * local / arc.Radius;
|
||||
|
||||
private static double LocalDistance(Entity e, Vector point)
|
||||
{
|
||||
if (e is Line line)
|
||||
return line.StartPoint.DistanceTo(point);
|
||||
var arc = (Arc)e;
|
||||
var swept = Angle.NormalizeRad(arc.IsReversed
|
||||
? arc.StartAngle - arc.Center.AngleTo(point)
|
||||
: arc.Center.AngleTo(point) - arc.StartAngle);
|
||||
var distance = swept * arc.Radius;
|
||||
// A point a hair before the start normalizes to a full turn; it belongs at an end.
|
||||
if (distance > arc.Length)
|
||||
return point.DistanceTo(arc.StartPoint()) <= point.DistanceTo(arc.EndPoint()) ? 0 : arc.Length;
|
||||
return distance;
|
||||
}
|
||||
|
||||
private static Entity Piece(Entity e, double a, double b)
|
||||
{
|
||||
Entity piece;
|
||||
if (e is Line line)
|
||||
{
|
||||
piece = new Line(PointOn(line, a), PointOn(line, b));
|
||||
}
|
||||
else
|
||||
{
|
||||
var arc = (Arc)e;
|
||||
piece = new Arc(arc.Center, arc.Radius, Angle.NormalizeRad(ArcAngleAt(arc, a)),
|
||||
Angle.NormalizeRad(ArcAngleAt(arc, b)), arc.IsReversed);
|
||||
}
|
||||
piece.Layer = e.Layer;
|
||||
piece.Color = e.Color;
|
||||
piece.LineTypeName = e.LineTypeName;
|
||||
return piece;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders <paramref name="edges"/> into one open chain, copied and oriented in travel
|
||||
/// order, or returns null when they do not form exactly one open chain.
|
||||
/// </summary>
|
||||
public static List<Entity> ChainOpen(IReadOnlyList<Entity> edges, double tolerance)
|
||||
{
|
||||
if (edges.Count == 0 || edges.Any(e => e is not (Line or Arc)))
|
||||
return null;
|
||||
|
||||
int Degree(Vector p) => edges.Count(e => Start(e).DistanceTo(p) <= tolerance)
|
||||
+ edges.Count(e => End(e).DistanceTo(p) <= tolerance);
|
||||
|
||||
var first = edges.FirstOrDefault(e => Degree(Start(e)) == 1 || Degree(End(e)) == 1);
|
||||
if (first == null)
|
||||
return null;
|
||||
|
||||
var remaining = edges.ToList();
|
||||
var chain = new List<Entity>();
|
||||
var current = Copy(first);
|
||||
if (Degree(Start(first)) != 1)
|
||||
current.Reverse();
|
||||
remaining.Remove(first);
|
||||
chain.Add(current);
|
||||
|
||||
while (remaining.Count > 0)
|
||||
{
|
||||
var tip = End(chain[^1]);
|
||||
var next = remaining.FirstOrDefault(e => Start(e).DistanceTo(tip) <= tolerance
|
||||
|| End(e).DistanceTo(tip) <= tolerance);
|
||||
if (next == null)
|
||||
return null;
|
||||
var copy = Copy(next);
|
||||
if (Start(next).DistanceTo(tip) > tolerance)
|
||||
copy.Reverse();
|
||||
remaining.Remove(next);
|
||||
chain.Add(copy);
|
||||
}
|
||||
|
||||
return Start(chain[0]).DistanceTo(End(chain[^1])) <= tolerance ? null : chain;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// Turns a plate's recorded moves into a timed timeline: numbers the contours, drops
|
||||
/// etch-only contours the post would skip, sorts contours into holes and outsides, and
|
||||
/// times each move at its feed, with acceleration when a motion profile is set.
|
||||
/// </summary>
|
||||
internal static class ContourTiming
|
||||
{
|
||||
private const double ClosedTolerance = 1e-4;
|
||||
|
||||
public static List<CutSimulationStep> Finish(List<CutSimulationStep> steps, Plate plate, CutParameters parameters)
|
||||
{
|
||||
NumberContours(steps);
|
||||
|
||||
if (parameters.SkipScribe && RemoveScribeContours(ref steps))
|
||||
NumberContours(steps);
|
||||
|
||||
ClassifyHoles(steps, plate);
|
||||
|
||||
var holeFeed = parameters.HoleFeedrate is > 0 ? parameters.HoleFeedrate.Value : parameters.Feedrate;
|
||||
foreach (var step in steps)
|
||||
{
|
||||
step.NominalSpeed = step.Kind switch
|
||||
{
|
||||
CutSimulationStepKind.Rapid => Speed(parameters.RapidTravelRate, parameters.Units),
|
||||
CutSimulationStepKind.Cut when step.IsHole => Speed(holeFeed, parameters.Units),
|
||||
CutSimulationStepKind.Pierce => 0,
|
||||
_ => Speed(parameters.Feedrate, parameters.Units),
|
||||
};
|
||||
step.Duration = step.Kind == CutSimulationStepKind.Pierce
|
||||
? parameters.PierceTime
|
||||
: step.NominalSpeed > 0 && step.Length > 0
|
||||
? TimeSpan.FromSeconds(step.Length / step.NominalSpeed)
|
||||
: TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (parameters.Motion != null)
|
||||
MotionPlanner.Plan(steps, parameters.Motion);
|
||||
|
||||
var clock = TimeSpan.Zero;
|
||||
foreach (var step in steps)
|
||||
{
|
||||
step.StartTime = clock;
|
||||
clock += step.Duration;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
// Inch rates are per minute, millimeter rates per second, as Timing.CalculateTime reads them.
|
||||
private static double Speed(double rate, Units units) =>
|
||||
rate <= 0 ? 0 : units == Units.Millimeters ? rate : rate / 60;
|
||||
|
||||
/// <summary>
|
||||
/// A contour starts where the torch goes down after travel: at a pierce, at a scribe
|
||||
/// after travel, on a new part, or where cutting switches to scribing or back.
|
||||
/// </summary>
|
||||
private static void NumberContours(List<CutSimulationStep> steps)
|
||||
{
|
||||
var contour = -1;
|
||||
CutSimulationStep previous = null;
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (step.Kind == CutSimulationStepKind.Rapid)
|
||||
{
|
||||
step.ContourIndex = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
var starts = previous == null
|
||||
|| previous.Kind == CutSimulationStepKind.Rapid
|
||||
|| step.Kind == CutSimulationStepKind.Pierce
|
||||
|| previous.PartIndex != step.PartIndex
|
||||
|| previous.Kind != CutSimulationStepKind.Pierce && previous.Kind != step.Kind;
|
||||
if (starts)
|
||||
contour++;
|
||||
step.ContourIndex = contour;
|
||||
}
|
||||
previous = step;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leaves out contours that only scribe, as the CI Fiber post does by default, along with
|
||||
/// the travel to them; travel is then rejoined around the gap. True when any were removed.
|
||||
/// </summary>
|
||||
private static bool RemoveScribeContours(ref List<CutSimulationStep> steps)
|
||||
{
|
||||
var scribeOnly = steps
|
||||
.Where(s => s.ContourIndex >= 0)
|
||||
.GroupBy(s => s.ContourIndex)
|
||||
.Where(g => g.All(s => s.Kind == CutSimulationStepKind.Scribe))
|
||||
.Select(g => g.Key)
|
||||
.ToHashSet();
|
||||
if (scribeOnly.Count == 0)
|
||||
return false;
|
||||
|
||||
var kept = new List<CutSimulationStep>();
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (!scribeOnly.Contains(step.ContourIndex))
|
||||
{
|
||||
kept.Add(step);
|
||||
continue;
|
||||
}
|
||||
// Drop the travel that led to the skipped contour.
|
||||
while (kept.Count > 0 && kept[^1].Kind == CutSimulationStepKind.Rapid)
|
||||
kept.RemoveAt(kept.Count - 1);
|
||||
}
|
||||
|
||||
var joined = new List<CutSimulationStep>(kept.Count);
|
||||
var at = Vector.Zero;
|
||||
foreach (var step in kept)
|
||||
{
|
||||
if (step.Kind == CutSimulationStepKind.Rapid)
|
||||
{
|
||||
if (at.DistanceTo(step.End) > Math.Tolerance.Epsilon)
|
||||
joined.Add(Travel(at, step.End, step.PartIndex));
|
||||
at = step.End;
|
||||
continue;
|
||||
}
|
||||
if (at.DistanceTo(step.Start) > Math.Tolerance.Epsilon)
|
||||
joined.Add(Travel(at, step.Start, step.PartIndex));
|
||||
joined.Add(step);
|
||||
at = step.End;
|
||||
}
|
||||
|
||||
steps = joined;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CutSimulationStep Travel(Vector from, Vector to, int partIndex) =>
|
||||
new()
|
||||
{
|
||||
Kind = CutSimulationStepKind.Rapid,
|
||||
PartIndex = partIndex,
|
||||
Start = from,
|
||||
End = to,
|
||||
Length = from.DistanceTo(to),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Matches the CI Fiber post's interior/exterior choice (L2/L4): a cut-off, or a closed
|
||||
/// contour wound clockwise, is exterior; everything else is a hole. Lead moves are
|
||||
/// left out of the winding, as the post samples from the lead-in's end.
|
||||
/// </summary>
|
||||
private static void ClassifyHoles(List<CutSimulationStep> steps, Plate plate)
|
||||
{
|
||||
foreach (var contour in steps.Where(s => s.ContourIndex >= 0).GroupBy(s => s.ContourIndex))
|
||||
{
|
||||
var moves = contour.Where(s => s.Kind == CutSimulationStepKind.Cut).ToList();
|
||||
if (moves.Count == 0)
|
||||
continue;
|
||||
|
||||
var partIndex = moves[0].PartIndex;
|
||||
var isCutOff = partIndex < plate.Parts.Count && plate.Parts[partIndex].BaseDrawing?.IsCutOff == true;
|
||||
var isHole = !isCutOff && !IsClosedClockwise(moves);
|
||||
foreach (var step in contour)
|
||||
step.IsHole = isHole && step.Kind != CutSimulationStepKind.Scribe;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsClosedClockwise(List<CutSimulationStep> moves)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
foreach (var move in moves.Where(m => m.Layer is not (LayerType.Leadin or LayerType.Leadout)))
|
||||
{
|
||||
if (points.Count == 0)
|
||||
points.Add(move.Start);
|
||||
if (move.IsArc)
|
||||
{
|
||||
var samples = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(move.Sweep) / (System.Math.PI / 2)));
|
||||
for (var i = 1; i < samples; i++)
|
||||
points.Add(move.PointAt((double)i / samples));
|
||||
}
|
||||
points.Add(move.End);
|
||||
}
|
||||
|
||||
if (points.Count < 3 || points[0].DistanceTo(points[^1]) > ClosedTolerance)
|
||||
return false;
|
||||
|
||||
var area = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
area += points[i].X * points[i + 1].Y - points[i + 1].X * points[i].Y;
|
||||
return area < 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum CutSimulationStepKind
|
||||
{
|
||||
/// <summary>Head-up travel: a rapid, a gap between programs, or an uncut (display/tab) move.</summary>
|
||||
Rapid,
|
||||
|
||||
/// <summary>The dwell before a cut that starts after travel.</summary>
|
||||
Pierce,
|
||||
|
||||
/// <summary>Torch-on motion through material (cut, lead-in/out, break).</summary>
|
||||
Cut,
|
||||
|
||||
/// <summary>Marking motion that does not cut through.</summary>
|
||||
Scribe,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One timed tool motion in plate coordinates. Arcs carry a signed sweep
|
||||
/// (positive counterclockwise); lines and pierces leave it zero.
|
||||
/// </summary>
|
||||
public sealed class CutSimulationStep
|
||||
{
|
||||
public CutSimulationStepKind Kind { get; init; }
|
||||
public int PartIndex { get; init; }
|
||||
public Vector Start { get; init; }
|
||||
public Vector End { get; init; }
|
||||
public bool IsArc { get; init; }
|
||||
public Vector Center { get; init; }
|
||||
public double Radius { get; init; }
|
||||
public double StartAngle { get; init; }
|
||||
public double Sweep { get; init; }
|
||||
public double Length { get; init; }
|
||||
|
||||
/// <summary>The program layer of a cutting or scribing move.</summary>
|
||||
public LayerType Layer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The contour this move or pierce belongs to, numbered in cutting order; -1 for travel.
|
||||
/// </summary>
|
||||
public int ContourIndex { get; internal set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// True when the move is part of an interior contour, timed at the hole feed. Follows the
|
||||
/// CI Fiber post's rule: only a closed clockwise contour (or a cut-off) is exterior.
|
||||
/// </summary>
|
||||
public bool IsHole { get; internal set; }
|
||||
|
||||
/// <summary>Programmed speed in units per second, before acceleration.</summary>
|
||||
public double NominalSpeed { get; internal set; }
|
||||
|
||||
public TimeSpan StartTime { get; internal set; }
|
||||
public TimeSpan Duration { get; internal set; }
|
||||
public TimeSpan EndTime => StartTime + Duration;
|
||||
|
||||
/// <summary>
|
||||
/// Planned speed profile in units per second: accelerate from <see cref="EntrySpeed"/>
|
||||
/// to <see cref="CruiseSpeed"/>, then decelerate to <see cref="ExitSpeed"/>. A zero
|
||||
/// <see cref="Acceleration"/> moves at constant speed for the whole step.
|
||||
/// </summary>
|
||||
public double EntrySpeed { get; internal set; }
|
||||
public double CruiseSpeed { get; internal set; }
|
||||
public double ExitSpeed { get; internal set; }
|
||||
public double Acceleration { get; internal set; }
|
||||
|
||||
/// <summary>Fraction (0..1) of this step's distance covered <paramref name="elapsed"/> into it.</summary>
|
||||
public double ProgressAt(TimeSpan elapsed)
|
||||
{
|
||||
if (Duration <= TimeSpan.Zero)
|
||||
return elapsed >= TimeSpan.Zero ? 1 : 0;
|
||||
if (Acceleration <= 0 || Length <= 0)
|
||||
return System.Math.Clamp(elapsed / Duration, 0, 1);
|
||||
return System.Math.Clamp(DistanceAt(elapsed.TotalSeconds) / Length, 0, 1);
|
||||
}
|
||||
|
||||
/// <summary>Head speed in units per second <paramref name="elapsed"/> into this step.</summary>
|
||||
public double SpeedAt(TimeSpan elapsed)
|
||||
{
|
||||
if (Kind == CutSimulationStepKind.Pierce || Duration <= TimeSpan.Zero)
|
||||
return 0;
|
||||
if (Acceleration <= 0)
|
||||
return Length / Duration.TotalSeconds;
|
||||
|
||||
var t = System.Math.Clamp(elapsed.TotalSeconds, 0, Duration.TotalSeconds);
|
||||
var (accelEnd, cruiseEnd) = PhaseEnds();
|
||||
if (t < accelEnd)
|
||||
return EntrySpeed + Acceleration * t;
|
||||
if (t < cruiseEnd)
|
||||
return CruiseSpeed;
|
||||
return System.Math.Max(0, CruiseSpeed - Acceleration * (t - cruiseEnd));
|
||||
}
|
||||
|
||||
private (double AccelEnd, double CruiseEnd) PhaseEnds()
|
||||
{
|
||||
var accelTime = (CruiseSpeed - EntrySpeed) / Acceleration;
|
||||
var accelDistance = (CruiseSpeed * CruiseSpeed - EntrySpeed * EntrySpeed) / (2 * Acceleration);
|
||||
var decelDistance = (CruiseSpeed * CruiseSpeed - ExitSpeed * ExitSpeed) / (2 * Acceleration);
|
||||
var cruise = CruiseSpeed > 0 ? System.Math.Max(0, Length - accelDistance - decelDistance) / CruiseSpeed : 0;
|
||||
return (accelTime, accelTime + cruise);
|
||||
}
|
||||
|
||||
private double DistanceAt(double t)
|
||||
{
|
||||
var (accelEnd, cruiseEnd) = PhaseEnds();
|
||||
var accelDistance = EntrySpeed * accelEnd + Acceleration * accelEnd * accelEnd / 2;
|
||||
if (t < accelEnd)
|
||||
return EntrySpeed * t + Acceleration * t * t / 2;
|
||||
if (t < cruiseEnd)
|
||||
return accelDistance + CruiseSpeed * (t - accelEnd);
|
||||
var decel = t - cruiseEnd;
|
||||
return accelDistance + CruiseSpeed * (cruiseEnd - accelEnd) + CruiseSpeed * decel - Acceleration * decel * decel / 2;
|
||||
}
|
||||
|
||||
/// <summary>Unit direction of travel at the start (0) or end (1) of the step.</summary>
|
||||
internal Vector TangentAt(double fraction)
|
||||
{
|
||||
if (IsArc)
|
||||
{
|
||||
var angle = StartAngle + Sweep * fraction;
|
||||
var sign = System.Math.Sign(Sweep);
|
||||
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
|
||||
}
|
||||
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
|
||||
}
|
||||
|
||||
/// <summary>The tool position after <paramref name="fraction"/> (0..1) of this step.</summary>
|
||||
public Vector PointAt(double fraction)
|
||||
{
|
||||
fraction = System.Math.Clamp(fraction, 0, 1);
|
||||
|
||||
if (IsArc)
|
||||
{
|
||||
var angle = StartAngle + Sweep * fraction;
|
||||
return new Vector(
|
||||
Center.X + Radius * System.Math.Cos(angle),
|
||||
Center.Y + Radius * System.Math.Sin(angle));
|
||||
}
|
||||
|
||||
return new Vector(
|
||||
Start.X + (End.X - Start.X) * fraction,
|
||||
Start.Y + (End.Y - Start.Y) * fraction);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A replayable timeline of a plate's tool motion in cutting order. Cut and display-layer
|
||||
/// moves cut (as the posts emit them), at the hole or outside feed. Suppressed moves and,
|
||||
/// with <see cref="CutParameters.SkipScribe"/>, etch-only contours are left out. Adds
|
||||
/// <see cref="CutParameters.Motion"/> acceleration when set (see <see cref="MotionPlanner"/>).
|
||||
/// It does not model jerk, controller feed tables or clamp removal. See docs/cut-time-estimates.md.
|
||||
/// </summary>
|
||||
public sealed class CutSimulation
|
||||
{
|
||||
private readonly List<CutSimulationStep> steps;
|
||||
private readonly List<TimeSpan> contourStarts = new();
|
||||
private readonly List<TimeSpan> partStarts = new();
|
||||
|
||||
private CutSimulation(List<CutSimulationStep> steps, int partCount)
|
||||
{
|
||||
this.steps = steps;
|
||||
PartCount = partCount;
|
||||
TotalTime = steps.Count == 0 ? TimeSpan.Zero : steps[^1].EndTime;
|
||||
|
||||
// A contour (a cutout or a part's perimeter) begins where the torch goes down after travel.
|
||||
var lastPart = -1;
|
||||
var lastContour = -1;
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (step.ContourIndex < 0 || step.ContourIndex == lastContour)
|
||||
continue;
|
||||
lastContour = step.ContourIndex;
|
||||
|
||||
contourStarts.Add(step.StartTime);
|
||||
if (step.PartIndex != lastPart)
|
||||
{
|
||||
partStarts.Add(step.StartTime);
|
||||
lastPart = step.PartIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<CutSimulationStep> Steps => steps;
|
||||
public int PartCount { get; }
|
||||
public TimeSpan TotalTime { get; }
|
||||
|
||||
/// <summary>When each contour's first cut (or its pierce) starts, in cutting order.</summary>
|
||||
public IReadOnlyList<TimeSpan> ContourStarts => contourStarts;
|
||||
|
||||
/// <summary>When each part's first contour starts, in cutting order.</summary>
|
||||
public IReadOnlyList<TimeSpan> PartStarts => partStarts;
|
||||
|
||||
public TimeSpan NextContour(TimeSpan time) => Next(contourStarts, time);
|
||||
public TimeSpan PreviousContour(TimeSpan time) => Previous(contourStarts, time);
|
||||
public TimeSpan NextPart(TimeSpan time) => Next(partStarts, time);
|
||||
public TimeSpan PreviousPart(TimeSpan time) => Previous(partStarts, time);
|
||||
|
||||
// The first start after the playhead, or the end once none is left.
|
||||
private TimeSpan Next(List<TimeSpan> starts, TimeSpan time)
|
||||
{
|
||||
foreach (var start in starts)
|
||||
{
|
||||
if (start > time)
|
||||
return start;
|
||||
}
|
||||
return TotalTime;
|
||||
}
|
||||
|
||||
// Like a track's previous button: back to the start of the current item, or the one
|
||||
// before when the playhead is already there.
|
||||
private static TimeSpan Previous(List<TimeSpan> starts, TimeSpan time)
|
||||
{
|
||||
for (var i = starts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (starts[i] < time)
|
||||
return starts[i];
|
||||
}
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
public static CutSimulation Build(Plate plate, CutParameters parameters)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
ArgumentNullException.ThrowIfNull(parameters);
|
||||
|
||||
var builder = new Builder();
|
||||
|
||||
for (var i = 0; i < plate.Parts.Count; i++)
|
||||
{
|
||||
var part = plate.Parts[i];
|
||||
if (part.Program == null)
|
||||
continue;
|
||||
|
||||
builder.PartIndex = i;
|
||||
var mode = Mode.Absolute;
|
||||
var pos = part.Location;
|
||||
builder.AddProgram(part.Program, part.Location, ref mode, ref pos);
|
||||
}
|
||||
|
||||
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
|
||||
return new CutSimulation(steps, plate.Parts.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index of the step running at <paramref name="time"/>; <see cref="Steps"/>.Count
|
||||
/// once the timeline has finished, -1 when it is empty.
|
||||
/// </summary>
|
||||
public int IndexAt(TimeSpan time)
|
||||
{
|
||||
if (steps.Count == 0)
|
||||
return -1;
|
||||
if (time >= TotalTime)
|
||||
return steps.Count;
|
||||
|
||||
int lo = 0, hi = steps.Count - 1;
|
||||
while (lo < hi)
|
||||
{
|
||||
var mid = (lo + hi + 1) / 2;
|
||||
if (steps[mid].StartTime <= time)
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fraction (0..1) of step <paramref name="index"/> completed at <paramref name="time"/>:
|
||||
/// distance for motion, elapsed time for a pierce.
|
||||
/// </summary>
|
||||
public double FractionAt(int index, TimeSpan time)
|
||||
{
|
||||
var step = steps[index];
|
||||
if (step.Kind == CutSimulationStepKind.Pierce)
|
||||
return step.Duration <= TimeSpan.Zero
|
||||
? time >= step.StartTime ? 1 : 0
|
||||
: System.Math.Clamp((time - step.StartTime) / step.Duration, 0, 1);
|
||||
return step.ProgressAt(time - step.StartTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When step <paramref name="index"/> is <paramref name="fraction"/> complete: the inverse
|
||||
/// of <see cref="FractionAt"/>, so a retimed timeline can find the same tool position.
|
||||
/// </summary>
|
||||
public TimeSpan TimeAt(int index, double fraction)
|
||||
{
|
||||
var step = steps[index];
|
||||
if (step.Duration <= TimeSpan.Zero || fraction <= 0)
|
||||
return step.StartTime;
|
||||
if (fraction >= 1)
|
||||
return step.EndTime;
|
||||
|
||||
// Progress never decreases through a step, so bisect its duration.
|
||||
long lo = 0, hi = step.Duration.Ticks;
|
||||
while (lo < hi)
|
||||
{
|
||||
var mid = lo + (hi - lo) / 2;
|
||||
if (step.ProgressAt(TimeSpan.FromTicks(mid)) < fraction)
|
||||
lo = mid + 1;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
return step.StartTime + TimeSpan.FromTicks(lo);
|
||||
}
|
||||
|
||||
public Vector ToolPositionAt(TimeSpan time)
|
||||
{
|
||||
var index = IndexAt(time);
|
||||
if (index < 0)
|
||||
return Vector.Zero;
|
||||
if (index >= steps.Count)
|
||||
return steps[^1].End;
|
||||
return steps[index].PointAt(FractionAt(index, time));
|
||||
}
|
||||
|
||||
private sealed class Builder
|
||||
{
|
||||
private Vector tool = Vector.Zero;
|
||||
private bool torchOn;
|
||||
|
||||
|
||||
public List<CutSimulationStep> Steps { get; } = new();
|
||||
public int PartIndex { get; set; }
|
||||
|
||||
// Sub-program offsets are relative to the calling frame's origin, as ConvertProgram reads them.
|
||||
public void AddProgram(Program program, Vector frameOrigin, ref Mode mode, ref Vector pos)
|
||||
{
|
||||
mode = program.Mode;
|
||||
|
||||
for (var i = 0; i < program.Length; i++)
|
||||
{
|
||||
switch (program[i])
|
||||
{
|
||||
case SubProgramCall { Program: { } sub } call:
|
||||
var savedMode = mode;
|
||||
var subOrigin = frameOrigin + call.Offset;
|
||||
pos = subOrigin;
|
||||
AddProgram(sub, subOrigin, ref mode, ref pos);
|
||||
mode = savedMode;
|
||||
break;
|
||||
|
||||
case RapidMove rapid:
|
||||
var rapidEnd = Resolve(rapid.EndPoint, mode, pos, frameOrigin);
|
||||
AddTravel(tool, rapidEnd);
|
||||
torchOn = false; // A rapid in place still ends the cut.
|
||||
pos = rapidEnd;
|
||||
break;
|
||||
|
||||
case Motion { Suppressed: true } suppressed:
|
||||
// Posts leave suppressed moves out; the next move starts from here.
|
||||
pos = Resolve(suppressed.EndPoint, mode, pos, frameOrigin);
|
||||
break;
|
||||
|
||||
case LinearMove linear:
|
||||
var lineEnd = Resolve(linear.EndPoint, mode, pos, frameOrigin);
|
||||
AddMotion(linear.Layer, pos, lineEnd, isArc: false, default, default);
|
||||
pos = lineEnd;
|
||||
break;
|
||||
|
||||
case ArcMove arc:
|
||||
var arcEnd = Resolve(arc.EndPoint, mode, pos, frameOrigin);
|
||||
var center = Resolve(arc.CenterPoint, mode, pos, frameOrigin);
|
||||
AddMotion(arc.Layer, pos, arcEnd, isArc: true, center, arc.Rotation);
|
||||
pos = arcEnd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector Resolve(Vector point, Mode mode, Vector pos, Vector frameOrigin) =>
|
||||
mode == Mode.Incremental ? point + pos : point + frameOrigin;
|
||||
|
||||
private static CutSimulationStepKind? KindOf(LayerType layer) =>
|
||||
layer switch
|
||||
{
|
||||
// Cutting strategies emit contour moves on Display; the posts cut them.
|
||||
LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break
|
||||
=> CutSimulationStepKind.Cut,
|
||||
LayerType.Scribe => CutSimulationStepKind.Scribe,
|
||||
_ => null, // Tab spans are never cut.
|
||||
};
|
||||
|
||||
private void AddMotion(LayerType layer, Vector start, Vector end, bool isArc, Vector center, RotationType rotation)
|
||||
{
|
||||
if (KindOf(layer) is not { } kind)
|
||||
{
|
||||
AddTravel(tool, end);
|
||||
return;
|
||||
}
|
||||
|
||||
// A program that begins cutting away from the tool reaches its start head-up.
|
||||
AddTravel(tool, start);
|
||||
|
||||
if (!torchOn && kind == CutSimulationStepKind.Cut)
|
||||
{
|
||||
Add(new CutSimulationStep
|
||||
{
|
||||
Kind = CutSimulationStepKind.Pierce,
|
||||
PartIndex = PartIndex,
|
||||
Start = start,
|
||||
End = start,
|
||||
});
|
||||
}
|
||||
|
||||
torchOn = kind == CutSimulationStepKind.Cut;
|
||||
|
||||
if (isArc)
|
||||
AddArc(kind, layer, start, end, center, rotation);
|
||||
else
|
||||
Add(new CutSimulationStep
|
||||
{
|
||||
Kind = kind,
|
||||
Layer = layer,
|
||||
PartIndex = PartIndex,
|
||||
Start = start,
|
||||
End = end,
|
||||
Length = start.DistanceTo(end),
|
||||
});
|
||||
}
|
||||
|
||||
private void AddArc(CutSimulationStepKind kind, LayerType layer, Vector start, Vector end, Vector center, RotationType rotation)
|
||||
{
|
||||
var radius = center.DistanceTo(start);
|
||||
var startAngle = center.AngleTo(start);
|
||||
var endAngle = center.AngleTo(end);
|
||||
var ccw = rotation == RotationType.CCW;
|
||||
|
||||
// Coincident ends are a full circle, as ConvertProgram reads them.
|
||||
var sweep = ccw
|
||||
? Angle.NormalizeRad(endAngle - startAngle)
|
||||
: -Angle.NormalizeRad(startAngle - endAngle);
|
||||
if (System.Math.Abs(sweep) < Tolerance.Epsilon)
|
||||
sweep = ccw ? Angle.TwoPI : -Angle.TwoPI;
|
||||
|
||||
var length = System.Math.Abs(sweep) * radius;
|
||||
Add(new CutSimulationStep
|
||||
{
|
||||
Kind = kind,
|
||||
Layer = layer,
|
||||
PartIndex = PartIndex,
|
||||
Start = start,
|
||||
End = end,
|
||||
IsArc = true,
|
||||
Center = center,
|
||||
Radius = radius,
|
||||
StartAngle = startAngle,
|
||||
Sweep = sweep,
|
||||
Length = length,
|
||||
});
|
||||
}
|
||||
|
||||
private void AddTravel(Vector start, Vector end)
|
||||
{
|
||||
var distance = start.DistanceTo(end);
|
||||
if (distance <= Tolerance.Epsilon)
|
||||
{
|
||||
tool = end;
|
||||
return;
|
||||
}
|
||||
|
||||
torchOn = false;
|
||||
|
||||
Add(new CutSimulationStep
|
||||
{
|
||||
Kind = CutSimulationStepKind.Rapid,
|
||||
PartIndex = PartIndex,
|
||||
Start = start,
|
||||
End = end,
|
||||
Length = distance,
|
||||
});
|
||||
}
|
||||
|
||||
private void Add(CutSimulationStep step)
|
||||
{
|
||||
tool = step.End;
|
||||
Steps.Add(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Which motions of an execution cut a break line: its lead-in and its break moves.</summary>
|
||||
internal static class BreakCutMotions
|
||||
{
|
||||
/// <summary>True for every <see cref="LayerType.Break"/> motion and the lead-in run directly before one.</summary>
|
||||
internal static bool[] Mask(IReadOnlyList<ExecutionMotion> motions)
|
||||
{
|
||||
var mask = new bool[motions.Count];
|
||||
for (var i = 0; i < motions.Count; i++)
|
||||
{
|
||||
if (motions[i].Rapid || motions[i].Layer != LayerType.Break)
|
||||
continue;
|
||||
mask[i] = true;
|
||||
for (var lead = i - 1; lead >= 0 && !motions[lead].Rapid && motions[lead].Layer == LayerType.Leadin; lead--)
|
||||
mask[lead] = true;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ internal enum AutomaticEntryKind
|
||||
|
||||
/// <summary>Tier 3 (fallback): one of the eight compass points of a whole circle.</summary>
|
||||
CircleCompass = 6,
|
||||
|
||||
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
|
||||
Pinned = 7,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -47,6 +47,7 @@ internal static class ContourProgramVerifier
|
||||
if (material == null || !material.IsComplete) return false;
|
||||
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
|
||||
var visited = new HashSet<int>();
|
||||
var piecesCut = new Dictionary<int, HashSet<int>>();
|
||||
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -55,11 +56,13 @@ internal static class ContourProgramVerifier
|
||||
var ringIndex = -1;
|
||||
var entityIndex = -1;
|
||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||
{
|
||||
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first) && curve.Contains(first.Start)
|
||||
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
ringIndex = r;
|
||||
@@ -67,28 +70,71 @@ internal static class ContourProgramVerifier
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
|
||||
}
|
||||
if (ringIndex < 0) return false;
|
||||
var tabs = material.Tabs[ringIndex];
|
||||
if (tabs.Any(tab => tab))
|
||||
{
|
||||
// A tabbed cutout: each run cuts exactly one span between tabs, whole and once.
|
||||
if (!CutsTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget)
|
||||
|| !(piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(entityIndex))
|
||||
return false;
|
||||
if (piecesCut[ringIndex].Count == SpanStarts(tabs).Count)
|
||||
visited.Add(ringIndex);
|
||||
continue;
|
||||
}
|
||||
if (!visited.Add(ringIndex)) return false;
|
||||
var ring = material.Rings[ringIndex];
|
||||
// Capture admits a nominal ring that closes within NominalContourClosure.Tolerance, and
|
||||
// preparation agrees at that inclusive boundary. A rotated tool path bridges that one
|
||||
// admitted joint, so this ring's positional and length agreement carries exactly that
|
||||
// admitted slack, whether bridging it shortens or lengthens the cut.
|
||||
var tolerance = AdmittedClosureSlack(ring);
|
||||
var nominalLength = ring.Sum(c => c.Length);
|
||||
var cutLength = run.Curves.Sum(c => c.Length);
|
||||
var missing = nominalLength - cutLength;
|
||||
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
|
||||
if (missing > Epsilon)
|
||||
// Bridging the admitted joint may add up to its gap; a second circuit/retrace never passes.
|
||||
if (missing < -tolerance) return false;
|
||||
if (missing > tolerance)
|
||||
{
|
||||
// Fixed legacy payloads have no certified selected tab/entry metadata.
|
||||
// A merely open contour or a caller's current tab switch proves nothing.
|
||||
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|
||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon)
|
||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > tolerance)
|
||||
return false;
|
||||
// SameEmission has already bound this exact terminal gap to the owned
|
||||
// settings' independently emitted, rounded/clamped selected geometry.
|
||||
}
|
||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||
true, token, ref budget)) return false;
|
||||
true, tolerance, token, ref budget)) return false;
|
||||
}
|
||||
return visited.Count == material.Rings.Count;
|
||||
}
|
||||
|
||||
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
|
||||
private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length)
|
||||
.Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList();
|
||||
|
||||
// A run that starts exactly where a span between tabs starts and covers that span exactly,
|
||||
// stopping at the next tab.
|
||||
private static bool CutsTabbedSpan(IReadOnlyList<Curve> run, IReadOnlyList<Curve> ring, bool[] tabs,
|
||||
int entityIndex, CancellationToken token, ref int budget)
|
||||
{
|
||||
var tolerance = AdmittedClosureSlack(ring);
|
||||
if (!SpanStarts(tabs).Contains(entityIndex) || ring[entityIndex].DistanceAlong(run[0].Start) > tolerance)
|
||||
return false;
|
||||
var span = new List<Curve>();
|
||||
for (var e = entityIndex; !tabs[e % tabs.Length]; e++)
|
||||
span.Add(ring[e % tabs.Length]);
|
||||
return System.Math.Abs(run.Sum(c => c.Length) - span.Sum(c => c.Length)) <= tolerance
|
||||
&& Follows(run, span, 0, 0, false, tolerance, token, ref budget);
|
||||
}
|
||||
|
||||
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
|
||||
// tolerance, and only that admitted endpoint gap relaxes replay's positional agreement.
|
||||
private static double AdmittedClosureSlack(IReadOnlyList<Curve> ring) => ring.Count == 0 ? Epsilon
|
||||
: Epsilon + System.Math.Max(0.0, ring[^1].End.DistanceTo(ring[0].Start));
|
||||
|
||||
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
|
||||
CancellationToken token, ref int budget)
|
||||
{
|
||||
@@ -107,20 +153,24 @@ internal static class ContourProgramVerifier
|
||||
if (a.End.DistanceTo(b.End) > Epsilon) return false;
|
||||
}
|
||||
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|
||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false;
|
||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, Epsilon, token, ref budget)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consume directed native arc-length from both streams. Subdivision, merged
|
||||
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
||||
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
|
||||
// unless the nominal ring closes only within the nominal closure tolerance).
|
||||
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
||||
int index, double offset, bool cyclic, CancellationToken token, ref int budget)
|
||||
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
|
||||
{
|
||||
foreach (var curve in actual)
|
||||
{
|
||||
var consumed = 0.0;
|
||||
while (consumed < curve.Length - Epsilon)
|
||||
// Like the nominal side below, a remainder within the slack is consumed: a path that
|
||||
// bridges the admitted joint leaves exactly that much on the edge it extends.
|
||||
while (consumed < curve.Length - tolerance)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
if (index >= expected.Count)
|
||||
@@ -137,13 +187,13 @@ internal static class ContourProgramVerifier
|
||||
continue;
|
||||
}
|
||||
var length = System.Math.Min(remaining, curve.Length - consumed);
|
||||
if (!nominal.SameDirection(curve)
|
||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
|
||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
|
||||
if (!nominal.SameDirection(curve, tolerance)
|
||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > tolerance
|
||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > tolerance)
|
||||
return false;
|
||||
consumed += length;
|
||||
offset += length;
|
||||
if (offset >= nominal.Length - Epsilon)
|
||||
if (offset >= nominal.Length - tolerance)
|
||||
{
|
||||
index++;
|
||||
offset = 0;
|
||||
@@ -151,7 +201,7 @@ internal static class ContourProgramVerifier
|
||||
}
|
||||
}
|
||||
return cyclic || index == expected.Count || index == expected.Count - 1
|
||||
&& expected[index].Length - offset <= Epsilon;
|
||||
&& expected[index].Length - offset <= tolerance;
|
||||
}
|
||||
|
||||
private static List<Run> Runs(OwnedExecution execution)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
public enum CutPinStatus
|
||||
{
|
||||
/// <summary>The pin lies on exactly one contour, which no other pin claims.</summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>The pin no longer lies on any contour of the drawing.</summary>
|
||||
NotOnContour,
|
||||
|
||||
/// <summary>The pin lies on more than one contour, so which one it pins is unknown.</summary>
|
||||
OnSeveralContours,
|
||||
|
||||
/// <summary>Another pin lies on the same contour; neither is used.</summary>
|
||||
ContourPinnedTwice,
|
||||
|
||||
/// <summary>The drawing's program cannot be prepared, or is not in the frame its source offset describes.</summary>
|
||||
UnsupportedGeometry,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where one drawing pin lands on a placed contour. <see cref="Point"/> is in the placed
|
||||
/// part's local frame (add the part's location for plate coordinates): the exact native point
|
||||
/// when resolved, otherwise where the pin maps to (<see cref="Vector.Invalid"/> when the drawing
|
||||
/// frame is unknown). Ordinals match <see cref="PreparedContours"/> captured from the same
|
||||
/// rotated clean program.
|
||||
/// </summary>
|
||||
public sealed record CutPinResolution(int PinIndex, ContourCutPin Pin, CutPinStatus Status, int ContourOrdinal,
|
||||
int EntityOrdinal, Vector Point)
|
||||
{
|
||||
public bool IsResolved => Status == CutPinStatus.Resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a drawing's source-frame pins onto its program contours at a placement rotation. A
|
||||
/// pin never resolves by guessing: it must lie within the contour closure tolerance of exactly
|
||||
/// one contour that no other pin claims, or it is reported unresolved with the reason.
|
||||
/// </summary>
|
||||
public static class CutPinResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the drawing's pins for a part rotated by <paramref name="rotation"/> radians
|
||||
/// about the drawing program's origin, as <see cref="Part.Rotate(double)"/> does.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<CutPinResolution> Resolve(Drawing drawing, double rotation, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawing);
|
||||
if (!double.IsFinite(rotation))
|
||||
throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite.");
|
||||
var pins = drawing.CutPins ?? new List<ContourCutPin>();
|
||||
if (pins.Count == 0)
|
||||
return Array.Empty<CutPinResolution>();
|
||||
if (drawing.Program == null || !IsSourceFrame(drawing))
|
||||
return Unsupported(pins);
|
||||
|
||||
var program = (Program)drawing.Program.Clone();
|
||||
if (!Math.Tolerance.IsEqualTo(rotation, 0))
|
||||
program.Rotate(rotation);
|
||||
|
||||
PreparedContours contours;
|
||||
try
|
||||
{
|
||||
contours = PreparedContours.Capture(program, new CuttingParameters(), token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException)
|
||||
{
|
||||
return Unsupported(pins);
|
||||
}
|
||||
|
||||
return Resolve(drawing, rotation, contours, token);
|
||||
}
|
||||
|
||||
/// <summary>Resolves the pins of a placed part at its current rotation.</summary>
|
||||
public static IReadOnlyList<CutPinResolution> Resolve(Part part, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(part);
|
||||
return Resolve(part.BaseDrawing, part.Rotation, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves against contours the caller already captured. <paramref name="contours"/> must
|
||||
/// come from the drawing's clean program rotated by <paramref name="rotation"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<CutPinResolution> Resolve(Drawing drawing, double rotation, PreparedContours contours,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawing);
|
||||
ArgumentNullException.ThrowIfNull(contours);
|
||||
if (!double.IsFinite(rotation))
|
||||
throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite.");
|
||||
var pins = drawing.CutPins ?? new List<ContourCutPin>();
|
||||
if (pins.Count == 0)
|
||||
return Array.Empty<CutPinResolution>();
|
||||
if (!IsSourceFrame(drawing))
|
||||
return Unsupported(pins);
|
||||
|
||||
var offset = drawing.Source?.Offset ?? Vector.Zero;
|
||||
var results = new CutPinResolution[pins.Count];
|
||||
for (var i = 0; i < pins.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var pin = pins[i] ?? throw new ArgumentException($"Drawing pin {i + 1} is missing.", nameof(drawing));
|
||||
var local = (pin.Point - offset).Rotate(rotation);
|
||||
var near = contours.ContoursNear(local, NominalContourClosure.Tolerance);
|
||||
results[i] = near.Count switch
|
||||
{
|
||||
0 => new(i, pin, CutPinStatus.NotOnContour, -1, -1, local),
|
||||
1 => new(i, pin, CutPinStatus.Resolved, near[0].ContourOrdinal, near[0].EntityOrdinal, near[0].Point),
|
||||
_ => new(i, pin, CutPinStatus.OnSeveralContours, -1, -1, local),
|
||||
};
|
||||
}
|
||||
|
||||
// At most one pin per contour. Choosing between two would be a guess, so neither is used.
|
||||
foreach (var claimed in results.Where(r => r.IsResolved).GroupBy(r => r.ContourOrdinal).Where(g => g.Count() > 1))
|
||||
foreach (var result in claimed)
|
||||
results[result.PinIndex] = result with { Status = CutPinStatus.ContourPinnedTwice };
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Pins are stored relative to the source entities; the program is that frame moved by
|
||||
// -Source.Offset and never rotated in place. A rotated program (a canonical copy) is not.
|
||||
private static bool IsSourceFrame(Drawing drawing) =>
|
||||
drawing.Program != null && Math.Tolerance.IsEqualTo(drawing.Program.Rotation, 0);
|
||||
|
||||
private static CutPinResolution[] Unsupported(List<ContourCutPin> pins) =>
|
||||
pins.Select((pin, i) => new CutPinResolution(i, pin, CutPinStatus.UnsupportedGeometry, -1, -1, Vector.Invalid))
|
||||
.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-post review of fixed emissions. Plan Cutting accepts a locked program's leads and a
|
||||
/// drawing-pinned contour's leads as given and warns (D1a), so posting checks them again against
|
||||
/// every part's clean material on the plate and the active clamps. It also reports a part with
|
||||
/// active drawing pins whose program does not cut its pinned contours as pinned. Reports only;
|
||||
/// nothing is repaired and an incomplete check is never a clear result.
|
||||
/// </summary>
|
||||
internal static class FixedLeadReview
|
||||
{
|
||||
/// <param name="fixedMotions">Receives, per reviewed part index, which of its placed program's
|
||||
/// motions this review owns, so other lead checks do not report them twice.</param>
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
IReadOnlyList<ClampZone> clamps, CancellationToken token,
|
||||
IDictionary<int, Func<int, bool>> fixedMotions = null)
|
||||
{
|
||||
var parts = plate.Parts;
|
||||
var reviewed = Enumerable.Range(0, parts.Count)
|
||||
.Where(i => parts[i] is { BaseDrawing.IsCutOff: false } part
|
||||
&& (part.LeadInsLocked || part.HasActiveDrawingPins)).ToArray();
|
||||
if (reviewed.Length == 0)
|
||||
return;
|
||||
var materials = new LeadMaterialSnapshot[parts.Count];
|
||||
var uncaptured = new List<int>();
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Scribe-only drawings are marks, not material.
|
||||
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
|
||||
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display))
|
||||
continue;
|
||||
materials[index] = LeadMaterialSnapshot.Capture(Clean(part), part.Location, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
// The overlap and rapid checks already report unreadable programs.
|
||||
}
|
||||
if (materials[index] is not { IsComplete: true })
|
||||
uncaptured.Add(index);
|
||||
}
|
||||
var others = materials.Select((material, index) => (Material: material, Index: index))
|
||||
.Where(m => m.Material is { IsComplete: true }).ToArray();
|
||||
// Leads near material that could not be captured are not checked: say so once per plate.
|
||||
foreach (var index in uncaptured)
|
||||
findings.Add(Incomplete(index, (materials[index]?.Reason ?? "the clean material could not be captured.")
|
||||
+ " Fixed leads near this part are not checked."));
|
||||
foreach (var index in reviewed)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
execution = ExecutionMotionReader.Read(part.Program, part.Location, null, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
continue; // The rapid check reports the unreadable program.
|
||||
}
|
||||
// Which motions are fixed: a locked program's every motion; otherwise the motions
|
||||
// that cut each active pin's contour exactly as pinned. Anything else was planned.
|
||||
int?[] pins = null;
|
||||
if (part.LeadInsLocked)
|
||||
{
|
||||
if (part.HasActiveDrawingPins)
|
||||
NotApplied(index, "the part's locked lead-ins override them. Use Plate > Remove Lead-ins, "
|
||||
+ "then Plan Cutting, to apply the pins.");
|
||||
}
|
||||
else
|
||||
{
|
||||
pins = PinnedMotions(plate, part, execution, reason => NotApplied(index, reason), token);
|
||||
if (pins == null || pins.All(p => p == null))
|
||||
continue;
|
||||
}
|
||||
bool IsFixed(int motion) => pins == null || pins[motion] != null;
|
||||
if (fixedMotions != null)
|
||||
fixedMotions[index] = IsFixed;
|
||||
if (materials[index] is { IsComplete: true } own)
|
||||
{
|
||||
var failures = new List<FixedLeadFailure>();
|
||||
var result = LeadPathValidator.Check(execution, own, others.Select(o => o.Material).ToArray(),
|
||||
IsFixed, failures, token);
|
||||
if (!result.IsComplete)
|
||||
findings.Add(Incomplete(index, result.Reason));
|
||||
foreach (var failure in failures)
|
||||
{
|
||||
var obstacle = failure.Obstacle is int other ? others[other].Index + 1 : (int?)null;
|
||||
var what = !failure.Result.IsComplete ? $"could not be checked ({failure.Result.Reason})"
|
||||
: obstacle != null ? $"contacts or enters the material of part {obstacle}"
|
||||
: "contacts or enters its own part's material";
|
||||
Lead(index, execution, failure.Motion, pins?[failure.Motion], what, obstacle, null, null);
|
||||
}
|
||||
}
|
||||
for (var motion = 0; motion < execution.Motions.Count && clamps is { Count: > 0 }; motion++)
|
||||
{
|
||||
var move = execution.Motions[motion];
|
||||
if (!IsFixed(motion) || move.Rapid || move.Start is not { } from
|
||||
|| move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
||||
continue;
|
||||
foreach (var zone in clamps)
|
||||
if (PostVerificationAnalyzer.CutTouches(move, from, zone.Bounds, out var contact))
|
||||
Lead(index, execution, motion, pins?[motion], $"touches active clamp '{zone.Id}'", null,
|
||||
zone.Id, contact);
|
||||
}
|
||||
}
|
||||
|
||||
void Lead(int index, OwnedExecution execution, int motion, int? pin, string what, int? obstacle,
|
||||
string clampId, Vector? contact)
|
||||
{
|
||||
var lead = pin is int number ? $"Drawing pin {number} lead" : "Locked lead";
|
||||
var move = execution.Motions[motion];
|
||||
findings.Add(new(PostVerificationKind.FixedLead, plateNumber, index + 1, obstacle,
|
||||
$"{lead} on cutting contour {ContourAt(execution, motion)} {what}. "
|
||||
+ "Planning kept it as given; review it before posting.")
|
||||
{ Location = contact ?? move.Curve?.Midpoint ?? move.End, ClampId = clampId });
|
||||
}
|
||||
|
||||
void NotApplied(int index, string reason) => findings.Add(new(PostVerificationKind.FixedLead,
|
||||
plateNumber, index + 1, null, "Drawing pins not applied: " + reason));
|
||||
|
||||
PostVerificationFinding Incomplete(int index, string reason) =>
|
||||
new(PostVerificationKind.Incomplete, plateNumber, index + 1, null, "Fixed lead check: " + reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each motion of an unlocked part's placed program, the one-based pin whose contour it
|
||||
/// cuts exactly as pinned, found by re-emitting each pinned contour with the part's settings
|
||||
/// and matching that emission motion for motion; null where none. Each pin that cannot be
|
||||
/// found, or does not resolve, is reported through <paramref name="notApplied"/>.
|
||||
/// </summary>
|
||||
private static int?[] PinnedMotions(Plate plate, Part part, OwnedExecution execution,
|
||||
Action<string> notApplied, CancellationToken token)
|
||||
{
|
||||
var parameters = part.CuttingParameters ?? plate.CuttingParameters ?? new CuttingParameters();
|
||||
PreparedContours pinned;
|
||||
IReadOnlyList<CutPinResolution> resolutions;
|
||||
try
|
||||
{
|
||||
var clean = Clean(part);
|
||||
var plain = PreparedContours.Capture(clean, parameters, token);
|
||||
resolutions = CutPinResolver.Resolve(part.BaseDrawing, part.Rotation, plain, token);
|
||||
pinned = PreparedContours.Capture(clean, parameters, resolutions, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
notApplied($"the drawing pins could not be checked ({exception.Message}).");
|
||||
return null;
|
||||
}
|
||||
foreach (var resolution in resolutions.Where(r => !r.IsResolved))
|
||||
notApplied($"drawing pin {resolution.PinIndex + 1} no longer matches the geometry.");
|
||||
foreach (var rejected in pinned.RejectedPins)
|
||||
notApplied($"drawing pin {rejected.PinIndex + 1} puts a tab on an internal contour.");
|
||||
var owned = new int?[execution.Motions.Count];
|
||||
for (var contour = 0; contour < pinned.Count; contour++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (pinned.PinnedEntry(contour) is not { } entry)
|
||||
continue;
|
||||
var number = pinned.PinNumberOf(contour);
|
||||
var expected = ExecutionMotionReader.Read(pinned.EmitCandidateForValidation(entry), part.Location, null, token)
|
||||
.Motions.Where(m => !m.Rapid && m.Layer != LayerType.Scribe).ToArray();
|
||||
var at = Find(execution.Motions, expected);
|
||||
if (at < 0)
|
||||
{
|
||||
notApplied($"drawing pin {number} is not cut as pinned; plan cutting to apply it.");
|
||||
continue;
|
||||
}
|
||||
for (var offset = 0; offset < expected.Length; offset++)
|
||||
owned[at + offset] = number;
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
// The first place the expected emission occurs motion for motion in the placed program.
|
||||
private static int Find(IReadOnlyList<ExecutionMotion> actual, ExecutionMotion[] expected)
|
||||
{
|
||||
const double epsilon = PostVerificationGeometry.Epsilon;
|
||||
for (var start = 0; expected.Length != 0 && start + expected.Length <= actual.Count; start++)
|
||||
{
|
||||
var same = true;
|
||||
for (var offset = 0; offset < expected.Length && same; offset++)
|
||||
{
|
||||
var a = actual[start + offset];
|
||||
var e = expected[offset];
|
||||
same = !a.Rapid && Kind(a.Layer) == Kind(e.Layer) && a.End.DistanceTo(e.End) <= epsilon
|
||||
&& System.Math.Abs(a.Length - e.Length) <= epsilon
|
||||
&& (a.Start is not { } from || e.Start is not { } to || from.DistanceTo(to) <= epsilon);
|
||||
}
|
||||
if (same)
|
||||
return start;
|
||||
}
|
||||
return -1;
|
||||
|
||||
static LayerType Kind(LayerType layer) => layer == LayerType.Display ? LayerType.Cut : layer;
|
||||
}
|
||||
|
||||
// The same clean, rotation-baked, drift-closed, seam-trimmed program that planning captures.
|
||||
internal static Program Clean(Part part)
|
||||
{
|
||||
var clean = (Program)part.BaseDrawing.Program.Clone();
|
||||
clean.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation);
|
||||
IncrementalClosureDrift.Close(clean);
|
||||
SeamOvercut.Trim(clean);
|
||||
return clean;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one-based cutting contour a lead motion belongs to: a lead-in leads into the next cut
|
||||
/// run, a lead-out leaves the last one. Scribes are not cutting contours.
|
||||
/// </summary>
|
||||
internal static int ContourAt(OwnedExecution execution, int motion)
|
||||
{
|
||||
var count = 0;
|
||||
var cutting = false;
|
||||
for (var index = 0; index < motion; index++)
|
||||
{
|
||||
var move = execution.Motions[index];
|
||||
var cut = !move.Rapid && move.Layer is LayerType.Cut or LayerType.Display
|
||||
&& move.Length > PostVerificationGeometry.Epsilon;
|
||||
if (cut && !cutting)
|
||||
count++;
|
||||
cutting = cut;
|
||||
}
|
||||
return execution.Motions[motion].Layer == LayerType.Leadout ? System.Math.Max(count, 1) : count + 1;
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Rounding drift at an incremental contour's seam. Each incremental move is written at the source
|
||||
/// CAM's precision, so a contour's end is a sum of rounded moves and can miss its start by more than
|
||||
/// <see cref="NominalContourClosure.Tolerance"/> (converted PEP perimeters of a hundred polyline
|
||||
/// moves written at 1e-6 miss by 1.4e-6). An absolute program has no such sum. A contour that misses
|
||||
/// by no more than its moves could accumulate at that precision, and never more than
|
||||
/// <see cref="Tolerance.Epsilon"/>, ends its last cut on its start; the next motion absorbs the
|
||||
/// correction, so nothing after the contour moves. Gaps inside the closure tolerance are closed too:
|
||||
/// material capture and preparation sum the moves differently, so a gap at that boundary can pass
|
||||
/// one and fail the other. Contours closed within the native epsilon are left alone.
|
||||
/// </summary>
|
||||
internal static class IncrementalClosureDrift
|
||||
{
|
||||
/// <summary>Closes drifted contours of an owned program in place; returns how many were closed.</summary>
|
||||
internal static int Close(Program program)
|
||||
{
|
||||
if (program?.Codes == null || program.Mode != Mode.Incremental)
|
||||
return 0;
|
||||
var closed = 0;
|
||||
var run = new List<Motion>();
|
||||
var start = new Vector();
|
||||
var position = new Vector();
|
||||
for (var index = 0; index <= program.Codes.Count; index++)
|
||||
{
|
||||
var code = index < program.Codes.Count ? program.Codes[index] : null;
|
||||
if (code is Motion motion && IsContour(motion))
|
||||
{
|
||||
if (run.Count == 0)
|
||||
start = position;
|
||||
run.Add(motion);
|
||||
position += motion.EndPoint;
|
||||
continue;
|
||||
}
|
||||
if (run.Count > 0 && TryClose(program, run, start, position, index))
|
||||
closed++;
|
||||
run.Clear();
|
||||
if (code is Motion other)
|
||||
position += other.EndPoint;
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
|
||||
private static bool IsContour(Motion motion) => !motion.Suppressed
|
||||
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }
|
||||
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
|
||||
|
||||
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
|
||||
{
|
||||
var gap = start.DistanceTo(end);
|
||||
// Each rounded move is off by at most half a quantum per axis.
|
||||
var drift = System.Math.Min(Tolerance.Epsilon,
|
||||
run.Count * NominalContourClosure.Tolerance * System.Math.Sqrt(0.5));
|
||||
if (run.Count < 2 || !(gap > PostVerificationGeometry.Epsilon) || !(gap <= drift))
|
||||
return false;
|
||||
var last = run[^1];
|
||||
if (last.VariableRefs is { Count: > 0 })
|
||||
return false;
|
||||
// The first motion after the contour starts where the contour used to end. A subprogram call
|
||||
// first would run from the corrected end instead, so that contour is left alone.
|
||||
Motion absorber = null;
|
||||
for (; next < program.Codes.Count && absorber == null; next++)
|
||||
{
|
||||
if (program.Codes[next] is SubProgramCall)
|
||||
return false;
|
||||
absorber = program.Codes[next] as Motion;
|
||||
}
|
||||
if (absorber != null && (absorber is ArcMove || absorber.VariableRefs is { Count: > 0 }))
|
||||
return false;
|
||||
|
||||
var correction = start - end;
|
||||
var endPoint = last.EndPoint + correction;
|
||||
if (last is ArcMove arc)
|
||||
{
|
||||
// Keep the arc's centre (relative to its start) equidistant from its moved end.
|
||||
var chord = endPoint.DistanceTo(new Vector());
|
||||
if (chord <= Tolerance.Epsilon)
|
||||
return false;
|
||||
var normal = new Vector(-endPoint.Y, endPoint.X) * (1 / chord);
|
||||
var middle = endPoint * 0.5;
|
||||
var offset = arc.CenterPoint - middle;
|
||||
arc.CenterPoint = middle + normal * (offset.X * normal.X + offset.Y * normal.Y);
|
||||
}
|
||||
last.EndPoint = endPoint;
|
||||
if (absorber != null)
|
||||
absorber.EndPoint -= correction;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Diagnostics;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Remembers whether a lead curve stays clear of another part's material, so a search that
|
||||
/// re-checks the same emitted leads (every prefix of a part repeats its earlier contours' leads,
|
||||
/// and backtracking revisits them) runs each native query set once. A hit returns exactly what
|
||||
/// <see cref="LeadPathValidator.MaterialContact"/> returned for that curve and material and charges
|
||||
/// the same native query budget. Curves match by exact bits and materials by reference; the
|
||||
/// materials must not change while the memo is in use. Scans that threw are not remembered and
|
||||
/// run again. One instance belongs to one single-threaded search; replay must not use one.
|
||||
/// </summary>
|
||||
internal sealed class LeadContactMemo
|
||||
{
|
||||
private readonly Dictionary<(PostVerificationGeometry.CurveKey Curve, LeadMaterialSnapshot Material), Scan> scans = new();
|
||||
|
||||
/// <summary>Remembered native queries answered without running (Debug cost tests).</summary>
|
||||
internal long Hits { get; private set; }
|
||||
|
||||
internal bool TryGet(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, out Scan scan)
|
||||
{
|
||||
if (!scans.TryGetValue((curve.Key, material), out scan))
|
||||
return false;
|
||||
Hits++;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void Add(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, Scan scan) =>
|
||||
scans[(curve.Key, material)] = scan;
|
||||
|
||||
/// <summary>A completed scan: whether it found contact and how many boundary queries it charged.</summary>
|
||||
internal readonly record struct Scan(bool Contact, int Queries);
|
||||
}
|
||||
@@ -4,22 +4,43 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Owned nominal material: one simple perimeter minus disjoint simple holes.</summary>
|
||||
public sealed class LeadMaterialSnapshot
|
||||
{
|
||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, string reason)
|
||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
|
||||
string reason)
|
||||
{
|
||||
Rings = rings;
|
||||
Tabs = tabs;
|
||||
Reason = reason;
|
||||
var extent = PostVerificationGeometry.Extent.None;
|
||||
foreach (var ring in rings)
|
||||
foreach (var curve in ring)
|
||||
extent = extent.Union(curve.Extent);
|
||||
Extent = extent;
|
||||
BoundaryCount = rings.Sum(ring => ring.Length);
|
||||
}
|
||||
|
||||
public bool IsComplete => Reason == null;
|
||||
public string Reason { get; }
|
||||
internal IReadOnlyList<PostVerificationGeometry.Curve[]> Rings { get; }
|
||||
|
||||
/// <summary>A conservative extent of every boundary's native contact reach; material lies within it.</summary>
|
||||
internal PostVerificationGeometry.Extent Extent { get; }
|
||||
|
||||
/// <summary>The boundary curves of all rings: the native queries one full scan charges.</summary>
|
||||
internal int BoundaryCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Per ring curve, whether it is a tabbed cutout's tab: material boundary that the
|
||||
/// program never cuts. Aligned with <see cref="Rings"/>.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<bool[]> Tabs { get; }
|
||||
|
||||
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
||||
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
||||
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
||||
@@ -29,16 +50,20 @@ public sealed class LeadMaterialSnapshot
|
||||
{
|
||||
var execution = ExecutionMotionReader.ReadSupported(cleanProgram, location, null, token);
|
||||
var rings = new List<PostVerificationGeometry.Curve[]>();
|
||||
var ringTabs = new List<bool[]>();
|
||||
var chain = new List<PostVerificationGeometry.Curve>();
|
||||
var chainTabs = new List<bool>();
|
||||
foreach (var motion in execution.Motions)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout)
|
||||
// Break cuts run through material that stays attached at tabs; they bound nothing.
|
||||
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Break)
|
||||
{
|
||||
Finish();
|
||||
continue;
|
||||
}
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display))
|
||||
// A tabbed cutout's tabs are never cut but still close its hole.
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
|
||||
throw new ArgumentException("Material capture contains an unsupported layer.");
|
||||
var curve = motion.Curve;
|
||||
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
||||
@@ -46,7 +71,8 @@ public sealed class LeadMaterialSnapshot
|
||||
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Material contour is discontinuous.");
|
||||
chain.Add(curve);
|
||||
if (PostVerificationGeometry.Closed(chain))
|
||||
chainTabs.Add(motion.Layer == LayerType.Tab);
|
||||
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||
Finish();
|
||||
}
|
||||
Finish();
|
||||
@@ -66,11 +92,23 @@ public sealed class LeadMaterialSnapshot
|
||||
var b = rings[s][j];
|
||||
var contacts = a.Contacts(b, out var overlap);
|
||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
||||
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
||||
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
||||
// Native arc queries pad angles by 1e-5 radians, admitting
|
||||
// intersections on an extension (distance grows with radius).
|
||||
// At a shared arc joint require both actual finite spans;
|
||||
// overlap and genuine second intersections still refuse.
|
||||
if (arcJoint)
|
||||
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
||||
// Native contacts at rounded arc/line joints can drift from the
|
||||
// authored endpoint. Use the shared geometry tolerance only to
|
||||
// recognize that joint; overlaps and other contacts still refuse.
|
||||
if (overlap || contacts.Any(p => !adjacent
|
||||
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon
|
||||
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon)
|
||||
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon
|
||||
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon)))
|
||||
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
||||
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
||||
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
||||
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
||||
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||
}
|
||||
var outer = -1;
|
||||
@@ -86,26 +124,36 @@ public sealed class LeadMaterialSnapshot
|
||||
}
|
||||
if (outer < 0)
|
||||
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
|
||||
if (ringTabs[outer].Any(tab => tab))
|
||||
throw new ArgumentException("A perimeter cannot have tabs.");
|
||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
||||
for (var i = 0; i < holes.Length; i++)
|
||||
for (var j = i + 1; j < holes.Length; j++)
|
||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), null);
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null);
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (chain.Count == 0)
|
||||
return;
|
||||
if (!PostVerificationGeometry.Closed(chain))
|
||||
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
|
||||
if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||
{
|
||||
var gap = chain[0].Start.DistanceTo(chain[^1].End);
|
||||
throw new ArgumentException(FormattableString.Invariant(
|
||||
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
||||
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
||||
}
|
||||
rings.Add(chain.ToArray());
|
||||
ringTabs.Add(chainTabs.ToArray());
|
||||
chain.Clear();
|
||||
chainTabs.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), ex.Message);
|
||||
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), Array.Empty<bool[]>(), ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,52 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
/// <summary>A complete unsafe result is distinct from an incomplete/unsupported check.</summary>
|
||||
public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// One lead motion of a fixed emission that failed validation. <see cref="Obstacle"/> indexes the
|
||||
/// other materials passed to the check, or is null when the lead meets its own part's material
|
||||
/// or could not be checked.
|
||||
/// </summary>
|
||||
internal sealed record FixedLeadFailure(int Motion, LeadPathValidationResult Result, int? Obstacle);
|
||||
|
||||
/// <summary>Certifies actual emitted native lead paths against owned nominal material.</summary>
|
||||
public static class LeadPathValidator
|
||||
{
|
||||
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default)
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default) =>
|
||||
Check(execution, target, otherMaterials, null, null, token);
|
||||
|
||||
/// <summary>
|
||||
/// As the public check, and every lead-in pierce must also keep <paramref name="pierceRadius"/>
|
||||
/// from all of the materials (see <see cref="CuttingStrategy.CuttingParameters.PierceDiameter"/>).
|
||||
/// Zero checks paths only.
|
||||
/// </summary>
|
||||
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, double pierceRadius, CancellationToken token = default) =>
|
||||
Check(execution, target, otherMaterials, null, null, token, pierceRadius: pierceRadius);
|
||||
|
||||
/// <summary>As the public check, remembering other-material results in <paramref name="memo"/>.</summary>
|
||||
internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, LeadContactMemo memo, CancellationToken token,
|
||||
double pierceRadius = 0) =>
|
||||
Check(execution, target, otherMaterials, null, null, token, memo, pierceRadius);
|
||||
|
||||
/// <summary>
|
||||
/// As the public check, but a lead motion that <paramref name="isFixed"/> owns (by motion
|
||||
/// index) is part of a fixed emission: its failure is added to <paramref name="fixedFailures"/>
|
||||
/// and checking continues. Any other failure is returned exactly as the public check returns it.
|
||||
/// A positive <paramref name="pierceRadius"/> also requires each lead-in pierce to keep that
|
||||
/// distance from every material; a contour pierced directly, without a lead-in, is not checked.
|
||||
/// </summary>
|
||||
internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, Func<int, bool> isFixed,
|
||||
List<FixedLeadFailure> fixedFailures, CancellationToken token, LeadContactMemo memo = null,
|
||||
double pierceRadius = 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (execution == null || target == null || otherMaterials == null)
|
||||
return new(false, false, "Missing execution or material snapshots.");
|
||||
if (!double.IsFinite(pierceRadius) || pierceRadius < 0)
|
||||
return new(false, false, "Pierce zone radius must be finite and nonnegative.");
|
||||
if (!target.IsComplete || otherMaterials.Any(m => m == null || !m.IsComplete))
|
||||
return new(false, false, "Material snapshot is incomplete.");
|
||||
try
|
||||
@@ -30,11 +67,25 @@ public static class LeadPathValidator
|
||||
var move = execution.Motions[i];
|
||||
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
||||
continue;
|
||||
var (failure, obstacle) = CheckMotion(i);
|
||||
if (failure == null)
|
||||
continue;
|
||||
if (isFixed?.Invoke(i) != true || fixedFailures == null)
|
||||
return failure;
|
||||
fixedFailures.Add(new(i, failure, obstacle));
|
||||
}
|
||||
// No lead motion is valid for direct contour piercing. The caller still
|
||||
// checks rapid travel and contour accounting for the complete program.
|
||||
return new(true, true, null);
|
||||
|
||||
(LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
|
||||
{
|
||||
var move = execution.Motions[i];
|
||||
if (move.Rapid || move.Start is not { } start || move.Curve == null
|
||||
|| !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon
|
||||
|| start.DistanceTo(move.Curve.Start) > PostVerificationGeometry.Epsilon
|
||||
|| move.End.DistanceTo(move.Curve.End) > PostVerificationGeometry.Epsilon)
|
||||
return new(false, false, "Lead motion is missing, degenerate or inconsistent.");
|
||||
return (new(false, false, "Lead motion is missing, degenerate or inconsistent."), null);
|
||||
Vector? allowed = null;
|
||||
var groupEdge = i;
|
||||
var step = move.Layer == LayerType.Leadin ? 1 : -1;
|
||||
@@ -48,10 +99,27 @@ public static class LeadPathValidator
|
||||
}
|
||||
var adjacentIndex = groupEdge + step;
|
||||
var genuineJoint = false;
|
||||
var breakLead = false;
|
||||
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
|
||||
{
|
||||
var adjacent = execution.Motions[adjacentIndex];
|
||||
if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
||||
// A break cut lies inside its part's material, so its lead joins no boundary:
|
||||
// the joint is the cut's own start (or end for a lead-out).
|
||||
if (!adjacent.Rapid && adjacent.Layer == LayerType.Break
|
||||
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
var edge = execution.Motions[groupEdge];
|
||||
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
||||
var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
||||
if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
genuineJoint = true;
|
||||
breakLead = true;
|
||||
if (groupEdge == i)
|
||||
allowed = joint;
|
||||
}
|
||||
}
|
||||
else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
||||
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
var edge = execution.Motions[groupEdge];
|
||||
@@ -77,48 +145,163 @@ public static class LeadPathValidator
|
||||
}
|
||||
}
|
||||
if (!genuineJoint)
|
||||
return new(true, false, "Lead chain has no genuine adjacent target contour entry or exit.");
|
||||
return (new(true, false, "Lead chain has no genuine adjacent target contour entry or exit."), null);
|
||||
if (allowed is { } jointPoint
|
||||
&& (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon)
|
||||
return new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only.");
|
||||
var failure = CheckMaterial(target, allowed);
|
||||
return (new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only."), null);
|
||||
// A break cut's lead runs through its own breakoff scrap: entering the target's
|
||||
// material is expected there, but touching any of its boundaries is not.
|
||||
var failure = breakLead
|
||||
? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true)
|
||||
: CheckMaterial(target, allowed);
|
||||
if (failure != null)
|
||||
return new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure}).");
|
||||
foreach (var material in otherMaterials)
|
||||
return (new(true, false, breakLead
|
||||
? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs."
|
||||
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
|
||||
var reach = move.Curve.Extent;
|
||||
for (var other = 0; other < otherMaterials.Count; other++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var material = otherMaterials[other];
|
||||
if (ReferenceEquals(material, target))
|
||||
continue;
|
||||
if (CheckMaterial(material, null) != null)
|
||||
return new(true, false, "Lead contacts or enters another placed material. "
|
||||
if (reach.IsClearOfQueriesFrom(material.Extent) ? FarInside(material) : OtherContact(material))
|
||||
return (new(true, false, "Lead contacts or enters another placed material. "
|
||||
+ "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. "
|
||||
+ "For locked parts, edit the leads or unlock the part before replanning.");
|
||||
+ "For locked parts, edit the leads or unlock the part before replanning."), other);
|
||||
}
|
||||
return PierceZone();
|
||||
|
||||
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
|
||||
// A lead-in reached by a rapid (or starting the program) starts at its pierce. A break
|
||||
// cut's pierce lies in its own breakoff scrap, inside the part's material, so only
|
||||
// others are checked.
|
||||
(LeadPathValidationResult, int?) PierceZone()
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
if (pierceRadius <= 0 || move.Layer != LayerType.Leadin || i > 0 && !execution.Motions[i - 1].Rapid)
|
||||
return (null, null);
|
||||
if (!breakLead && Clearance(target) is { } own)
|
||||
return (Short(own, "its own part's material"), null);
|
||||
for (var other = 0; other < otherMaterials.Count; other++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
var contacts = move.Curve.Contacts(boundary, out var overlap);
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||
var material = otherMaterials[other];
|
||||
if (ReferenceEquals(material, target))
|
||||
continue;
|
||||
// Every boundary of material this far away is farther than the zone.
|
||||
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
|
||||
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
|
||||
{
|
||||
Charge(material.BoundaryCount);
|
||||
continue;
|
||||
}
|
||||
if (Clearance(material) is { } distance)
|
||||
return (Short(distance, "another placed part"), other);
|
||||
}
|
||||
// With all other boundary contacts excluded, the connected open path
|
||||
// has constant material membership. Use the native arc midpoint, not
|
||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// The pierce's distance to the material when it is inside the zone, else null.
|
||||
double? Clearance(LeadMaterialSnapshot material)
|
||||
{
|
||||
var distance = MaterialDistance(start, material, ref budget, token);
|
||||
return IsInsideZone(distance, pierceRadius) ? distance : null;
|
||||
}
|
||||
|
||||
LeadPathValidationResult Short(double distance, string what) =>
|
||||
new(true, false, PierceZoneMessage(start, distance, what, pierceRadius)
|
||||
+ " Try a longer lead-in, more part spacing or a smaller pierce diameter, then replan.");
|
||||
|
||||
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) =>
|
||||
MaterialContact(move.Curve, material, permittedJoint, ref budget, token);
|
||||
|
||||
// Exactly the unmemoized check: a remembered scan charges the queries it ran
|
||||
// and throws where running it again would exhaust the budget.
|
||||
bool OtherContact(LeadMaterialSnapshot material)
|
||||
{
|
||||
if (memo == null)
|
||||
return CheckMaterial(material, null) != null;
|
||||
if (memo.TryGet(move.Curve, material, out var scan))
|
||||
{
|
||||
Charge(scan.Queries);
|
||||
return scan.Contact;
|
||||
}
|
||||
var before = budget;
|
||||
var contact = CheckMaterial(material, null) != null;
|
||||
memo.Add(move.Curve, material, new(contact, before - budget));
|
||||
return contact;
|
||||
}
|
||||
|
||||
// Material too far from the lead for any native boundary query to report a contact
|
||||
// (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran.
|
||||
// Ray parity is still asked natively unless the lead lies beside the material's rows.
|
||||
bool FarInside(LeadMaterialSnapshot material)
|
||||
{
|
||||
Charge(material.BoundaryCount);
|
||||
var midpoint = move.Curve.Midpoint;
|
||||
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
|
||||
}
|
||||
|
||||
void Charge(int queries)
|
||||
{
|
||||
if (budget - queries < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
budget -= queries;
|
||||
}
|
||||
}
|
||||
// Missing leads are the ReleasedContourState check's responsibility.
|
||||
return new(true, true, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
return new(false, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exact native distance from <paramref name="point"/> to the nearest boundary of complete
|
||||
/// material. Exceeding the budget throws.
|
||||
/// </summary>
|
||||
internal static double MaterialDistance(Vector point, LeadMaterialSnapshot material, ref int budget,
|
||||
CancellationToken token)
|
||||
{
|
||||
var nearest = double.PositiveInfinity;
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
nearest = System.Math.Min(nearest, boundary.DistanceTo(point));
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/// <summary>True when material <paramref name="distance"/> away lies inside a pierce zone.</summary>
|
||||
internal static bool IsInsideZone(double distance, double pierceRadius) =>
|
||||
pierceRadius > 0 && distance < pierceRadius - PostVerificationGeometry.Epsilon;
|
||||
|
||||
internal static string PierceZoneMessage(Vector pierce, double distance, string what, double pierceRadius) =>
|
||||
FormattableString.Invariant(
|
||||
$"The pierce at ({pierce.X:0.####}, {pierce.Y:0.####}) is {distance:0.####} from {what}; ")
|
||||
+ FormattableString.Invariant($"the {pierceRadius * 2:0.####} pierce diameter needs {pierceRadius:0.####}.");
|
||||
|
||||
/// <summary>
|
||||
/// How a native lead curve contacts or enters complete material, or null when it stays clear.
|
||||
/// A contact at <paramref name="permittedJoint"/> is allowed. Exceeding the budget throws.
|
||||
/// </summary>
|
||||
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
||||
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
var contacts = curve.Contacts(boundary, out var overlap);
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||
}
|
||||
// With all other boundary contacts excluded, the connected open path
|
||||
// has constant material membership. Use the native arc midpoint, not
|
||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Whether one part's clean material lies strictly inside a cutout of another's. The cutting
|
||||
/// planner's prerequisites and the pre-post review share this proof, so a pair the planner orders
|
||||
/// inner-before-host is the pair posting checks. Bounds only select candidates; containment is
|
||||
/// proven on native material, and any boundary contact or incomplete material is ambiguous.
|
||||
/// </summary>
|
||||
internal static class NestedPartContainment
|
||||
{
|
||||
private const int ContactBudget = 1000000;
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="inner"/>'s perimeter lies strictly inside one cutout of
|
||||
/// <paramref name="host"/>, false when it lies in none; otherwise null, with
|
||||
/// <paramref name="ambiguity"/> saying why containment cannot be established.
|
||||
/// </summary>
|
||||
internal static bool? InsideCutout(LeadMaterialSnapshot inner, LeadMaterialSnapshot host,
|
||||
CancellationToken token, out string ambiguity)
|
||||
{
|
||||
ambiguity = null;
|
||||
if (!inner.IsComplete || !host.IsComplete)
|
||||
{
|
||||
ambiguity = inner.Reason ?? host.Reason;
|
||||
return null;
|
||||
}
|
||||
var budget = ContactBudget;
|
||||
foreach (var curve in inner.Rings[0])
|
||||
foreach (var ring in host.Rings)
|
||||
foreach (var boundary in ring)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
{
|
||||
ambiguity = "the containment check exceeds the native query limit";
|
||||
return null;
|
||||
}
|
||||
if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
|
||||
{
|
||||
ambiguity = "their boundaries touch or cross";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var probe = inner.Rings[0][0].Start;
|
||||
return host.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
|
||||
}
|
||||
|
||||
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
|
||||
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
|
||||
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
|
||||
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
||||
|
||||
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
|
||||
internal static bool Within(Box inner, Box outer) =>
|
||||
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
|
||||
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
|
||||
|
||||
/// <summary>The union of two boxes.</summary>
|
||||
internal static Box Union(Box a, Box b)
|
||||
{
|
||||
var left = System.Math.Min(a.Left, b.Left);
|
||||
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
||||
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-post review of parts nested in other parts' cutouts. Cutting a host's enclosing hole
|
||||
/// releases the slug the nested part is cut from, so the nested part must be cut first; and the
|
||||
/// host's hole leads must not cross the nested part, nor the nested part's leads enter the host.
|
||||
/// Containment is proven exactly as the cutting planner proves it; an ambiguous pair is incomplete,
|
||||
/// never clear. Motions a fixed-lead review already owns are not reported twice. Reports only.
|
||||
/// </summary>
|
||||
internal static class NestedPartReview
|
||||
{
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
IReadOnlyDictionary<int, Func<int, bool>> fixedMotions, CancellationToken token)
|
||||
{
|
||||
var parts = plate.Parts;
|
||||
var clean = new Program[parts.Count];
|
||||
var cleanBounds = new Box[parts.Count];
|
||||
var hostBounds = new Box[parts.Count];
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Scribe-only drawings are marks, not material.
|
||||
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
|
||||
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null))
|
||||
continue;
|
||||
var program = FixedLeadReview.Clean(part);
|
||||
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
|
||||
hostBounds[index] = NestedPartContainment.Union(part.BoundingBox, cleanBounds[index]);
|
||||
clean[index] = program;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
// The overlap and rapid checks already report unreadable programs.
|
||||
}
|
||||
}
|
||||
var materials = new LeadMaterialSnapshot[parts.Count];
|
||||
var executions = new OwnedExecution[parts.Count];
|
||||
for (var inner = 0; inner < parts.Count; inner++)
|
||||
for (var host = 0; host < parts.Count; host++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (inner == host || clean[inner] == null || clean[host] == null
|
||||
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host]))
|
||||
continue;
|
||||
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
||||
out var ambiguity);
|
||||
if (inside == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, inner + 1, host + 1,
|
||||
$"Containment of part {inner + 1} relative to part {host + 1} cannot be established: "
|
||||
+ $"{ambiguity}. Their cut order and leads are not checked."));
|
||||
continue;
|
||||
}
|
||||
if (inside == false)
|
||||
continue;
|
||||
if (inner > host)
|
||||
{
|
||||
var bounds = cleanBounds[inner];
|
||||
findings.Add(new(PostVerificationKind.NestedCutOrder, plateNumber, inner + 1, host + 1,
|
||||
$"Part {inner + 1} lies inside a cutout of part {host + 1} but is cut after it, so cutting "
|
||||
+ "the cutout releases its slug first. Plan Cutting cuts nested parts first; replan or "
|
||||
+ "reorder before posting.")
|
||||
{ Location = new Vector((bounds.Left + bounds.Right) / 2, (bounds.Bottom + bounds.Top) / 2) });
|
||||
}
|
||||
Leads(host, inner, $"part {inner + 1}, which is nested in this part's cutout");
|
||||
Leads(inner, host, $"part {host + 1}, whose cutout this part is nested in");
|
||||
}
|
||||
|
||||
LeadMaterialSnapshot Material(int index) =>
|
||||
materials[index] ??= LeadMaterialSnapshot.Capture(clean[index], parts[index].Location, token);
|
||||
|
||||
void Leads(int from, int against, string what)
|
||||
{
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
execution = executions[from] ??= ExecutionMotionReader.Read(parts[from].Program,
|
||||
parts[from].Location, null, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
return; // The rapid check reports the unreadable program.
|
||||
}
|
||||
var owned = fixedMotions.TryGetValue(from, out var isFixed) ? isFixed : null;
|
||||
var budget = 1000000;
|
||||
for (var motion = 0; motion < execution.Motions.Count; motion++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = execution.Motions[motion];
|
||||
if (move.Rapid || move.Layer is not (LayerType.Leadin or LayerType.Leadout)
|
||||
|| owned?.Invoke(motion) == true
|
||||
|| move.Curve == null || !(move.Length > PostVerificationGeometry.Epsilon))
|
||||
continue;
|
||||
string contact;
|
||||
try
|
||||
{
|
||||
contact = LeadPathValidator.MaterialContact(move.Curve, materials[against], null, ref budget, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, from + 1, against + 1,
|
||||
"Nested part lead check: " + exception.Message));
|
||||
return;
|
||||
}
|
||||
if (contact != null)
|
||||
findings.Add(new(PostVerificationKind.NestedLead, plateNumber, from + 1, against + 1,
|
||||
$"Lead on cutting contour {FixedLeadReview.ContourAt(execution, motion)} contacts or enters "
|
||||
+ $"the material of {what}. Replan cutting or edit the lead before posting.")
|
||||
{ Location = move.Curve.Midpoint });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Closure of source contours during planner material capture and preparation.</summary>
|
||||
internal static class NominalContourClosure
|
||||
{
|
||||
internal const double Tolerance = 0.000001;
|
||||
|
||||
internal static bool IsClosed(Vector start, Vector end)
|
||||
{
|
||||
// Subtracting translated/rotated coordinates can put an exact boundary gap
|
||||
// a few floating-point ulps above the limit. Bound that allowance to 1e-12.
|
||||
var scale = System.Math.Max(1, System.Math.Max(
|
||||
System.Math.Max(System.Math.Abs(start.X), System.Math.Abs(start.Y)),
|
||||
System.Math.Max(System.Math.Abs(end.X), System.Math.Abs(end.Y))));
|
||||
var roundoff = System.Math.Min(1e-12, 4 * 2.2204460492503131e-16 * scale);
|
||||
return start.DistanceTo(end) <= Tolerance + roundoff;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ internal static class OwnedCuttingParameters
|
||||
Kerf = Dimension(source.Kerf),
|
||||
PartSpacing = Dimension(source.PartSpacing),
|
||||
PierceClearance = Dimension(source.PierceClearance),
|
||||
PierceDiameter = Dimension(source.PierceDiameter),
|
||||
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
|
||||
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
|
||||
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
|
||||
@@ -65,7 +66,7 @@ internal static class OwnedCuttingParameters
|
||||
};
|
||||
}
|
||||
|
||||
private static LeadIn CopyLeadIn(LeadIn source) => source switch
|
||||
internal static LeadIn CopyLeadIn(LeadIn source) => source switch
|
||||
{
|
||||
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
|
||||
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
|
||||
@@ -86,7 +87,7 @@ internal static class OwnedCuttingParameters
|
||||
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
|
||||
};
|
||||
|
||||
private static LeadOut CopyLeadOut(LeadOut source) => source switch
|
||||
internal static LeadOut CopyLeadOut(LeadOut source) => source switch
|
||||
{
|
||||
NoLeadOut n when n.GetType() == typeof(NoLeadOut) => new NoLeadOut(),
|
||||
LineLeadOut n when n.GetType() == typeof(LineLeadOut) => new LineLeadOut
|
||||
@@ -96,7 +97,7 @@ internal static class OwnedCuttingParameters
|
||||
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
|
||||
};
|
||||
|
||||
private static Tab CopyTab(Tab source)
|
||||
internal static Tab CopyTab(Tab source)
|
||||
{
|
||||
Tab copy = source switch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-post review of pierce zones. When a part's cutting parameters set a
|
||||
/// <see cref="CuttingParameters.PierceDiameter"/>, every lead-in pierce of its placed program must
|
||||
/// keep half of it from every part's clean material on the plate, however the leads were made
|
||||
/// (planned, assigned, placed by hand or locked). A contour pierced directly, without a lead-in,
|
||||
/// is not checked. Reports only; an incomplete check is never a clear result.
|
||||
/// </summary>
|
||||
internal static class PierceZoneReview
|
||||
{
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
CancellationToken token)
|
||||
{
|
||||
var parts = plate.Parts;
|
||||
var radii = parts.Select(part => part is { BaseDrawing.IsCutOff: false }
|
||||
? (part.CuttingParameters ?? plate.CuttingParameters)?.GetPierceZoneRadius() ?? 0 : 0).ToArray();
|
||||
if (radii.All(radius => radius <= 0))
|
||||
return;
|
||||
var materials = new LeadMaterialSnapshot[parts.Count];
|
||||
var uncaptured = new List<int>();
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Scribe-only drawings are marks, not material.
|
||||
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
|
||||
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display))
|
||||
continue;
|
||||
materials[index] = LeadMaterialSnapshot.Capture(FixedLeadReview.Clean(part), part.Location, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
// The overlap and rapid checks already report unreadable programs.
|
||||
}
|
||||
if (materials[index] is not { IsComplete: true })
|
||||
uncaptured.Add(index);
|
||||
}
|
||||
// Pierces near material that could not be captured are not checked: say so once per part.
|
||||
foreach (var index in uncaptured)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, index + 1, null,
|
||||
"Pierce zone check: " + (materials[index]?.Reason ?? "the clean material could not be captured.")
|
||||
+ " Pierces near this part are not checked."));
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (radii[index] <= 0)
|
||||
continue;
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
execution = ExecutionMotionReader.Read(parts[index].Program, parts[index].Location, null, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
continue; // The rapid check reports the unreadable program.
|
||||
}
|
||||
try
|
||||
{
|
||||
Review(index, execution, radii[index]);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, index + 1, null,
|
||||
"Pierce zone check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
void Review(int index, OwnedExecution execution, double radius)
|
||||
{
|
||||
var budget = 1000000;
|
||||
var motions = execution.Motions;
|
||||
for (var motion = 0; motion < motions.Count; motion++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = motions[motion];
|
||||
// A lead-in reached by a rapid (or starting the program) starts at its pierce.
|
||||
if (move.Rapid || move.Layer != LayerType.Leadin || move.Start is not { } pierce
|
||||
|| motion > 0 && !motions[motion - 1].Rapid)
|
||||
continue;
|
||||
var end = motion;
|
||||
while (end + 1 < motions.Count && !motions[end + 1].Rapid && motions[end + 1].Layer == LayerType.Leadin)
|
||||
end++;
|
||||
// A break cut's pierce sits in its breakoff scrap, inside its own part's material.
|
||||
var breakLead = end + 1 < motions.Count && motions[end + 1].Layer == LayerType.Break;
|
||||
for (var other = 0; other < parts.Count; other++)
|
||||
{
|
||||
if (materials[other] is not { IsComplete: true } material || other == index && breakLead)
|
||||
continue;
|
||||
var distance = LeadPathValidator.MaterialDistance(pierce, material, ref budget, token);
|
||||
if (!LeadPathValidator.IsInsideZone(distance, radius))
|
||||
continue;
|
||||
var what = other == index ? "its own part's material" : $"the material of part {other + 1}";
|
||||
findings.Add(new(PostVerificationKind.PierceZone, plateNumber, index + 1,
|
||||
other == index ? null : other + 1,
|
||||
$"Lead-in on cutting contour {FixedLeadReview.ContourAt(execution, motion)}: "
|
||||
+ LeadPathValidator.PierceZoneMessage(pierce, distance, what, radius)
|
||||
+ " Replan cutting or edit the lead before posting.")
|
||||
{ Location = pierce });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
/// Exact caller-thread record of everything a cutting proposal for one plate depends on:
|
||||
/// part list instance and order, plate quantity/size/quadrant/settings, cutoff definitions, and
|
||||
/// each part's complete cutting state with owned copies of its placed and drawing programs,
|
||||
/// drawing cutoff classification and exact settings content.
|
||||
/// drawing cutoff classification, drawing pins and pins-off, and exact settings content.
|
||||
/// A commit compares it with the live plate and refuses when anything differs.
|
||||
/// </summary>
|
||||
public sealed class PlateCuttingState
|
||||
@@ -24,12 +24,12 @@ public sealed class PlateCuttingState
|
||||
private readonly int quadrant;
|
||||
private readonly ObservableList<CutOff> cutOffList;
|
||||
private readonly CutOffRecord[] cutOffs;
|
||||
private readonly Dictionary<Drawing, (Program Program, Program Copy, bool IsCutOff)> drawings;
|
||||
private readonly Dictionary<Drawing, DrawingRecord> drawings;
|
||||
private readonly Dictionary<CuttingParameters, string> settings;
|
||||
private readonly CuttingParameters plateSettings;
|
||||
|
||||
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
|
||||
Dictionary<Drawing, (Program, Program, bool)> drawings, Dictionary<CuttingParameters, string> settings)
|
||||
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
plateSettings = plate.CuttingParameters;
|
||||
@@ -61,7 +61,7 @@ public sealed class PlateCuttingState
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
if (plate.Parts == null || plate.CutOffs == null)
|
||||
throw new ArgumentException("Plate part and cutoff lists are required.");
|
||||
var drawings = new Dictionary<Drawing, (Program, Program, bool)>(ReferenceEqualityComparer.Instance);
|
||||
var drawings = new Dictionary<Drawing, DrawingRecord>(ReferenceEqualityComparer.Instance);
|
||||
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
|
||||
Fingerprint(plate.CuttingParameters);
|
||||
var records = new List<PartRecord>(plate.Parts.Count);
|
||||
@@ -72,11 +72,13 @@ public sealed class PlateCuttingState
|
||||
throw new ArgumentException("Plate contains a missing part, drawing or program.");
|
||||
var drawing = part.BaseDrawing;
|
||||
if (!drawings.ContainsKey(drawing))
|
||||
drawings.Add(drawing, (drawing.Program, drawing.Program == null ? null
|
||||
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff));
|
||||
drawings.Add(drawing, new(drawing.Program, drawing.Program == null ? null
|
||||
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff,
|
||||
drawing.CutPins, StateFingerprint.Of(drawing.CutPins)));
|
||||
var state = part.CaptureCuttingState();
|
||||
Fingerprint(state.CuttingParameters);
|
||||
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox)));
|
||||
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox),
|
||||
part.DrawingPinsOff));
|
||||
}
|
||||
var cutOffs = plate.CutOffs.Select(c => c == null
|
||||
? throw new ArgumentException("Plate contains a missing cutoff definition.")
|
||||
@@ -130,12 +132,19 @@ public sealed class PlateCuttingState
|
||||
return $"Part {i + 1} program was edited in place.";
|
||||
if (!SameSettings(live.CuttingParameters))
|
||||
return $"Part {i + 1} cutting settings were edited in place.";
|
||||
var (drawingProgram, drawingCopy, isCutOff) = drawings[part.BaseDrawing];
|
||||
if (!ReferenceEquals(part.BaseDrawing.Program, drawingProgram)
|
||||
|| !ProgramContent.Equal(drawingProgram, drawingCopy, token))
|
||||
var drawing = drawings[part.BaseDrawing];
|
||||
if (!ReferenceEquals(part.BaseDrawing.Program, drawing.Program)
|
||||
|| !ProgramContent.Equal(drawing.Program, drawing.Copy, token))
|
||||
return $"Part {i + 1} drawing program changed.";
|
||||
if (part.BaseDrawing.IsCutOff != isCutOff)
|
||||
if (part.BaseDrawing.IsCutOff != drawing.IsCutOff)
|
||||
return $"Part {i + 1} cutoff classification changed.";
|
||||
// The drawing's pins decide how its pinned contours are cut, and pins-off whether
|
||||
// they apply to this placement: either change makes the proposal stale.
|
||||
if (part.DrawingPinsOff != record.DrawingPinsOff)
|
||||
return $"Part {i + 1} drawing pins were switched on or off.";
|
||||
if (!ReferenceEquals(part.BaseDrawing.CutPins, drawing.Pins)
|
||||
|| StateFingerprint.Of(drawing.Pins) != drawing.PinsFingerprint)
|
||||
return $"Part {i + 1} drawing pins changed.";
|
||||
}
|
||||
if (plate.CutOffs.Count != cutOffs.Length)
|
||||
return "Cutoffs were added or removed.";
|
||||
@@ -188,7 +197,11 @@ public sealed class PlateCuttingState
|
||||
private static bool Bits(double? a, double? b) => a.HasValue == b.HasValue
|
||||
&& (!a.HasValue || Bits(a.Value, b.Value));
|
||||
|
||||
private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds);
|
||||
private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds,
|
||||
bool DrawingPinsOff);
|
||||
|
||||
private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff,
|
||||
List<ContourCutPin> Pins, string PinsFingerprint);
|
||||
|
||||
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
|
||||
double? StartLimit, double? EndLimit);
|
||||
|
||||
@@ -12,6 +12,13 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
|
||||
{
|
||||
internal PreparedContours Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see
|
||||
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to
|
||||
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice.
|
||||
/// </summary>
|
||||
public Vector? Pierce { get; internal init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -23,17 +30,118 @@ public sealed class PreparedContours
|
||||
private readonly Shape[] shapes;
|
||||
private readonly CuttingParameters parameters;
|
||||
private readonly List<Entity> scribes;
|
||||
// Open break cuts in program order, each travel-ordered with its scrap on the left. They are
|
||||
// not contours: no choices, no material; every full or prefix emission cuts them first.
|
||||
private readonly List<Shape> breakCuts;
|
||||
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
|
||||
// every full or prefix emission cuts their pieces after the break cuts.
|
||||
private readonly List<Shape> tabbedCutouts;
|
||||
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
||||
// its own owned settings (the pin's lead-in, lead-out and tab).
|
||||
private readonly ContourChoice[] pinned;
|
||||
private readonly CuttingParameters[] contourParameters;
|
||||
private readonly int[] pinNumbers;
|
||||
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, CuttingParameters parameters)
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||
CuttingParameters parameters)
|
||||
{
|
||||
this.shapes = shapes;
|
||||
this.scribes = scribes;
|
||||
this.breakCuts = breakCuts;
|
||||
this.tabbedCutouts = tabbedCutouts;
|
||||
this.parameters = parameters;
|
||||
pinned = new ContourChoice[shapes.Length];
|
||||
pinNumbers = new int[shapes.Length];
|
||||
}
|
||||
|
||||
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
|
||||
{
|
||||
contourParameters = new CuttingParameters[shapes.Length];
|
||||
var rejected = new List<CutPinResolution>();
|
||||
foreach (var pin in pins)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (pin == null)
|
||||
throw new ArgumentException("A pin resolution is missing.", nameof(pins));
|
||||
if (!pin.IsResolved)
|
||||
continue; // The caller reports why; the contour stays automatic.
|
||||
var contour = pin.ContourOrdinal;
|
||||
if (contour < 0 || contour >= Count || pin.EntityOrdinal < 0
|
||||
|| pin.EntityOrdinal >= shapes[contour].Entities.Count)
|
||||
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
|
||||
if (pinned[contour] != null)
|
||||
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
|
||||
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned.
|
||||
if (pin.Pin.HasTab && contour != PerimeterOrdinal)
|
||||
{
|
||||
rejected.Add(pin);
|
||||
continue;
|
||||
}
|
||||
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
|
||||
ValidateChoice(choice);
|
||||
pinned[contour] = choice;
|
||||
pinNumbers[contour] = pin.PinIndex + 1;
|
||||
contourParameters[contour] = PinParameters(parameters, pin.Pin);
|
||||
}
|
||||
RejectedPins = rejected.AsReadOnly();
|
||||
}
|
||||
|
||||
public int Count => shapes.Length;
|
||||
|
||||
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
||||
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
||||
public int PerimeterOrdinal => Count - 1;
|
||||
|
||||
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||
|
||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||
|
||||
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
||||
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
||||
|
||||
/// <summary>The pinned contour's only entry, or null for an automatic contour.</summary>
|
||||
public ContourChoice PinnedEntry(int contourOrdinal) => IsPinned(contourOrdinal) ? pinned[contourOrdinal] : null;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(Program, CuttingParameters, CancellationToken)"/>, then fixes every
|
||||
/// resolved pin's contour: that contour's only entry is the pin's point and it is emitted with
|
||||
/// the pin's own lead-in, lead-out and tab through the same emitter, so its tab gap is genuine
|
||||
/// and certifiable. <paramref name="pins"/> must come from <see cref="CutPinResolver"/> against
|
||||
/// contours of this same program; unresolved pins are skipped (the caller reports them), a pin
|
||||
/// with a tab on an internal contour is listed in <see cref="RejectedPins"/>, and every other
|
||||
/// contour keeps the automatic selection with <paramref name="parameters"/>.
|
||||
/// </summary>
|
||||
public static PreparedContours Capture(Program program, CuttingParameters parameters,
|
||||
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pins);
|
||||
var unpinned = Capture(program, parameters, token);
|
||||
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
||||
}
|
||||
|
||||
// The pin's leads replace every contour type's leads, since the type the emitter detects is
|
||||
// not the caller's concern; angle rounding is off because a pinned entry never moves.
|
||||
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
|
||||
{
|
||||
var owned = OwnedCuttingParameters.Copy(shared);
|
||||
owned.ExternalLeadIn = pin.LeadIn;
|
||||
owned.InternalLeadIn = pin.LeadIn;
|
||||
owned.ArcCircleLeadIn = pin.LeadIn;
|
||||
owned.ExternalLeadOut = pin.LeadOut;
|
||||
owned.InternalLeadOut = pin.LeadOut;
|
||||
owned.ArcCircleLeadOut = pin.LeadOut;
|
||||
owned.RoundLeadInAngles = false;
|
||||
owned.TabsEnabled = pin.HasTab;
|
||||
if (pin.HasTab)
|
||||
owned.TabConfig = pin.Tab;
|
||||
return owned;
|
||||
}
|
||||
|
||||
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
|
||||
|
||||
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -45,12 +153,30 @@ public sealed class PreparedContours
|
||||
var geometryProgram = CopyForGeometry(program, token);
|
||||
var contours = new List<Shape>();
|
||||
var scribes = new List<Entity>();
|
||||
var breakCuts = new List<Shape>();
|
||||
var tabbedCutouts = new List<Shape>();
|
||||
Shape breakCut = null;
|
||||
var current = new Shape();
|
||||
foreach (var entity in geometryProgram.ToGeometry())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle))
|
||||
throw new NotSupportedException("Unsupported native geometry.");
|
||||
if (entity.Layer == SpecialLayers.Break)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
throw new ArgumentException("Preparation requires closed execution contours.");
|
||||
if (entity is Circle || entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate break cut entity.");
|
||||
if (breakCut == null || End(breakCut.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
breakCut = new Shape();
|
||||
breakCuts.Add(breakCut);
|
||||
}
|
||||
breakCut.Entities.Add(entity);
|
||||
continue;
|
||||
}
|
||||
breakCut = null;
|
||||
if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
@@ -59,13 +185,13 @@ public sealed class PreparedContours
|
||||
scribes.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display)
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
|
||||
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
||||
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate contour entity.");
|
||||
if (entity is Circle)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
if (current.Entities.Count != 0 || entity.Layer == SpecialLayers.Tab)
|
||||
throw new ArgumentException("Circle interrupts an open contour.");
|
||||
current.Entities.Add(entity);
|
||||
FinishContour();
|
||||
@@ -74,7 +200,7 @@ public sealed class PreparedContours
|
||||
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Discontinuous native contour.");
|
||||
current.Entities.Add(entity);
|
||||
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
|
||||
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
||||
FinishContour();
|
||||
}
|
||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||
@@ -90,20 +216,32 @@ public sealed class PreparedContours
|
||||
var outer = contours[perimeter];
|
||||
contours.RemoveAt(perimeter);
|
||||
contours.Add(outer);
|
||||
return new(contours.ToArray(), scribes, ownedParameters);
|
||||
foreach (var cut in breakCuts)
|
||||
cut.UpdateBounds();
|
||||
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters);
|
||||
|
||||
void FinishContour()
|
||||
{
|
||||
current.UpdateBounds();
|
||||
contours.Add(current);
|
||||
if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||
{
|
||||
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
||||
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
||||
tabbedCutouts.Add(current);
|
||||
}
|
||||
else
|
||||
contours.Add(current);
|
||||
current = new Shape();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The closest native entry to <paramref name="approach"/>; a pinned contour's pinned entry.</summary>
|
||||
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
|
||||
{
|
||||
PostVerificationGeometry.Validate(approach);
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return pin;
|
||||
var point = shape.ClosestPointTo(approach, out var entity);
|
||||
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
||||
}
|
||||
@@ -112,6 +250,7 @@ public sealed class PreparedContours
|
||||
/// Closest native point first, then source-order vertices/midpoints or eight circle
|
||||
/// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
|
||||
/// Emit before validating actual paths: circle rounding/clamping may move this point.
|
||||
/// A pinned contour has its pinned entry only.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ContourChoice> Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
|
||||
{
|
||||
@@ -119,6 +258,8 @@ public sealed class PreparedContours
|
||||
if (maxEntries <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxEntries));
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { pin };
|
||||
var entries = new List<ContourChoice>();
|
||||
entries.Add(ClosestEntry(contourOrdinal, approach));
|
||||
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
|
||||
@@ -149,6 +290,25 @@ public sealed class PreparedContours
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every contour with a native entity within <paramref name="tolerance"/> of
|
||||
/// <paramref name="point"/>, in ordinal order, each with its closest entity and the exact
|
||||
/// closest native point on it. A locating aid for drawing pins, not an entry choice.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<(int ContourOrdinal, int EntityOrdinal, Vector Point)> ContoursNear(Vector point, double tolerance)
|
||||
{
|
||||
PostVerificationGeometry.Validate(point);
|
||||
var found = new List<(int, int, Vector)>();
|
||||
for (var ordinal = 0; ordinal < Count; ordinal++)
|
||||
{
|
||||
var shape = shapes[ordinal];
|
||||
var closest = shape.ClosestPointTo(point, out var entity);
|
||||
if (closest.DistanceTo(point) <= tolerance)
|
||||
found.Add((ordinal, shape.Entities.IndexOf(entity), closest));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
||||
{
|
||||
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
||||
@@ -156,6 +316,27 @@ public sealed class PreparedContours
|
||||
return choice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An automatic perimeter entry pierced at <paramref name="pierce"/> (local coordinates),
|
||||
/// entering at the closest native point of the perimeter along a straight lead-in. The
|
||||
/// planner's pierce-zone fallback chooses the pierce from the free scrap around the part;
|
||||
/// emission reads only this choice and the perimeter, and the lead validator checks the
|
||||
/// lead as usual. A pinned perimeter keeps its pin's entry.
|
||||
/// </summary>
|
||||
internal ContourChoice ScrapPierceEntry(Vector pierce)
|
||||
{
|
||||
PostVerificationGeometry.Validate(pierce);
|
||||
var shape = GetShape(PerimeterOrdinal);
|
||||
var point = shape.ClosestPointTo(pierce, out var entity);
|
||||
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
|
||||
{
|
||||
Owner = this,
|
||||
Pierce = pierce,
|
||||
};
|
||||
ValidateChoice(choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The uncapped preferred automatic start catalogue for one contour, in preference then
|
||||
/// contour-travel order: convex corners of the contour's own winding, then straight-edge
|
||||
@@ -170,6 +351,8 @@ public sealed class PreparedContours
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||
if (IsSingleCircle(shape))
|
||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
|
||||
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
|
||||
@@ -183,7 +366,8 @@ public sealed class PreparedContours
|
||||
/// <paramref name="lookAhead"/> (pass the arrival point there when there is no next cut).
|
||||
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
||||
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry.
|
||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
||||
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||
int contourOrdinal,
|
||||
@@ -192,6 +376,8 @@ public sealed class PreparedContours
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||
var all = IsSingleCircle(shape)
|
||||
? new List<ContourEntryCandidate>()
|
||||
: PreferredCandidates(shape, contourOrdinal, token);
|
||||
@@ -254,12 +440,11 @@ public sealed class PreparedContours
|
||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
|
||||
// Back INTO each incident edge from the corner: the edge ending at the
|
||||
// corner retreats against its own travel, the edge starting at the corner
|
||||
// advances along its own travel.
|
||||
AddInset(corner.EntityOrdinal, cornerPoint, inward: false);
|
||||
AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true);
|
||||
AddInset(corner.EntityOrdinal, inward: false);
|
||||
AddInset((corner.EntityOrdinal + 1) % count, inward: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +461,7 @@ public sealed class PreparedContours
|
||||
token.ThrowIfCancellationRequested();
|
||||
return fallbacks;
|
||||
|
||||
void AddInset(int entityOrdinal, Vector corner, bool inward)
|
||||
void AddInset(int entityOrdinal, bool inward)
|
||||
{
|
||||
// `inward` selects along the edge's own travel from its start; without it the
|
||||
// point retreats against travel. Both ways move BACK INTO the edge from the
|
||||
@@ -285,7 +470,9 @@ public sealed class PreparedContours
|
||||
return;
|
||||
var direction = (line.EndPoint - line.StartPoint).Normalize();
|
||||
var offset = direction * (2 * lead);
|
||||
var point = inward ? corner + offset : corner - offset;
|
||||
// Adjacent native endpoints may differ within contour-continuity tolerance.
|
||||
// Anchor to the selected edge itself, never the preceding edge's vertex.
|
||||
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||
// sign when a short edge is overshoot): never the corner, never the far
|
||||
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
||||
@@ -441,17 +628,29 @@ public sealed class PreparedContours
|
||||
// Same owned clones as a real emission; the source shapes/settings are never used
|
||||
// directly, so the probe cannot drift the preparation or mutate it.
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice);
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
|
||||
contourParameters);
|
||||
}
|
||||
|
||||
// Each prefix is a standalone owned program, including the same scribes once.
|
||||
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
||||
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
Validate(choices, false);
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
||||
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
||||
public int BreakCutCount => breakCuts.Count;
|
||||
|
||||
/// <summary>Tabbed cutouts this preparation cuts, as pieces, after its break cuts and before its contours.</summary>
|
||||
public int TabbedCutoutCount => tabbedCutouts.Count;
|
||||
|
||||
/// <summary>Open pieces the tabbed cutouts are cut in, each a lead-in and cut group of its own.</summary>
|
||||
public int TabbedPieceCount => tabbedCutouts.Sum(c => ContourCuttingStrategy.TabbedCutoutPieces(c).Count);
|
||||
|
||||
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
|
||||
|
||||
// Build expected-emission metadata independently of the selected payload BEFORE
|
||||
@@ -461,7 +660,9 @@ public sealed class PreparedContours
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||
return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
|
||||
// Only the outside is tabbed, with its own pin's tab when it is pinned.
|
||||
var perimeter = ParametersFor(PerimeterOrdinal);
|
||||
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
||||
}
|
||||
|
||||
private void Validate(IReadOnlyList<ContourChoice> choices, bool complete)
|
||||
@@ -488,6 +689,17 @@ public sealed class PreparedContours
|
||||
var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
|
||||
if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Entry is not on the selected native entity.");
|
||||
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
|
||||
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
|
||||
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
|
||||
if (choice.Pierce is { } pierce)
|
||||
{
|
||||
PostVerificationGeometry.Validate(pierce);
|
||||
if (choice.ContourOrdinal != PerimeterOrdinal)
|
||||
throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
|
||||
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("A scrap pierce must lie off its contour.");
|
||||
}
|
||||
}
|
||||
|
||||
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
||||
@@ -510,6 +722,17 @@ public sealed class PreparedContours
|
||||
return centres;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
||||
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
||||
/// </summary>
|
||||
internal bool HoleContains(int contourOrdinal, Vector point)
|
||||
{
|
||||
var shape = GetShape(contourOrdinal);
|
||||
return contourOrdinal != PerimeterOrdinal && shape.BoundingBox.Contains(point)
|
||||
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
||||
}
|
||||
|
||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||
|
||||
|
||||
@@ -11,10 +11,20 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
public sealed class ReleasedContourState
|
||||
{
|
||||
private readonly List<Obstacle> obstacles = new();
|
||||
private readonly bool reportMissingLeadIns;
|
||||
|
||||
public ReleasedContourState() : this(true) { }
|
||||
|
||||
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
||||
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
||||
public ReleasedContourState(bool reportMissingLeadIns)
|
||||
{
|
||||
this.reportMissingLeadIns = reportMissingLeadIns;
|
||||
}
|
||||
|
||||
public ReleasedContourState Copy()
|
||||
{
|
||||
var copy = new ReleasedContourState();
|
||||
var copy = new ReleasedContourState(reportMissingLeadIns);
|
||||
copy.obstacles.AddRange(obstacles);
|
||||
return copy;
|
||||
}
|
||||
@@ -28,7 +38,18 @@ public sealed class ReleasedContourState
|
||||
/// being open, never become obstacles.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
|
||||
int partNumber, bool cutoff, CancellationToken token = default)
|
||||
int partNumber, bool cutoff, CancellationToken token = default) =>
|
||||
Check(execution, arrival, partNumber, cutoff, null, token).Strict;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
|
||||
/// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is
|
||||
/// returned apart: a fixed emission is accepted as given and only reported. The checker
|
||||
/// state advances exactly as it would without ownership.
|
||||
/// </summary>
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
|
||||
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
|
||||
CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execution);
|
||||
if (arrival is { } point)
|
||||
@@ -36,24 +57,65 @@ public sealed class ReleasedContourState
|
||||
var moves = execution.Motions.ToArray();
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
|
||||
return findings.AsReadOnly();
|
||||
var fixedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
||||
}
|
||||
|
||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
||||
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
||||
bool proveOpenCuts = false)
|
||||
{
|
||||
var contour = new List<PostVerificationGeometry.Curve>();
|
||||
var unfinished = new List<PostVerificationGeometry.Curve[]>();
|
||||
var current = new CutGroup();
|
||||
var unfinished = new List<CutGroup>();
|
||||
// Part-local cuts read only by the open-cut proof below: closed contours, break cuts
|
||||
// and leads no contour owns. Each lead belongs to the cut it enters or leaves.
|
||||
var closed = new List<CutGroup>();
|
||||
var otherOpen = new List<CutGroup>();
|
||||
var loose = new CutGroup();
|
||||
var leadIns = new List<PostVerificationGeometry.Curve>();
|
||||
CutGroup previous = null;
|
||||
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
||||
var hasLead = false;
|
||||
var contourNumber = 0;
|
||||
foreach (var move in moves)
|
||||
var inBreakCut = false;
|
||||
var reportedTab = false;
|
||||
for (var index = 0; index < moves.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = moves[index];
|
||||
var owned = isFixed?.Invoke(index) == true;
|
||||
if (!move.Rapid && move.Layer == LayerType.Break)
|
||||
{
|
||||
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
||||
// rapid obstacle and never an unexplained fragment, but it is still pierced.
|
||||
if (!inBreakCut)
|
||||
{
|
||||
Finish();
|
||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
"A break cut has no nonzero placed lead-in motion.")
|
||||
{ Location = move.Curve.Start }, owned);
|
||||
inBreakCut = true;
|
||||
hasLead = false;
|
||||
previous = new CutGroup();
|
||||
previous.Leads.AddRange(leadIns);
|
||||
leadIns.Clear();
|
||||
otherOpen.Add(previous);
|
||||
}
|
||||
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
previous.Curves.Add(move.Curve);
|
||||
continue;
|
||||
}
|
||||
inBreakCut = false;
|
||||
if (move.Rapid)
|
||||
{
|
||||
Finish();
|
||||
hasLead = false;
|
||||
loose.Leads.AddRange(leadIns);
|
||||
leadIns.Clear();
|
||||
previous = null;
|
||||
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
||||
continue;
|
||||
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
|
||||
@@ -66,73 +128,290 @@ public sealed class ReleasedContourState
|
||||
if (reach.IsClearOf(obstacle.Extent))
|
||||
continue;
|
||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||
$"of part {obstacle.Part}."));
|
||||
$"of part {obstacle.Part}.")
|
||||
{
|
||||
Location = (start + move.End) * 0.5,
|
||||
RapidStart = start,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
|
||||
}, owned);
|
||||
}
|
||||
}
|
||||
else if (move.Layer == LayerType.Leadin)
|
||||
{
|
||||
Finish();
|
||||
previous = null;
|
||||
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
|
||||
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
leadIns.Add(move.Curve);
|
||||
}
|
||||
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."));
|
||||
if (move.Layer == LayerType.Leadout && move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
// Withheld until the part ends, where the open-cut proof may show it cuts no gap.
|
||||
if (current.Curves.Count > 0 && !PostVerificationGeometry.Closed(current.Curves))
|
||||
Defer(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."), owned);
|
||||
(previous ?? loose).Leads.Add(move.Curve);
|
||||
}
|
||||
Finish();
|
||||
hasLead = false;
|
||||
if (move.Layer == LayerType.Scribe)
|
||||
previous = null;
|
||||
}
|
||||
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
if (contour.Count == 0)
|
||||
// A clean program's tab spans close their cutout but are not meant to be cut.
|
||||
if (move.Layer == LayerType.Tab && !reportedTab)
|
||||
{
|
||||
reportedTab = true;
|
||||
Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"A tabbed cutout still has its tabs as motions, so it may be cut free. " +
|
||||
"Assign lead-ins or plan cutting to cut it in pieces around its tabs.")
|
||||
{ Location = move.Curve.Start }, owned);
|
||||
}
|
||||
if (current.Curves.Count == 0)
|
||||
{
|
||||
contourNumber++;
|
||||
if (!cutoff && !hasLead)
|
||||
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
|
||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
|
||||
{ Location = move.Curve.Start }, owned);
|
||||
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)
|
||||
var resumed = unfinished.FindIndex(chain =>
|
||||
chain.Curves[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
|
||||
if (resumed >= 0)
|
||||
{
|
||||
contour.AddRange(unfinished[previous]);
|
||||
unfinished.RemoveAt(previous);
|
||||
current = unfinished[resumed];
|
||||
unfinished.RemoveAt(resumed);
|
||||
}
|
||||
current.Leads.AddRange(leadIns);
|
||||
leadIns.Clear();
|
||||
}
|
||||
contour.Add(move.Curve);
|
||||
current.Curves.Add(move.Curve);
|
||||
current.Fixed |= owned;
|
||||
previous = current;
|
||||
// A completed contour becomes an obstacle immediately, not at part end.
|
||||
if (PostVerificationGeometry.Closed(contour))
|
||||
if (PostVerificationGeometry.Closed(current.Curves))
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
Finish();
|
||||
if (unfinished.Count > 1)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
loose.Leads.AddRange(leadIns);
|
||||
// Open cuts that provably enclose nothing retain all their material, however many
|
||||
// pieces a tabbed cutout was cut in. Otherwise they need manual review. Planning proves
|
||||
// them too (its own tabbed cutouts are cut in pieces without lead-outs), but only
|
||||
// pre-post review lets the proof clear a lead-out: planning keeps refusing to certify
|
||||
// its own tabs followed by a lead-out.
|
||||
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
|
||||
&& unfinished.All(LeadsClearOpenEnds)
|
||||
&& OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token);
|
||||
for (var i = deferred.Count - 1; i >= 0 && !(retained && proveOpenCuts); i--)
|
||||
deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding);
|
||||
// Only one open fragment per part can be the planner's own certified tab, so a
|
||||
// further fragment that a fixed emission cut makes this uncertainty that emission's.
|
||||
if (unfinished.Count > 1 && !retained)
|
||||
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."));
|
||||
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
|
||||
unfinished.Any(chain => chain.Fixed));
|
||||
|
||||
void Add(PostVerificationFinding finding, bool fixedEmission)
|
||||
{
|
||||
if (fixedEmission && fixedFindings != null)
|
||||
fixedFindings.Add(finding);
|
||||
else
|
||||
findings.Add(finding);
|
||||
}
|
||||
|
||||
// Records the finding's place among the others, in case it is reported after all.
|
||||
void Defer(PostVerificationFinding finding, bool fixedEmission)
|
||||
{
|
||||
var target = fixedEmission && fixedFindings != null ? fixedFindings : findings;
|
||||
deferred.Add((target, target.Count, finding));
|
||||
}
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (contour.Count == 0)
|
||||
if (current.Curves.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()));
|
||||
if (!cutoff && PostVerificationGeometry.Closed(current.Curves))
|
||||
{
|
||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
|
||||
closed.Add(current);
|
||||
}
|
||||
else if (!cutoff)
|
||||
unfinished.Add(contour.ToArray());
|
||||
contour.Clear();
|
||||
unfinished.Add(current);
|
||||
current = new CutGroup();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether one part's open cuts, with their leads, can release no material. A closed
|
||||
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
||||
/// Open cuts may touch any cut only at their own ends, and must form no loop through those
|
||||
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
||||
/// tab strength are not modelled, exactly as for a single open contour.
|
||||
/// </summary>
|
||||
private const double JointTolerance = 1e-5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether every lead at an open end of <paramref name="group"/> stays out of the uncut gap
|
||||
/// beyond it: a lead-out never reaches ahead of the end it leaves, and a lead-in never
|
||||
/// behind the start it enters. A lead that curls into the gap can cut the tab with its kerf
|
||||
/// even where its centerline does not touch the next cut.
|
||||
/// </summary>
|
||||
private static bool LeadsClearOpenEnds(CutGroup group)
|
||||
{
|
||||
var start = group.Curves[0].Start;
|
||||
var end = group.Curves[^1].End;
|
||||
var behindStart = group.Curves[0].Tangent(false) * -1;
|
||||
var aheadOfEnd = group.Curves[^1].Tangent(true);
|
||||
foreach (var lead in group.Leads)
|
||||
{
|
||||
if (Touches(lead, start) && lead.Reach(start, behindStart) > PostVerificationGeometry.Epsilon)
|
||||
return false;
|
||||
if (Touches(lead, end) && lead.Reach(end, aheadOfEnd) > PostVerificationGeometry.Epsilon)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
static bool Touches(PostVerificationGeometry.Curve lead, Vector point) =>
|
||||
lead.Start.DistanceTo(point) <= PostVerificationGeometry.Epsilon
|
||||
|| lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon;
|
||||
}
|
||||
|
||||
private static bool OpenCutsEncloseNothing(IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> open,
|
||||
CancellationToken token)
|
||||
{
|
||||
var edges = open.SelectMany(group => group.Curves.Concat(group.Leads)).ToList();
|
||||
var nodes = new DisjointSet(closed.Count + 2 * edges.Count);
|
||||
int Node(int edge, bool end) => closed.Count + 2 * edge + (end ? 1 : 0);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < closed.Count; i++)
|
||||
for (var j = 0; j < i; j++)
|
||||
if (Touch(closed[i], closed[j], token))
|
||||
nodes.Union(i, j);
|
||||
for (var a = 0; a < edges.Count; a++)
|
||||
{
|
||||
var edge = edges[a];
|
||||
if (edge.Start.DistanceTo(edge.End) <= PostVerificationGeometry.Epsilon)
|
||||
return false;
|
||||
for (var g = 0; g < closed.Count; g++)
|
||||
{
|
||||
foreach (var other in closed[g].Curves.Concat(closed[g].Leads))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (edge.Extent.IsClearOf(other.Extent))
|
||||
continue;
|
||||
foreach (var point in Contacts(edge, other))
|
||||
{
|
||||
if (EndAt(edge, point, other) is not { } end)
|
||||
return false;
|
||||
nodes.Union(Node(a, end), g);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var b = a + 1; b < edges.Count; b++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var other = edges[b];
|
||||
if (edge.Extent.IsClearOf(other.Extent))
|
||||
continue;
|
||||
foreach (var point in Contacts(edge, other))
|
||||
{
|
||||
if (EndAt(edge, point, other) is not { } end || EndAt(other, point, edge) is not { } otherEnd)
|
||||
return false;
|
||||
nodes.Union(Node(a, end), Node(b, otherEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or NotSupportedException
|
||||
or ArithmeticException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var forest = new DisjointSet(closed.Count + 2 * edges.Count);
|
||||
for (var a = 0; a < edges.Count; a++)
|
||||
{
|
||||
var start = nodes.Find(Node(a, false));
|
||||
var end = nodes.Find(Node(a, true));
|
||||
if (forest.Find(start) == forest.Find(end))
|
||||
return false;
|
||||
forest.Union(start, end);
|
||||
}
|
||||
return true;
|
||||
|
||||
static IReadOnlyList<Vector> Contacts(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b)
|
||||
{
|
||||
var points = a.Contacts(b, out var overlap);
|
||||
if (overlap)
|
||||
throw new ArgumentException("Cuts share a span.");
|
||||
return points;
|
||||
}
|
||||
|
||||
// Native contacts at a near-tangent joint (a lead or line meeting an arc) can land
|
||||
// slightly off the vertex the two cuts share; that is still their shared end.
|
||||
static bool? EndAt(PostVerificationGeometry.Curve curve, Vector point, PostVerificationGeometry.Curve other)
|
||||
{
|
||||
foreach (var end in new[] { false, true })
|
||||
{
|
||||
var vertex = end ? curve.End : curve.Start;
|
||||
if (point.DistanceTo(vertex) <= PostVerificationGeometry.Epsilon
|
||||
|| point.DistanceTo(vertex) <= JointTolerance
|
||||
&& (vertex.DistanceTo(other.Start) <= PostVerificationGeometry.Epsilon
|
||||
|| vertex.DistanceTo(other.End) <= PostVerificationGeometry.Epsilon))
|
||||
return end;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool Touch(CutGroup a, CutGroup b, CancellationToken token)
|
||||
{
|
||||
foreach (var curve in a.Curves.Concat(a.Leads))
|
||||
foreach (var other in b.Curves.Concat(b.Leads))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!curve.Extent.IsClearOf(other.Extent) && Contacts(curve, other).Count > 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One contour's cut motions with the leads entering and leaving it.</summary>
|
||||
private sealed class CutGroup
|
||||
{
|
||||
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
|
||||
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
|
||||
internal bool Fixed { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DisjointSet(int count)
|
||||
{
|
||||
private readonly int[] parents = Enumerable.Range(0, count).ToArray();
|
||||
|
||||
internal int Find(int node)
|
||||
{
|
||||
while (parents[node] != node)
|
||||
node = parents[node] = parents[parents[node]];
|
||||
return node;
|
||||
}
|
||||
|
||||
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
|
||||
}
|
||||
|
||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
|
||||
{
|
||||
internal PostVerificationGeometry.Extent Extent { get; } =
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// A seam overcut baked into a clean contour: the last cut runs on past the contour's own start
|
||||
/// along its first cut, then a straight jog returns to the start (converted nests carry a source
|
||||
/// CAM's 0.03 overcut this way). The retraced tail encloses no area, so trimming it leaves the
|
||||
/// material unchanged; left in, the contour touches itself at its seam and nothing can certify it.
|
||||
/// Only that exact shape is recognized. Any other self-contact is still refused by its consumer.
|
||||
/// </summary>
|
||||
internal static class SeamOvercut
|
||||
{
|
||||
/// <summary>
|
||||
/// The same program when it has no seam overcut, otherwise an owned copy without them.
|
||||
/// The source program is never edited.
|
||||
/// </summary>
|
||||
internal static Program Trimmed(Program program)
|
||||
{
|
||||
var edits = Find(program);
|
||||
if (edits.Count == 0)
|
||||
return program;
|
||||
var copy = (Program)program.Clone();
|
||||
Apply(copy, edits);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>Trims seam overcuts from an owned program in place; returns how many were removed.</summary>
|
||||
internal static int Trim(Program program)
|
||||
{
|
||||
var edits = Find(program);
|
||||
Apply(program, edits);
|
||||
return edits.Count;
|
||||
}
|
||||
|
||||
private static void Apply(Program program, List<Edit> edits)
|
||||
{
|
||||
// Last first, so removing a return jog does not shift an earlier edit's indices.
|
||||
for (var i = edits.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var edit = edits[i];
|
||||
var last = (Motion)program.Codes[edit.LastCut];
|
||||
last.EndPoint = edit.EndPoint;
|
||||
if (last is ArcMove arc)
|
||||
arc.CenterPoint = edit.CenterPoint;
|
||||
program.Codes.RemoveAt(edit.ReturnJog);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Edit> Find(Program program)
|
||||
{
|
||||
var edits = new List<Edit>();
|
||||
if (program?.Codes == null)
|
||||
return edits;
|
||||
var incremental = program.Mode == Mode.Incremental;
|
||||
var run = new List<Segment>();
|
||||
// Null while the position is unknown: after a subprogram call in an absolute program.
|
||||
Vector? position = new Vector();
|
||||
for (var index = 0; index < program.Codes.Count; index++)
|
||||
{
|
||||
var code = program.Codes[index];
|
||||
if (code is not Motion motion)
|
||||
{
|
||||
Finish();
|
||||
if (code is SubProgramCall && !incremental)
|
||||
position = null;
|
||||
continue;
|
||||
}
|
||||
// Incremental coordinates only need a consistent origin within one contour.
|
||||
var reference = incremental ? position ?? new Vector() : new Vector();
|
||||
var end = reference + motion.EndPoint;
|
||||
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
|
||||
or ArcMove { Layer: LayerType.Cut or LayerType.Display };
|
||||
if (!cut || position == null)
|
||||
{
|
||||
Finish();
|
||||
position = end;
|
||||
continue;
|
||||
}
|
||||
run.Add(new(index, motion, position.Value, end,
|
||||
motion is ArcMove arc ? reference + arc.CenterPoint : null, reference));
|
||||
position = end;
|
||||
}
|
||||
Finish();
|
||||
return edits;
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (TryTrim(run, out var edit))
|
||||
edits.Add(edit);
|
||||
run.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryTrim(List<Segment> run, out Edit edit)
|
||||
{
|
||||
edit = default;
|
||||
if (run.Count < 3)
|
||||
return false;
|
||||
var first = run[0];
|
||||
var last = run[^2];
|
||||
var jog = run[^1];
|
||||
var seam = first.Start;
|
||||
var overcut = last.End;
|
||||
if (jog.Motion is not LinearMove || !NominalContourClosure.IsClosed(seam, jog.End)
|
||||
|| HasVariables(last.Motion) || HasVariables(jog.Motion)
|
||||
|| !IsFinite(seam) || !IsFinite(overcut) || !IsFinite(last.Start)
|
||||
|| overcut.DistanceTo(seam) <= Tolerance.Epsilon || last.Start.DistanceTo(seam) <= Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
if (first.Center == null && last.Center == null)
|
||||
{
|
||||
var length = first.Start.DistanceTo(first.End);
|
||||
if (length <= Tolerance.Epsilon)
|
||||
return false;
|
||||
var direction = (first.End - first.Start) * (1 / length);
|
||||
// The last cut lies on the first cut's line, passes the seam and stops inside the first cut.
|
||||
var along = Dot(overcut - seam, direction);
|
||||
var before = Dot(seam - last.Start, direction);
|
||||
if (System.Math.Abs(Cross(overcut - seam, direction)) > Tolerance.Epsilon
|
||||
|| System.Math.Abs(Cross(last.Start - seam, direction)) > Tolerance.Epsilon
|
||||
|| along <= Tolerance.Epsilon || along >= length - Tolerance.Epsilon || before <= Tolerance.Epsilon)
|
||||
return false;
|
||||
edit = new(last.Index, jog.Index, seam - last.Reference, new Vector());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first.Center is not { } firstCenter || last.Center is not { } lastCenter
|
||||
|| !IsFinite(firstCenter) || !IsFinite(lastCenter)
|
||||
|| ((ArcMove)first.Motion).Rotation != ((ArcMove)last.Motion).Rotation)
|
||||
return false;
|
||||
var radius = seam.DistanceTo(firstCenter);
|
||||
if (radius <= Tolerance.Epsilon || firstCenter.DistanceTo(lastCenter) > Tolerance.Epsilon
|
||||
|| System.Math.Abs(overcut.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon
|
||||
|| System.Math.Abs(overcut.DistanceTo(firstCenter) - radius) > Tolerance.Epsilon
|
||||
|| System.Math.Abs(seam.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon)
|
||||
return false;
|
||||
var clockwise = ((ArcMove)first.Motion).Rotation == RotationType.CW;
|
||||
var margin = Tolerance.Epsilon / radius;
|
||||
// The overcut end lies inside the first arc's span, and the seam inside the last arc's.
|
||||
var firstSweep = first.Start.DistanceTo(first.End) <= Tolerance.Epsilon
|
||||
? 2 * System.Math.PI : Travel(firstCenter, seam, first.End, clockwise);
|
||||
var passed = Travel(firstCenter, seam, overcut, clockwise);
|
||||
var lastSweep = Travel(lastCenter, last.Start, overcut, clockwise);
|
||||
var toSeam = Travel(lastCenter, last.Start, seam, clockwise);
|
||||
if (passed <= margin || passed >= System.Math.PI || passed >= firstSweep - margin
|
||||
|| toSeam <= margin || toSeam >= lastSweep - margin
|
||||
|| System.Math.Abs(lastSweep - toSeam - passed) > margin)
|
||||
return false;
|
||||
|
||||
// Keep the shortened arc's center equidistant from both of its ends.
|
||||
var chord = seam - last.Start;
|
||||
var normal = new Vector(-chord.Y, chord.X) * (1 / last.Start.DistanceTo(seam));
|
||||
var middle = (last.Start + seam) * 0.5;
|
||||
var center = middle + normal * Dot(lastCenter - middle, normal);
|
||||
edit = new(last.Index, jog.Index, seam - last.Reference, center - last.Reference);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The angle travelled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
|
||||
private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
|
||||
{
|
||||
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
|
||||
var end = System.Math.Atan2(to.Y - center.Y, to.X - center.X);
|
||||
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
|
||||
return travel < 0 ? travel + 2 * System.Math.PI : travel;
|
||||
}
|
||||
|
||||
private static bool HasVariables(Motion motion) => motion.VariableRefs is { Count: > 0 };
|
||||
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
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;
|
||||
|
||||
/// <summary>One cut of a contour in the walk's coordinates; <see cref="Reference"/> maps them back to its code.</summary>
|
||||
private readonly record struct Segment(int Index, Motion Motion, Vector Start, Vector End, Vector? Center, Vector Reference);
|
||||
|
||||
/// <summary>The last cut's new code-relative end and center, and the return jog to remove.</summary>
|
||||
private readonly record struct Edit(int LastCut, int ReturnJog, Vector EndPoint, Vector CenterPoint);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
@@ -23,11 +24,51 @@ internal static class StateFingerprint
|
||||
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
|
||||
internal static string Of(CuttingParameters settings)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
Settings(settings);
|
||||
return text.ToString();
|
||||
var writer = new Writer();
|
||||
writer.Settings(settings);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
void Settings(CuttingParameters p)
|
||||
/// <summary>
|
||||
/// Fingerprint of a drawing's cut pins in order: each pin's point, lead-in, lead-out and
|
||||
/// tab. A null list and an empty list differ.
|
||||
/// </summary>
|
||||
internal static string Of(IReadOnlyList<ContourCutPin> pins)
|
||||
{
|
||||
var writer = new Writer();
|
||||
writer.Pins(pins);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private sealed class Writer
|
||||
{
|
||||
private readonly StringBuilder text = new();
|
||||
|
||||
public override string ToString() => text.ToString();
|
||||
|
||||
internal void Pins(IReadOnlyList<ContourCutPin> pins)
|
||||
{
|
||||
if (pins == null)
|
||||
{
|
||||
text.Append("null;");
|
||||
return;
|
||||
}
|
||||
text.Append("Pins{");
|
||||
foreach (var pin in pins)
|
||||
{
|
||||
if (!Begin(pin, typeof(ContourCutPin)))
|
||||
continue;
|
||||
Number(pin.Point.X);
|
||||
Number(pin.Point.Y);
|
||||
Lead(pin.LeadIn);
|
||||
Out(pin.LeadOut);
|
||||
TabSettings(pin.Tab);
|
||||
text.Append('}');
|
||||
}
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
internal void Settings(CuttingParameters p)
|
||||
{
|
||||
if (!Begin(p, typeof(CuttingParameters)))
|
||||
return;
|
||||
@@ -45,6 +86,7 @@ internal static class StateFingerprint
|
||||
Lead(p.ArcCircleLeadIn);
|
||||
Out(p.ArcCircleLeadOut);
|
||||
Number(p.PierceClearance);
|
||||
Number(p.PierceDiameter);
|
||||
Flag(p.RoundLeadInAngles);
|
||||
Number(p.LeadInAngleIncrement);
|
||||
Number(p.AutoTabMinSize);
|
||||
@@ -56,7 +98,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Sequence(SequenceParameters s)
|
||||
private void Sequence(SequenceParameters s)
|
||||
{
|
||||
if (!Begin(s, typeof(SequenceParameters)))
|
||||
return;
|
||||
@@ -72,7 +114,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Assignment(AssignmentParameters a)
|
||||
private void Assignment(AssignmentParameters a)
|
||||
{
|
||||
if (!Begin(a, typeof(AssignmentParameters)))
|
||||
return;
|
||||
@@ -82,7 +124,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Lead(LeadIn lead)
|
||||
private void Lead(LeadIn lead)
|
||||
{
|
||||
if (lead == null)
|
||||
{
|
||||
@@ -122,7 +164,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Out(LeadOut lead)
|
||||
private void Out(LeadOut lead)
|
||||
{
|
||||
if (lead == null)
|
||||
{
|
||||
@@ -146,7 +188,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void TabSettings(Tab tab)
|
||||
private void TabSettings(Tab tab)
|
||||
{
|
||||
if (tab == null)
|
||||
{
|
||||
@@ -180,7 +222,7 @@ internal static class StateFingerprint
|
||||
|
||||
// Writes the exact type tag and returns true; null writes a marker and returns false.
|
||||
// A runtime type other than the expected exact type throws before any member is read.
|
||||
bool Begin(object value, Type exact)
|
||||
private bool Begin(object value, Type exact)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
@@ -193,15 +235,15 @@ internal static class StateFingerprint
|
||||
return true;
|
||||
}
|
||||
|
||||
void Number(double value) =>
|
||||
private void Number(double value) =>
|
||||
text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
|
||||
void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
private void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
|
||||
void Flag(bool value) => text.Append(value ? "1;" : "0;");
|
||||
private void Flag(bool value) => text.Append(value ? "1;" : "0;");
|
||||
|
||||
// Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
|
||||
void Text(string value)
|
||||
private void Text(string value)
|
||||
{
|
||||
if (value == null)
|
||||
text.Append("~;");
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// How one contour of a drawing must be cut: where it is entered and the lead-in,
|
||||
/// lead-out and optional tab used there. <see cref="Point"/> is in the drawing's source
|
||||
/// frame (the frame of <see cref="Drawing.SourceEntities"/>), which does not move when the
|
||||
/// program is re-origined; <see cref="CutPinResolver"/> maps it to a placed contour.
|
||||
/// Immutable: only the exact built-in lead and tab types are accepted, they are copied
|
||||
/// in, and every property returns a fresh copy.
|
||||
/// </summary>
|
||||
public sealed class ContourCutPin
|
||||
{
|
||||
private readonly LeadIn leadIn;
|
||||
private readonly LeadOut leadOut;
|
||||
private readonly Tab tab;
|
||||
|
||||
/// <exception cref="ArgumentException">A missing or invalid lead, a nonfinite point, or a tab with a lead-out.</exception>
|
||||
/// <exception cref="NotSupportedException">A lead or tab that is not an exact built-in type.</exception>
|
||||
public ContourCutPin(Vector point, LeadIn leadIn, LeadOut leadOut, Tab tab = null)
|
||||
{
|
||||
PostVerificationGeometry.Validate(point);
|
||||
var ownedLeadIn = OwnedCuttingParameters.CopyLeadIn(leadIn);
|
||||
var ownedLeadOut = OwnedCuttingParameters.CopyLeadOut(leadOut);
|
||||
var ownedTab = tab == null ? null : OwnedCuttingParameters.CopyTab(tab);
|
||||
// A tab leaves the contour open at its end; a lead-out after an open contour is
|
||||
// refused by the planner, so the pin cannot ask for both.
|
||||
if (ownedTab != null && ownedLeadOut.GetType() != typeof(NoLeadOut))
|
||||
throw new ArgumentException("A pin with a tab cannot have a lead-out.", nameof(leadOut));
|
||||
|
||||
Point = point;
|
||||
this.leadIn = ownedLeadIn;
|
||||
this.leadOut = ownedLeadOut;
|
||||
this.tab = ownedTab;
|
||||
}
|
||||
|
||||
/// <summary>The contour entry in the drawing's source frame.</summary>
|
||||
public Vector Point { get; }
|
||||
|
||||
/// <summary>A copy of the pinned lead-in.</summary>
|
||||
public LeadIn LeadIn => OwnedCuttingParameters.CopyLeadIn(leadIn);
|
||||
|
||||
/// <summary>A copy of the pinned lead-out; always <see cref="NoLeadOut"/> when <see cref="HasTab"/>.</summary>
|
||||
public LeadOut LeadOut => OwnedCuttingParameters.CopyLeadOut(leadOut);
|
||||
|
||||
/// <summary>A copy of the pinned tab, or null for a closed contour.</summary>
|
||||
public Tab Tab => tab == null ? null : OwnedCuttingParameters.CopyTab(tab);
|
||||
|
||||
public bool HasTab => tab != null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -19,6 +20,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
var breakCuts = TakeBreakCuts(entities);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
@@ -77,10 +79,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var result = new Program(Mode.Absolute);
|
||||
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
EmitBreakCuts(result, breakCuts, BreakCutContours(profile));
|
||||
|
||||
foreach (var entry in cutoutEntries)
|
||||
{
|
||||
if (!entry.Shape.IsClosed())
|
||||
if (IsTabbedCutout(entry.Shape))
|
||||
EmitTabbedCutout(result, entry.Shape);
|
||||
else if (!entry.Shape.IsClosed())
|
||||
EmitRawContour(result, entry.Shape);
|
||||
else
|
||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||
@@ -110,6 +115,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
var breakCuts = TakeBreakCuts(entities);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
@@ -120,14 +126,20 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var result = new Program(Mode.Absolute);
|
||||
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
EmitBreakCuts(result, breakCuts, BreakCutContours(profile));
|
||||
|
||||
// Find the target shape that contains the clicked entity
|
||||
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
||||
|
||||
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
if (!cutout.IsClosed())
|
||||
// A tabbed cutout's pieces are always entered from its slug, like break cuts.
|
||||
if (IsTabbedCutout(cutout))
|
||||
{
|
||||
EmitTabbedCutout(result, cutout);
|
||||
}
|
||||
else if (!cutout.IsClosed())
|
||||
{
|
||||
EmitRawContour(result, cutout);
|
||||
}
|
||||
@@ -234,43 +246,61 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <param name="contourParameters">Per-contour settings by contour ordinal (a drawing pin's
|
||||
/// own lead-in, lead-out and tab); a null list or entry uses <see cref="Parameters"/>.</param>
|
||||
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
||||
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
||||
IReadOnlyList<Shape> tabbedCutouts = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, choices);
|
||||
EmitBreakCuts(result, breakCuts, shapes);
|
||||
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
||||
EmitTabbedCutout(result, cutout);
|
||||
EmitChosenContours(result, shapes, choices, contourParameters);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Diagnostic seam for candidate lead validation (PreparedContours
|
||||
// .EmitCandidateForValidation): emit a single already-validated choice with its
|
||||
// ORIGINAL contour type — the last shape is External even when emitted alone, so a
|
||||
// ORIGINAL contour type — the last shape is External even when emitted alone, so a
|
||||
// perimeter never degrades to a hole because it was isolated. Output must not be
|
||||
// installed on a Part or accepted as a complete plan.
|
||||
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
||||
CuttingPlanning.ContourChoice choice)
|
||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, new[] { choice });
|
||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// One contour per choice in order; the perimeter (last shape) is always External.
|
||||
// Contour emission itself reads only the contour's own geometry and settings, never
|
||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||
private void EmitChosenContours(Program result, Shape[] shapes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
|
||||
{
|
||||
foreach (var choice in choices)
|
||||
var shared = Parameters;
|
||||
try
|
||||
{
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
||||
exactCirclePrograms: true);
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
// A contour's own settings apply to its emission alone; scribes and every
|
||||
// other contour keep the shared settings.
|
||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Parameters = shared;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +420,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType? forceType = null,
|
||||
bool exactCirclePrograms = false
|
||||
bool exactCirclePrograms = false,
|
||||
Vector? scrapPierce = null
|
||||
)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
@@ -415,7 +446,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
);
|
||||
}
|
||||
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||
Parameters.GetEffectivePierceClearance());
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius());
|
||||
|
||||
// Build hole sub-program relative to (0,0)
|
||||
var holeCenter = circle.Center;
|
||||
@@ -454,8 +487,17 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return;
|
||||
}
|
||||
|
||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||
Parameters.PierceClearance, out var leadInNormal);
|
||||
double leadInNormal;
|
||||
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
||||
if (scrapPierce is { } pierce)
|
||||
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
||||
else
|
||||
{
|
||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
||||
if (contourType == ContourType.Internal)
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius());
|
||||
}
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
||||
|
||||
var reindexedShape = shape.ReindexAt(point, entity);
|
||||
@@ -483,7 +525,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
else
|
||||
{
|
||||
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
|
||||
Parameters.PierceClearance, out leadOutNormal);
|
||||
Parameters.GetEffectivePierceClearance(), out leadOutNormal);
|
||||
}
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
@@ -783,7 +825,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
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π).
|
||||
// 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);
|
||||
@@ -840,7 +882,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
// Perpendicular to line direction: tangent + π/2 = left side.
|
||||
// Perpendicular to line direction: tangent + π/2 = left side.
|
||||
// Left side = outward for CW winding; for CCW winding, outward
|
||||
// is on the right side, so flip.
|
||||
var tangent = line.EndPoint.AngleFrom(line.StartPoint);
|
||||
@@ -851,7 +893,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
// Radial direction from center to point.
|
||||
// Flip when the arc direction differs from the contour winding —
|
||||
// Flip when the arc direction differs from the contour winding —
|
||||
// that indicates a concave feature where radial points inward.
|
||||
normal = point.AngleFrom(arc.Center);
|
||||
if (arc.Rotation != winding)
|
||||
@@ -859,7 +901,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Radial outward — always correct regardless of winding
|
||||
// Radial outward — always correct regardless of winding
|
||||
normal = point.AngleFrom(circle.Center);
|
||||
}
|
||||
else
|
||||
@@ -887,18 +929,24 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return polygon.RotationDirection();
|
||||
}
|
||||
|
||||
private LeadIn ClampLeadInForCircle(
|
||||
/// <summary>
|
||||
/// Shortens a round hole's lead-in so its pierce keeps <paramref name="clearance"/> from
|
||||
/// the hole wall. A hole no wider than twice the clearance keeps the lead as it is.
|
||||
/// Shared by program generation and the manual placement preview.
|
||||
/// </summary>
|
||||
public static LeadIn ClampLeadInForCircle(
|
||||
LeadIn leadIn,
|
||||
Circle circle,
|
||||
Vector contourPoint,
|
||||
double normalAngle
|
||||
double normalAngle,
|
||||
double clearance
|
||||
)
|
||||
{
|
||||
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
|
||||
if (leadIn is NoLeadIn || clearance <= 0)
|
||||
return leadIn;
|
||||
|
||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
|
||||
var maxRadius = circle.Radius - Parameters.PierceClearance;
|
||||
var maxRadius = circle.Radius - clearance;
|
||||
if (maxRadius <= 0)
|
||||
return leadIn;
|
||||
|
||||
@@ -936,6 +984,85 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return leadIn.Scale(scale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
|
||||
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
|
||||
/// returned as given. Otherwise the pierce moves onto the line from
|
||||
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
|
||||
/// round hole), as close to the contour as the zone allows, and the lead becomes a
|
||||
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
|
||||
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
|
||||
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
|
||||
/// knows nothing of parts nested inside the hole. Shared by program generation and the
|
||||
/// manual placement preview.
|
||||
/// </summary>
|
||||
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
|
||||
double pierceRadius)
|
||||
{
|
||||
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|
||||
|| !hole.IsClosed())
|
||||
return leadIn;
|
||||
|
||||
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
|
||||
var polygon = circle == null
|
||||
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
|
||||
: null;
|
||||
if (Clear(leadIn.GetPiercePoint(point, normal)))
|
||||
return leadIn;
|
||||
|
||||
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
|
||||
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
|
||||
if (!Inside(pole))
|
||||
return leadIn;
|
||||
|
||||
// Clearance grows monotonically toward the pole of a convex hole, so the bisection
|
||||
// finds the shortest clear lead; in other holes it still ends on a clear point.
|
||||
var pierce = pole;
|
||||
if (Clear(pole))
|
||||
{
|
||||
double low = 0, high = 1;
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
var middle = (low + high) / 2;
|
||||
if (Clear(point + (pole - point) * middle))
|
||||
high = middle;
|
||||
else
|
||||
low = middle;
|
||||
}
|
||||
pierce = point + (pole - point) * high;
|
||||
}
|
||||
|
||||
var length = pierce.DistanceTo(point);
|
||||
if (length <= Math.Tolerance.Epsilon)
|
||||
return leadIn;
|
||||
var direction = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||
return new LineLeadIn
|
||||
{
|
||||
Length = length,
|
||||
ApproachAngle = Angle.ToDegrees(Angle.NormalizeRad(direction - normal + Angle.HalfPI)),
|
||||
};
|
||||
|
||||
bool Inside(Vector p) => circle != null
|
||||
? p.DistanceTo(circle.Center) < circle.Radius
|
||||
: polygon.ContainsPoint(p);
|
||||
|
||||
bool Clear(Vector p) => double.IsFinite(p.X) && double.IsFinite(p.Y) && Inside(p)
|
||||
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
|
||||
/// normal to generate it with.
|
||||
/// </summary>
|
||||
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
|
||||
{
|
||||
normal = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
|
||||
}
|
||||
|
||||
// Bounds the pole search on long, thin holes; the result is checked before use.
|
||||
private const int PoleSearchCells = 4096;
|
||||
|
||||
private LeadIn SelectLeadIn(ContourType contourType)
|
||||
{
|
||||
return contourType switch
|
||||
@@ -998,7 +1125,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
break;
|
||||
}
|
||||
|
||||
// No intersection — entity is entirely inside circle, remove it
|
||||
// No intersection — entity is entirely inside circle, remove it
|
||||
if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon)
|
||||
{
|
||||
entities.RemoveAt(entities.Count - 1);
|
||||
@@ -1029,15 +1156,27 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
var moves = new List<ICode>();
|
||||
var position = startPoint;
|
||||
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
moves.Add(new LinearMove(line.EndPoint) { Layer = layer });
|
||||
position = line.EndPoint;
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
// A contour entered away from its seam crosses the seam's admitted closure gap
|
||||
// mid-cut. Starting the arc there would move its start off its own circle, so
|
||||
// bridge the gap with a straight move and cut the arc exactly as drawn. A lead-in
|
||||
// must join the contour itself, so an entry on the seam is left as it was.
|
||||
var arcStart = arc.StartPoint();
|
||||
var gap = position.DistanceTo(arcStart);
|
||||
if (moves.Count > 0 && gap > Diagnostics.PostVerificationGeometry.Epsilon
|
||||
&& CuttingPlanning.NominalContourClosure.IsClosed(position, arcStart))
|
||||
moves.Add(new LinearMove(arcStart) { Layer = layer });
|
||||
position = arc.EndPoint();
|
||||
moves.Add(
|
||||
new ArcMove(
|
||||
arc.EndPoint(),
|
||||
@@ -1054,6 +1193,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
moves.Add(
|
||||
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
|
||||
);
|
||||
position = startPoint;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1066,6 +1206,253 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return moves;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes break-cut entities from <paramref name="entities"/> (in program order) and
|
||||
/// returns them as travel-ordered open chains, one per uninterrupted run.
|
||||
/// </summary>
|
||||
internal static List<Shape> TakeBreakCuts(List<Entity> entities)
|
||||
{
|
||||
var cuts = new List<Shape>();
|
||||
Shape current = null;
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (entity.Layer != SpecialLayers.Break)
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (current == null
|
||||
|| EntityEndPoint(current.Entities[^1]).DistanceTo(EntityStartPoint(entity)) > Tolerance.Epsilon)
|
||||
{
|
||||
current = new Shape();
|
||||
cuts.Add(current);
|
||||
}
|
||||
current.Entities.Add(entity);
|
||||
}
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Break);
|
||||
foreach (var cut in cuts)
|
||||
cut.UpdateBounds();
|
||||
return cuts;
|
||||
}
|
||||
|
||||
// A clean program's break cut travels with its scrap on the left. It is entered from the
|
||||
// scrap, with the internal lead-in (holes are pierced in scrap the same way), at whichever
|
||||
// end has more scrap before the part's contours: near the outline the scrap narrows to a
|
||||
// wedge. NoLeadIn pierces on the line. There is no lead-out: the cut ends at a tab, which a
|
||||
// run-out would cut into. Entering at the far end cuts it the other way round.
|
||||
private void EmitBreakCuts(Program program, IReadOnlyList<Shape> cuts, IReadOnlyList<Shape> contours)
|
||||
{
|
||||
if (cuts == null)
|
||||
return;
|
||||
var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn();
|
||||
foreach (var cut in cuts)
|
||||
{
|
||||
var (entry, scrap, fromEnd) = BreakCutEntry(cut, contours);
|
||||
var path = cut;
|
||||
if (fromEnd)
|
||||
{
|
||||
path = (Shape)cut.Clone();
|
||||
path.Reverse();
|
||||
}
|
||||
var normal = Angle.NormalizeRad(System.Math.Atan2(scrap.Y, scrap.X));
|
||||
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more
|
||||
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
|
||||
/// whether that is the cut's far end.
|
||||
/// </summary>
|
||||
public static (Vector Entry, Vector ScrapDirection, bool FromEnd) BreakCutEntry(Shape cut,
|
||||
IReadOnlyList<Shape> contours)
|
||||
{
|
||||
var start = EntityStartPoint(cut.Entities[0]);
|
||||
var end = EntityEndPoint(cut.Entities[^1]);
|
||||
var startScrap = LeftOf(Tangent(cut.Entities[0], atEnd: false));
|
||||
var endScrap = LeftOf(Tangent(cut.Entities[^1], atEnd: true));
|
||||
return Room(end, endScrap, contours) > Room(start, startScrap, contours)
|
||||
? (end, endScrap, true)
|
||||
: (start, startScrap, false);
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
|
||||
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
|
||||
|
||||
/// <summary>
|
||||
/// The cut pieces of a closed tabbed cutout in its own travel order, starting after a tab:
|
||||
/// each maximal run of entities that are not on the tab layer.
|
||||
/// </summary>
|
||||
internal static List<Shape> TabbedCutoutPieces(Shape cutout)
|
||||
{
|
||||
var entities = cutout.Entities;
|
||||
var count = entities.Count;
|
||||
var first = -1;
|
||||
for (var i = 0; i < count && first < 0; i++)
|
||||
if (entities[i].Layer != SpecialLayers.Tab && entities[(i + count - 1) % count].Layer == SpecialLayers.Tab)
|
||||
first = i;
|
||||
var pieces = new List<Shape>();
|
||||
if (first < 0)
|
||||
return pieces;
|
||||
Shape current = null;
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var entity = entities[(first + k) % count];
|
||||
if (entity.Layer == SpecialLayers.Tab)
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (current == null)
|
||||
{
|
||||
current = new Shape();
|
||||
pieces.Add(current);
|
||||
}
|
||||
current.Entities.Add(entity);
|
||||
}
|
||||
foreach (var piece in pieces)
|
||||
piece.UpdateBounds();
|
||||
return pieces;
|
||||
}
|
||||
|
||||
// A tabbed cutout is cut as open pieces between its tabs, in the cutout's own direction.
|
||||
// Each piece is entered from the slug with the internal lead-in (NoLeadIn pierces on the
|
||||
// contour) and has no lead-out, since it ends at a tab a run-out would cut into. A lead-in
|
||||
// that would reach back over the tab behind its piece is mirrored about the entry normal,
|
||||
// so it approaches from the piece's side instead. On an arc, a lead ending in an arc would
|
||||
// meet the cut tangentially (a contact verification cannot resolve), so it pierces straight
|
||||
// in from the same distance.
|
||||
private void EmitTabbedCutout(Program program, Shape cutout)
|
||||
{
|
||||
var winding = DetermineWinding(cutout);
|
||||
var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn();
|
||||
foreach (var piece in TabbedCutoutPieces(cutout))
|
||||
{
|
||||
var first = piece.Entities[0];
|
||||
var entry = EntityStartPoint(first);
|
||||
var normal = ComputeNormal(entry, first, ContourType.Internal, winding);
|
||||
var tangent = TravelTangent(first, entry);
|
||||
program.Codes.AddRange(TabbedLeadIn(leadIn, entry, normal, tangent, winding, first is Arc));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(piece, entry));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ICode> TabbedLeadIn(LeadIn leadIn, Vector entry, double normal, Vector tangent,
|
||||
RotationType winding, bool onArc)
|
||||
{
|
||||
var codes = leadIn.Generate(entry, normal, winding);
|
||||
if (!(onArc && codes.Count > 0 && codes[^1] is ArcMove))
|
||||
{
|
||||
if (ReachBehind(codes, entry, tangent) <= Tolerance.Epsilon)
|
||||
return codes;
|
||||
var mirrored = codes.Select(code => Mirror(code, entry, tangent)).ToList();
|
||||
if (ReachBehind(mirrored, entry, tangent) <= Tolerance.Epsilon)
|
||||
return mirrored;
|
||||
}
|
||||
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
|
||||
var pierce = leadIn.GetPiercePoint(entry, normal);
|
||||
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
|
||||
}
|
||||
|
||||
// How far the lead motions of `codes` (absolute, after their first rapid) reach behind
|
||||
// `entry`, against the unit travel `tangent`.
|
||||
private static double ReachBehind(List<ICode> codes, Vector entry, Vector tangent)
|
||||
{
|
||||
var behind = new Vector(-tangent.X, -tangent.Y);
|
||||
double Along(Vector p) => (p.X - entry.X) * behind.X + (p.Y - entry.Y) * behind.Y;
|
||||
var reach = double.NegativeInfinity;
|
||||
Vector? position = null;
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is not Motion motion)
|
||||
continue;
|
||||
var end = motion.EndPoint;
|
||||
if (position is { } start && code is not RapidMove)
|
||||
{
|
||||
reach = System.Math.Max(reach, System.Math.Max(Along(start), Along(end)));
|
||||
if (code is ArcMove arc)
|
||||
{
|
||||
var center = arc.CenterPoint;
|
||||
var radius = center.DistanceTo(end);
|
||||
var extreme = new Vector(center.X + behind.X * radius, center.Y + behind.Y * radius);
|
||||
if (OnArc(center, start, end, extreme, arc.Rotation))
|
||||
reach = System.Math.Max(reach, Along(extreme));
|
||||
}
|
||||
}
|
||||
position = end;
|
||||
}
|
||||
return reach;
|
||||
}
|
||||
|
||||
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
|
||||
{
|
||||
var a0 = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
||||
var a1 = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
||||
var ap = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
|
||||
var sign = rotation == RotationType.CCW ? 1 : -1;
|
||||
var sweep = Angle.NormalizeRad(sign * (a1 - a0));
|
||||
if (sweep <= Tolerance.Epsilon)
|
||||
sweep = Angle.TwoPI;
|
||||
return Angle.NormalizeRad(sign * (ap - a0)) <= sweep;
|
||||
}
|
||||
|
||||
// `code` reflected across the line through `entry` along its normal (negating the
|
||||
// tangent component), with arc directions swapped.
|
||||
private static ICode Mirror(ICode code, Vector entry, Vector tangent)
|
||||
{
|
||||
Vector Reflect(Vector p)
|
||||
{
|
||||
var along = (p.X - entry.X) * tangent.X + (p.Y - entry.Y) * tangent.Y;
|
||||
return new Vector(p.X - 2 * along * tangent.X, p.Y - 2 * along * tangent.Y);
|
||||
}
|
||||
return code switch
|
||||
{
|
||||
RapidMove rapid => new RapidMove(Reflect(rapid.EndPoint)),
|
||||
LinearMove line => new LinearMove(Reflect(line.EndPoint)) { Layer = line.Layer },
|
||||
ArcMove arc => new ArcMove(Reflect(arc.EndPoint), Reflect(arc.CenterPoint),
|
||||
arc.Rotation == RotationType.CW ? RotationType.CCW : RotationType.CW)
|
||||
{ Layer = arc.Layer },
|
||||
_ => code,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Shape> BreakCutContours(ShapeProfile profile) =>
|
||||
new List<Shape>(profile.Cutouts) { profile.Perimeter };
|
||||
|
||||
// Distance from a break cut end along its scrap direction to the nearest contour.
|
||||
private static double Room(Vector point, Vector direction, IReadOnlyList<Shape> contours)
|
||||
{
|
||||
const double reach = 1e6;
|
||||
var ray = new Line(point, point + direction * reach);
|
||||
var nearest = reach;
|
||||
foreach (var contour in contours ?? Array.Empty<Shape>())
|
||||
foreach (var entity in contour.Entities)
|
||||
if (entity.Intersects(ray, out var hits))
|
||||
foreach (var hit in hits)
|
||||
nearest = System.Math.Min(nearest, hit.DistanceTo(point));
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private static Vector LeftOf(Vector tangent)
|
||||
{
|
||||
var length = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||
return length <= 0 ? new Vector(0, 1) : new Vector(-tangent.Y / length, tangent.X / length);
|
||||
}
|
||||
|
||||
private static Vector Tangent(Entity entity, bool atEnd)
|
||||
{
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
var angle = atEnd ? arc.EndAngle : arc.StartAngle;
|
||||
return arc.IsReversed
|
||||
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
||||
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
||||
}
|
||||
var line = (Line)entity;
|
||||
return line.EndPoint - line.StartPoint;
|
||||
}
|
||||
|
||||
private static Vector GetShapeStartPoint(Shape shape)
|
||||
{
|
||||
var first = shape.Entities[0];
|
||||
|
||||
@@ -24,6 +24,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
public double PierceClearance { get; set; } = 0.0625;
|
||||
|
||||
/// <summary>
|
||||
/// Diameter of the crater a pierce blows through this plate; 0 turns the pierce zone off.
|
||||
/// When set, every lead-in pierce must keep half of it from all part material.
|
||||
/// </summary>
|
||||
public double PierceDiameter { get; set; }
|
||||
|
||||
/// <summary>The pierce zone's radius, or 0 when <see cref="PierceDiameter"/> is off.</summary>
|
||||
public double GetPierceZoneRadius() =>
|
||||
PierceDiameter > 0 && double.IsFinite(PierceDiameter) ? PierceDiameter / 2 : 0;
|
||||
|
||||
/// <summary>The clearance a pierce keeps from its own contour: the larger setting wins.</summary>
|
||||
public double GetEffectivePierceClearance() => System.Math.Max(PierceClearance, GetPierceZoneRadius());
|
||||
|
||||
public bool RoundLeadInAngles { get; set; }
|
||||
public double LeadInAngleIncrement { get; set; } = 5.0;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum LayerType
|
||||
{
|
||||
@@ -7,5 +7,19 @@
|
||||
Cut,
|
||||
Leadin,
|
||||
Leadout,
|
||||
|
||||
/// <summary>
|
||||
/// An intentionally open cut through part material, left uncut at tabs (a breakoff's
|
||||
/// break line). It cuts like <see cref="Cut"/> but is not a material boundary; the scrap
|
||||
/// it may be pierced from lies to the left of travel.
|
||||
/// </summary>
|
||||
Break,
|
||||
|
||||
/// <summary>
|
||||
/// An uncut span of a material boundary (a tabbed cutout's tab). It bounds material like
|
||||
/// <see cref="Cut"/> so the cutout stays a closed hole, but it is never cut: emission
|
||||
/// leaves it out, cutting the rest of the contour as open pieces.
|
||||
/// </summary>
|
||||
Tab,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// Re-times <see cref="CutSimulationStep"/>s with trapezoidal acceleration, in the style of
|
||||
/// Grbl's lookahead planner. Each cutting run (moves between a pierce and the next travel)
|
||||
/// starts and ends at rest. Corners slow to a junction-deviation speed, arcs are capped at
|
||||
/// their centripetal limit, and a backward then forward pass keeps every change of speed
|
||||
/// within the acceleration. Travel and pierces are planned on their own, from rest to rest.
|
||||
/// </summary>
|
||||
public static class MotionPlanner
|
||||
{
|
||||
private const double Straight = 0.999999;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces each step's <see cref="CutSimulationStep.Duration"/> with its accelerated time,
|
||||
/// from its <see cref="CutSimulationStep.NominalSpeed"/>. Pierces keep their dwell.
|
||||
/// </summary>
|
||||
public static void Plan(List<CutSimulationStep> steps, MotionProfile motion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(steps);
|
||||
ArgumentNullException.ThrowIfNull(motion);
|
||||
|
||||
var start = 0;
|
||||
while (start < steps.Count)
|
||||
{
|
||||
var end = start + 1;
|
||||
if (IsCutting(steps[start]))
|
||||
{
|
||||
while (end < steps.Count && IsCutting(steps[end]) && steps[end].Kind == steps[start].Kind)
|
||||
end++;
|
||||
PlanRun(steps, start, end, motion.CutAcceleration, motion.CornerDeviation);
|
||||
}
|
||||
else if (steps[start].Kind == CutSimulationStepKind.Rapid)
|
||||
{
|
||||
PlanRun(steps, start, end, motion.RapidAcceleration, 0);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCutting(CutSimulationStep step) =>
|
||||
step.Kind is CutSimulationStepKind.Cut or CutSimulationStepKind.Scribe;
|
||||
|
||||
private static void PlanRun(List<CutSimulationStep> steps, int start, int end, double acceleration, double deviation)
|
||||
{
|
||||
if (acceleration <= 0)
|
||||
return; // Keep the constant-speed timing.
|
||||
|
||||
var count = end - start;
|
||||
var maxSpeed = new double[count];
|
||||
var entry = new double[count + 1]; // entry[count] is the run's exit: at rest.
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var step = steps[start + i];
|
||||
var nominal = step.NominalSpeed;
|
||||
maxSpeed[i] = step.IsArc && step.Radius > 0
|
||||
? System.Math.Min(nominal, System.Math.Sqrt(acceleration * step.Radius))
|
||||
: nominal;
|
||||
entry[i] = i == 0 ? 0 : JunctionSpeed(steps[start + i - 1], step, acceleration, deviation, System.Math.Min(maxSpeed[i - 1], maxSpeed[i]));
|
||||
}
|
||||
|
||||
// Backward: every step must be able to slow to the next one's entry.
|
||||
for (var i = count - 1; i >= 0; i--)
|
||||
entry[i] = System.Math.Min(entry[i], Reachable(entry[i + 1], acceleration, steps[start + i].Length));
|
||||
|
||||
// Forward: and to reach that entry from its own.
|
||||
for (var i = 0; i < count; i++)
|
||||
entry[i + 1] = System.Math.Min(entry[i + 1], Reachable(entry[i], acceleration, steps[start + i].Length));
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
Profile(steps[start + i], entry[i], entry[i + 1], maxSpeed[i], acceleration);
|
||||
}
|
||||
|
||||
private static double Reachable(double speed, double acceleration, double length) =>
|
||||
System.Math.Sqrt(speed * speed + 2 * acceleration * length);
|
||||
|
||||
/// <summary>
|
||||
/// The fastest the head may pass from <paramref name="previous"/> into <paramref name="next"/>:
|
||||
/// v² = a·δ·sin(θ/2) / (1 − sin(θ/2)), θ being the angle the path turns through.
|
||||
/// </summary>
|
||||
internal static double JunctionSpeed(CutSimulationStep previous, CutSimulationStep next, double acceleration, double deviation, double cap)
|
||||
{
|
||||
var exit = previous.TangentAt(1);
|
||||
var enter = next.TangentAt(0);
|
||||
var cosTheta = -(exit.X * enter.X + exit.Y * enter.Y);
|
||||
|
||||
if (cosTheta < -Straight)
|
||||
return cap;
|
||||
if (cosTheta > Straight || deviation <= 0)
|
||||
return 0;
|
||||
|
||||
var sinHalf = System.Math.Sqrt(0.5 * (1 - cosTheta));
|
||||
var speed = System.Math.Sqrt(acceleration * deviation * sinHalf / (1 - sinHalf));
|
||||
return System.Math.Min(cap, speed);
|
||||
}
|
||||
|
||||
private static void Profile(CutSimulationStep step, double entry, double exit, double maxSpeed, double acceleration)
|
||||
{
|
||||
var length = step.Length;
|
||||
if (length <= 0 || maxSpeed <= 0)
|
||||
return; // Nothing to accelerate: keep the constant-speed timing.
|
||||
|
||||
var cruise = maxSpeed;
|
||||
var accelDistance = (cruise * cruise - entry * entry) / (2 * acceleration);
|
||||
var decelDistance = (cruise * cruise - exit * exit) / (2 * acceleration);
|
||||
if (accelDistance + decelDistance > length)
|
||||
{
|
||||
// Too short to reach full speed: a triangle peaking where the two ramps meet.
|
||||
cruise = System.Math.Sqrt((2 * acceleration * length + entry * entry + exit * exit) / 2);
|
||||
cruise = System.Math.Max(cruise, System.Math.Max(entry, exit));
|
||||
accelDistance = (cruise * cruise - entry * entry) / (2 * acceleration);
|
||||
decelDistance = (cruise * cruise - exit * exit) / (2 * acceleration);
|
||||
}
|
||||
|
||||
var cruiseDistance = System.Math.Max(0, length - accelDistance - decelDistance);
|
||||
var seconds = (cruise - entry) / acceleration + (cruise - exit) / acceleration
|
||||
+ (cruise > 0 ? cruiseDistance / cruise : 0);
|
||||
|
||||
step.EntrySpeed = entry;
|
||||
step.CruiseSpeed = cruise;
|
||||
step.ExitSpeed = exit;
|
||||
step.Acceleration = acceleration;
|
||||
step.Duration = TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,16 +31,16 @@ namespace OpenNest.CNC
|
||||
|
||||
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||
{
|
||||
// Draw the rapid from the previous tool position to the program's first
|
||||
// pierce point. The walk then starts at the program origin (basePos), not
|
||||
// the pierce: the skipped first rapid still advances pos, so starting at
|
||||
// the pierce would apply a nonzero Incremental first delta twice (as in
|
||||
// lead-in programs) and shift every later rapid by it.
|
||||
// Draw the approach to the first rapid destination, or to the program
|
||||
// origin when it starts cutting without a rapid. Only an initial rapid
|
||||
// (or call arrival) is already represented by this approach; a later
|
||||
// first rapid must remain visible. Walk starts at the program origin,
|
||||
// so an incremental initial rapid is not applied twice.
|
||||
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||
results.Add(new Segment(startPos, firstPierce));
|
||||
|
||||
var pos = basePos;
|
||||
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
||||
Walk(pgm, basePos, ref pos, skipFirst: StartsWithRapidOrCall(pgm), results);
|
||||
// The last rapid ends at a pierce, not necessarily the final tool position.
|
||||
return pos;
|
||||
}
|
||||
@@ -53,11 +53,23 @@ namespace OpenNest.CNC
|
||||
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
||||
|
||||
if (pgm[i] is Motion motion)
|
||||
return motion.EndPoint + basePos;
|
||||
return motion is RapidMove ? motion.EndPoint + basePos : basePos;
|
||||
}
|
||||
return basePos;
|
||||
}
|
||||
|
||||
private static bool StartsWithRapidOrCall(Program pgm)
|
||||
{
|
||||
for (var i = 0; i < pgm.Length; i++)
|
||||
{
|
||||
if (pgm[i] is SubProgramCall { Program: not null })
|
||||
return true;
|
||||
if (pgm[i] is Motion motion)
|
||||
return motion is RapidMove;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Walk(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
@@ -83,7 +95,7 @@ namespace OpenNest.CNC
|
||||
results.Add(new Segment(pos, firstPierce));
|
||||
|
||||
var subPos = holeBase;
|
||||
Walk(program, holeBase, ref subPos, skipFirst: true, results);
|
||||
Walk(program, holeBase, ref subPos, skipFirst: StartsWithRapidOrCall(program), results);
|
||||
pos = subPos;
|
||||
}
|
||||
else if (code is Motion motion)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// The sheet edge a <see cref="ClampRail"/> clamp sits on, named relative to the plate
|
||||
/// origin so one rail resolves onto every sheet size and quadrant.
|
||||
/// </summary>
|
||||
public enum ClampEdge
|
||||
{
|
||||
/// <summary>The edge along X through the origin (operator side).</summary>
|
||||
OriginSide = 0,
|
||||
|
||||
/// <summary>The edge along X across the sheet's width from the origin.</summary>
|
||||
FarSide = 1,
|
||||
|
||||
/// <summary>The edge along Y through the origin.</summary>
|
||||
OriginEnd = 2,
|
||||
|
||||
/// <summary>The edge along Y across the sheet's length from the origin.</summary>
|
||||
FarEnd = 3,
|
||||
}
|
||||
|
||||
public static class ClampEdges
|
||||
{
|
||||
/// <summary>True for the two edges along X, whose clamp positions are X distances.</summary>
|
||||
public static bool RunsAlongX(this ClampEdge edge) =>
|
||||
edge is ClampEdge.OriginSide or ClampEdge.FarSide;
|
||||
|
||||
public static bool IsDefined(ClampEdge edge) =>
|
||||
edge is ClampEdge.OriginSide or ClampEdge.FarSide or ClampEdge.OriginEnd or ClampEdge.FarEnd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One clamp of a <see cref="ClampRail"/>: its edge and its center measured from the plate
|
||||
/// origin along that edge (X for the sides, Y for the ends). Orders by edge, then center.
|
||||
/// </summary>
|
||||
public readonly record struct RailClamp(ClampEdge Edge, double Center) : IComparable<RailClamp>
|
||||
{
|
||||
public int CompareTo(RailClamp other)
|
||||
{
|
||||
var edge = Edge.CompareTo(other.Edge);
|
||||
return edge != 0 ? edge : Center.CompareTo(other.Center);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// The machine's fixed clamp rail: identical rectangular clamps, each on one sheet edge
|
||||
/// (<see cref="ClampEdge"/>) with its center measured from the plate origin along that
|
||||
/// edge. Plates always start at the origin, so the same rail resolves onto every sheet
|
||||
/// size without re-scaling. A clamp whose footprint lies wholly beyond a sheet is skipped;
|
||||
/// one that still reaches onto it is kept whole: the zone is an avoidance area, so its
|
||||
/// full footprint applies even where it hangs past the sheet.
|
||||
/// </summary>
|
||||
public sealed class ClampRail : IEquatable<ClampRail>
|
||||
{
|
||||
/// <summary>
|
||||
/// Planner default minutes to remove one clamp and put it back after cutting (D9),
|
||||
/// used when a caller does not supply its own value.
|
||||
/// </summary>
|
||||
public const double DefaultRemovalMinutes = 5.0;
|
||||
|
||||
/// <summary>A rail whose clamps all sit on the origin-side edge.</summary>
|
||||
public ClampRail(
|
||||
double width,
|
||||
double depth,
|
||||
IEnumerable<double> centers,
|
||||
double removalMinutes = DefaultRemovalMinutes
|
||||
)
|
||||
: this(
|
||||
width,
|
||||
depth,
|
||||
(centers ?? throw new ArgumentNullException(nameof(centers)))
|
||||
.Select(c => new RailClamp(ClampEdge.OriginSide, c)),
|
||||
removalMinutes
|
||||
)
|
||||
{ }
|
||||
|
||||
public ClampRail(
|
||||
double width,
|
||||
double depth,
|
||||
IEnumerable<RailClamp> clamps,
|
||||
double removalMinutes = DefaultRemovalMinutes
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(clamps);
|
||||
if (!double.IsFinite(width) || width <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(width),
|
||||
"Clamp width must be finite and positive."
|
||||
);
|
||||
if (!double.IsFinite(depth) || depth <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(depth),
|
||||
"Clamp depth must be finite and positive."
|
||||
);
|
||||
if (!double.IsFinite(removalMinutes) || removalMinutes < 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(removalMinutes),
|
||||
"Clamp removal time must be finite and zero or more."
|
||||
);
|
||||
|
||||
var sorted = new SortedSet<RailClamp>();
|
||||
foreach (var clamp in clamps)
|
||||
{
|
||||
if (!double.IsFinite(clamp.Center) || clamp.Center < 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
"centers",
|
||||
"Clamp centers must be finite and measured from the origin (zero or more)."
|
||||
);
|
||||
if (!ClampEdges.IsDefined(clamp.Edge))
|
||||
throw new ArgumentOutOfRangeException(nameof(clamps), "Unknown clamp edge.");
|
||||
sorted.Add(clamp);
|
||||
}
|
||||
|
||||
Width = width;
|
||||
Depth = depth;
|
||||
RemovalMinutes = removalMinutes;
|
||||
Clamps = sorted.ToArray();
|
||||
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Clamp extent along its edge.</summary>
|
||||
public double Width { get; }
|
||||
|
||||
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
|
||||
public double Depth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Minutes to remove one clamp and put it back after cutting (D9). Added to a sheet's
|
||||
/// cut-time estimate once per clamp <see cref="Plate.RemovedClamps"/> lists there.
|
||||
/// </summary>
|
||||
public double RemovalMinutes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Distinct clamps ordered by edge, then center. A clamp's position in this list
|
||||
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
|
||||
/// it had before clamps could sit on other edges.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RailClamp> Clamps { get; }
|
||||
|
||||
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
|
||||
public IReadOnlyList<double> Centers { get; }
|
||||
|
||||
/// <summary>A copy with the same size and removal time and these clamps.</summary>
|
||||
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
|
||||
new(Width, Depth, clamps, RemovalMinutes);
|
||||
|
||||
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
|
||||
public static string IdOf(int index) => $"clamp-{index + 1}";
|
||||
|
||||
/// <summary>
|
||||
/// The footprint <paramref name="clamp"/> would have on a sheet, in plate coordinates
|
||||
/// (lower-left corner origin), or null when it lies wholly beyond the sheet. The clamp
|
||||
/// need not belong to this rail; it takes this rail's width and depth.
|
||||
/// </summary>
|
||||
public Box Footprint(RailClamp clamp, Size size, int quadrant) =>
|
||||
Footprint(clamp, Width, Depth, size, quadrant);
|
||||
|
||||
/// <summary>
|
||||
/// The footprint of a <paramref name="width"/> by <paramref name="depth"/> clamp at
|
||||
/// <paramref name="clamp"/>; see <see cref="Footprint(RailClamp, Size, int)"/>.
|
||||
/// </summary>
|
||||
public static Box Footprint(RailClamp clamp, double width, double depth, Size size, int quadrant)
|
||||
{
|
||||
var length = size.Length;
|
||||
var sheetWidth = size.Width;
|
||||
if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth))
|
||||
return null;
|
||||
|
||||
var extendsLeft = quadrant is 2 or 3;
|
||||
var extendsDown = quadrant is 3 or 4;
|
||||
var half = width / 2;
|
||||
// Distances from the origin along the edge.
|
||||
var near = clamp.Center - half;
|
||||
var far = clamp.Center + half;
|
||||
|
||||
if (clamp.Edge.RunsAlongX())
|
||||
{
|
||||
if (near >= length)
|
||||
return null; // wholly beyond this sheet
|
||||
// The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y.
|
||||
var atTop = (clamp.Edge == ClampEdge.FarSide) != extendsDown;
|
||||
// Plate coordinates are measured from the lower-left corner; a sheet extending
|
||||
// to -X from the origin has its origin edge at x = length.
|
||||
var x = extendsLeft ? length - far : near;
|
||||
return new Box(x, atTop ? sheetWidth - depth : 0, width, depth);
|
||||
}
|
||||
|
||||
if (near >= sheetWidth)
|
||||
return null;
|
||||
var atRight = (clamp.Edge == ClampEdge.FarEnd) != extendsLeft;
|
||||
var y = extendsDown ? sheetWidth - far : near;
|
||||
return new Box(atRight ? length - depth : 0, y, depth, width);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The zones this rail places on a sheet, in plate coordinates (the frame of
|
||||
/// <see cref="Plate.ClampZones"/>: origin at the sheet's lower-left corner). The
|
||||
/// quadrant locates the plate origin, and with it the origin-side edge, on the sheet.
|
||||
/// Zones are whole clamp footprints, never clipped, so one reaching past a sheet end
|
||||
/// (or deeper than a narrow sheet) extends beyond the sheet. Ids are <c>clamp-N</c> by
|
||||
/// rail position, so one physical clamp keeps its id on every sheet even when others
|
||||
/// are skipped.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ClampZone> ResolveFor(Size size, int quadrant)
|
||||
{
|
||||
var zones = new List<ClampZone>(Clamps.Count);
|
||||
for (var i = 0; i < Clamps.Count; i++)
|
||||
{
|
||||
var box = Footprint(Clamps[i], size, quadrant);
|
||||
if (box != null)
|
||||
zones.Add(new ClampZone(IdOf(i), box));
|
||||
}
|
||||
|
||||
return zones;
|
||||
}
|
||||
|
||||
/// <summary>True when the plate's zones are exactly (ids and bits) this rail's for its sheet.</summary>
|
||||
public bool Matches(Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the plate's zones are exactly this rail's resolution for its sheet minus
|
||||
/// the clamps named in <paramref name="removedClamps"/> (D8 last-resort removal, C13).
|
||||
/// A null or empty set is the same check as <see cref="Matches(Plate)"/>.
|
||||
/// </summary>
|
||||
public bool Matches(Plate plate, IReadOnlyCollection<string> removedClamps)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
if (removedClamps == null || removedClamps.Count == 0)
|
||||
return Matches(plate);
|
||||
var expected = ResolveFor(plate.Size, plate.Quadrant)
|
||||
.Where(z => !removedClamps.Contains(z.Id))
|
||||
.ToList();
|
||||
return SameZones(plate.ClampZones, expected);
|
||||
}
|
||||
|
||||
internal static bool SameZones(IList<ClampZone> current, IReadOnlyList<ClampZone> expected)
|
||||
{
|
||||
if (current.Count != expected.Count)
|
||||
return false;
|
||||
for (var i = 0; i < current.Count; i++)
|
||||
{
|
||||
var a = current[i];
|
||||
var b = expected[i];
|
||||
if (
|
||||
!string.Equals(a.Id, b.Id, StringComparison.Ordinal)
|
||||
|| a.Bounds.X != b.Bounds.X
|
||||
|| a.Bounds.Y != b.Bounds.Y
|
||||
|| a.Bounds.Length != b.Bounds.Length
|
||||
|| a.Bounds.Width != b.Bounds.Width
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Equals(ClampRail other) =>
|
||||
other is not null
|
||||
&& Width.Equals(other.Width)
|
||||
&& Depth.Equals(other.Depth)
|
||||
&& RemovalMinutes.Equals(other.RemovalMinutes)
|
||||
&& Clamps.SequenceEqual(other.Clamps);
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as ClampRail);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count);
|
||||
foreach (var clamp in Clamps)
|
||||
hash = HashCode.Combine(hash, clamp);
|
||||
return hash;
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]";
|
||||
|
||||
private static string Describe(RailClamp clamp) =>
|
||||
clamp.Edge == ClampEdge.OriginSide
|
||||
? clamp.Center.ToString("R")
|
||||
: $"{clamp.Edge} {clamp.Center:R}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
|
||||
/// origin at the plate corner, X along the edge, Y depth from it). Machine edge clamps
|
||||
/// are the first users; a future "don't cut here" tag is another entry of this shape.
|
||||
/// Zones are exclusion and display only - never material, quantity, utilization,
|
||||
/// cut sequence or CNC output. Rectangles only: axis-aligned by construction.
|
||||
/// </summary>
|
||||
public sealed class ClampZone
|
||||
{
|
||||
public ClampZone(string id, Box bounds)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentNullException.ThrowIfNull(bounds);
|
||||
if (
|
||||
!double.IsFinite(bounds.X)
|
||||
|| !double.IsFinite(bounds.Y)
|
||||
|| !double.IsFinite(bounds.Length)
|
||||
|| !double.IsFinite(bounds.Width)
|
||||
)
|
||||
throw new ArgumentException(
|
||||
$"Clamp zone '{id}' bounds must have finite coordinates and size.",
|
||||
nameof(bounds)
|
||||
);
|
||||
if (bounds.Length <= 0 || bounds.Width <= 0)
|
||||
throw new ArgumentException(
|
||||
$"Clamp zone '{id}' must have a positive length (X) and width (Y).",
|
||||
nameof(bounds)
|
||||
);
|
||||
|
||||
Id = id;
|
||||
// Detached copy: the zone owns its rectangle, callers may keep mutating theirs.
|
||||
Bounds = new Box(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
/// <summary>Stable identifier used by persistence and diagnostics.</summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The rectangle in plate coordinates: Length is the extent along X, Width the
|
||||
/// extent along Y (depth from the y = 0 operator-side edge for machine clamps).
|
||||
/// </summary>
|
||||
public Box Bounds { get; }
|
||||
|
||||
public override string ToString() =>
|
||||
$"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
|
||||
/// (<see cref="ClampRail.ResolveFor"/>), into the signed placement frame of its parts,
|
||||
/// cut-offs and motions. The translation matches the sheet itself and the engine stock:
|
||||
/// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are
|
||||
/// kept, never clipped. Every plate uses the sheet frame, including plates the engine builds
|
||||
/// from a <c>NestPlateStock</c>, so this is the only translation any consumer applies.
|
||||
/// </summary>
|
||||
public static class ClampZoneFrame
|
||||
{
|
||||
/// <summary>
|
||||
/// Detached placement-frame copies in the plate's zone order; empty when there are none.
|
||||
/// Throws <see cref="ArgumentException"/> for a missing or malformed zone: zone bounds stay
|
||||
/// publicly mutable, so construction-time validation is not proof of current geometry.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ClampZone> ToPlacement(Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
var zones = plate.ClampZones;
|
||||
if (zones == null || zones.Count == 0)
|
||||
return Array.Empty<ClampZone>();
|
||||
if (plate.Quadrant is < 1 or > 4)
|
||||
throw new ArgumentException("Quadrant must be 1 through 4.", nameof(plate));
|
||||
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
|
||||
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
|
||||
if (!double.IsFinite(left) || !double.IsFinite(bottom))
|
||||
throw new ArgumentException("Sheet size must be finite.", nameof(plate));
|
||||
|
||||
var mapped = new ClampZone[zones.Count];
|
||||
for (var i = 0; i < mapped.Length; i++)
|
||||
{
|
||||
var zone = zones[i];
|
||||
if (zone?.Bounds == null)
|
||||
throw new ArgumentException($"Clamp zone {i + 1} is missing.", nameof(plate));
|
||||
var box = zone.Bounds;
|
||||
var x = box.X + left;
|
||||
var y = box.Y + bottom;
|
||||
if (!(box.Length > 0) || !(box.Width > 0) || !double.IsFinite(box.Length)
|
||||
|| !double.IsFinite(box.Width) || !double.IsFinite(box.X) || !double.IsFinite(box.Y)
|
||||
|| !double.IsFinite(x) || !double.IsFinite(y)
|
||||
|| !double.IsFinite(x + box.Length) || !double.IsFinite(y + box.Width))
|
||||
throw new ArgumentException(
|
||||
$"Clamp zone '{zone.Id}' must have finite bounds and a positive size.", nameof(plate));
|
||||
mapped[i] = new ClampZone(zone.Id, new Box(x, y, box.Length, box.Width));
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
@@ -9,7 +9,24 @@ namespace OpenNest.Converters
|
||||
{
|
||||
public static Program ToProgram(IList<Entity> geometry)
|
||||
{
|
||||
var shapes = ShapeBuilder.GetShapes(geometry);
|
||||
// Break cuts keep their order and direction (their scrap side): they are emitted as
|
||||
// given, one program run per continuous chain, before the contours.
|
||||
var breakCuts = new List<Shape>();
|
||||
var contours = new List<Entity>();
|
||||
foreach (var entity in geometry)
|
||||
{
|
||||
if (entity.Layer != SpecialLayers.Break)
|
||||
{
|
||||
contours.Add(entity);
|
||||
continue;
|
||||
}
|
||||
var last = breakCuts.Count == 0 ? null : breakCuts[^1].Entities[^1];
|
||||
if (last == null || EndOf(last).DistanceTo(StartOf(entity)) > Tolerance.Epsilon)
|
||||
breakCuts.Add(new Shape());
|
||||
breakCuts[^1].Entities.Add(entity);
|
||||
}
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(contours);
|
||||
|
||||
if (shapes.Count == 0)
|
||||
return null;
|
||||
@@ -34,6 +51,9 @@ namespace OpenNest.Converters
|
||||
|
||||
var pgm = new Program();
|
||||
|
||||
foreach (var cut in breakCuts)
|
||||
pgm.Merge(ToProgram(cut));
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var subpgm = ToProgram(shape);
|
||||
@@ -46,6 +66,10 @@ namespace OpenNest.Converters
|
||||
return pgm;
|
||||
}
|
||||
|
||||
private static Vector StartOf(Entity entity) => entity is Arc arc ? arc.StartPoint() : ((Line)entity).StartPoint;
|
||||
|
||||
private static Vector EndOf(Entity entity) => entity is Arc arc ? arc.EndPoint() : ((Line)entity).EndPoint;
|
||||
|
||||
public static Program ToProgram(Shape shape)
|
||||
{
|
||||
var pgm = new Program();
|
||||
@@ -153,6 +177,12 @@ namespace OpenNest.Converters
|
||||
)
|
||||
return LayerType.Scribe;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.Break.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Break;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Tab;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
@@ -207,6 +207,12 @@ namespace OpenNest.Converters
|
||||
case LayerType.Scribe:
|
||||
return SpecialLayers.Scribe;
|
||||
|
||||
case LayerType.Break:
|
||||
return SpecialLayers.Break;
|
||||
|
||||
case LayerType.Tab:
|
||||
return SpecialLayers.Tab;
|
||||
|
||||
default:
|
||||
return new Layer(layer.ToString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks
|
||||
/// only in each placed part's saved cutting program; the drawing itself had none, so removing
|
||||
/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too.
|
||||
/// </summary>
|
||||
public static class DrawingMarkRecovery
|
||||
{
|
||||
private const double MatchTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// For each drawing without etch marks whose saved placed programs all carry the same marks,
|
||||
/// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed
|
||||
/// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that
|
||||
/// already have marks, are left alone. Returns the number of drawings that were given marks.
|
||||
/// </summary>
|
||||
public static int Apply(Nest nest)
|
||||
{
|
||||
if (nest == null)
|
||||
return 0;
|
||||
|
||||
var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList();
|
||||
var recovered = 0;
|
||||
foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance))
|
||||
{
|
||||
var drawing = (Drawing)group.Key;
|
||||
if (Apply(drawing, group.ToList()))
|
||||
recovered++;
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
/// <summary>Recovers marks for one drawing from its placed parts; true when marks were added.</summary>
|
||||
public static bool Apply(Drawing drawing, IReadOnlyList<Part> placedParts)
|
||||
{
|
||||
if (drawing?.Program == null || drawing.IsCutOff || placedParts == null)
|
||||
return false;
|
||||
// An incremental call's end position is not tracked by Program.EndPoint, so only
|
||||
// flat drawings (which is what conversion produces) can be extended safely.
|
||||
if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall))
|
||||
return false;
|
||||
|
||||
var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns)
|
||||
.ToList();
|
||||
if (saved.Count == 0)
|
||||
return false;
|
||||
|
||||
var marks = MarksInDrawingFrame(saved[0]);
|
||||
if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part))))
|
||||
return false;
|
||||
|
||||
AppendMarks(drawing.Program, marks);
|
||||
drawing.UpdateArea();
|
||||
drawing.RecomputeCanonicalAngle();
|
||||
|
||||
foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing)
|
||||
&& !part.HasManualLeadIns))
|
||||
part.Update();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasMarks(Program program) =>
|
||||
program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }
|
||||
|| code is SubProgramCall { Program: not null } call && HasMarks(call.Program));
|
||||
|
||||
/// <summary>
|
||||
/// The part's marks as they would sit in its drawing: <see cref="Part.RemoveLeadIns"/>
|
||||
/// rebuilds the part as the drawing turned by <see cref="Part.Rotation"/>, so undo that turn.
|
||||
/// </summary>
|
||||
private static List<Entity> MarksInDrawingFrame(Part part)
|
||||
{
|
||||
var marks = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(entity => entity.Layer == SpecialLayers.Scribe)
|
||||
.ToList();
|
||||
if (!Tolerance.IsEqualTo(part.Rotation, 0))
|
||||
foreach (var mark in marks)
|
||||
mark.Rotate(-part.Rotation);
|
||||
return marks;
|
||||
}
|
||||
|
||||
private static bool SameMarks(List<Entity> expected, List<Entity> actual)
|
||||
{
|
||||
if (expected.Count != actual.Count)
|
||||
return false;
|
||||
var unmatched = actual.ToList();
|
||||
foreach (var mark in expected)
|
||||
{
|
||||
var index = unmatched.FindIndex(other => SameMark(mark, other));
|
||||
if (index < 0)
|
||||
return false;
|
||||
unmatched.RemoveAt(index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Direction does not matter for an etch: both marks must lie on each other.
|
||||
private static bool SameMark(Entity a, Entity b) =>
|
||||
a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a);
|
||||
|
||||
private static bool LiesOn(List<Vector> points, Entity entity) =>
|
||||
points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
|
||||
|
||||
private static List<Vector> Samples(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new List<Vector>
|
||||
{
|
||||
line.StartPoint,
|
||||
line.EndPoint,
|
||||
new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
|
||||
},
|
||||
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
|
||||
Circle circle => new List<Vector>
|
||||
{
|
||||
new(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
new(circle.Center.X - circle.Radius, circle.Center.Y),
|
||||
new(circle.Center.X, circle.Center.Y + circle.Radius),
|
||||
new(circle.Center.X, circle.Center.Y - circle.Radius),
|
||||
},
|
||||
_ => new List<Vector>(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Appends the marks after the drawing's existing motion, so the existing codes and the
|
||||
/// drawing's first-rapid origin stay exactly as they were.
|
||||
/// </summary>
|
||||
private static void AppendMarks(Program program, List<Entity> marks)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(marks);
|
||||
var markProgram = ConvertGeometry.ToProgram(shape);
|
||||
|
||||
// Start from the drawing's end point, with an explicit rapid onto the first mark.
|
||||
var start = EndOf(program);
|
||||
var first = markProgram.Codes.OfType<Motion>().First();
|
||||
var positioned = new Program(Mode.Absolute);
|
||||
positioned.Codes.Add(new RapidMove(start));
|
||||
if (first is not RapidMove)
|
||||
positioned.Codes.Add(new RapidMove(StartOf(marks[0])));
|
||||
positioned.Codes.AddRange(markProgram.Codes);
|
||||
|
||||
if (program.Mode == Mode.Incremental)
|
||||
positioned.Mode = Mode.Incremental;
|
||||
// Drop the move onto the drawing's end point; it is where the drawing already stops.
|
||||
program.Codes.AddRange(positioned.Codes.Skip(1));
|
||||
}
|
||||
|
||||
// Where a flat program leaves the head (callers exclude sub-program calls).
|
||||
private static Vector EndOf(Program program)
|
||||
{
|
||||
var position = Vector.Zero;
|
||||
foreach (var motion in program.Codes.OfType<Motion>())
|
||||
position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint;
|
||||
return position;
|
||||
}
|
||||
|
||||
private static Vector StartOf(Entity entity) => entity switch
|
||||
{
|
||||
Line line => line.StartPoint,
|
||||
Arc arc => arc.StartPoint(),
|
||||
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
_ => Vector.Zero,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
/// <summary>What a drawing does with a source entity.</summary>
|
||||
public enum EntityRole
|
||||
{
|
||||
/// <summary>Cut through the material.</summary>
|
||||
Cut,
|
||||
|
||||
/// <summary>Marked on the surface (a Scribe pass): never material.</summary>
|
||||
Etch,
|
||||
|
||||
/// <summary>
|
||||
/// Shown with the part for reference only. It is kept out of the program, so it is never
|
||||
/// cut, posted, nested against or verified.
|
||||
/// </summary>
|
||||
Display,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies per-entity role overrides. An entity without an override keeps the role its layer
|
||||
/// implies: mark layers (ETCH, SCRIBE, ENGRAVE) etch, everything else cuts.
|
||||
/// </summary>
|
||||
public static class EntityRoles
|
||||
{
|
||||
public static EntityRole Default(Entity entity) =>
|
||||
ScribeLayerRepair.IsMarkLayer(entity.Layer) ? EntityRole.Etch : EntityRole.Cut;
|
||||
|
||||
public static EntityRole Of(Entity entity, IReadOnlyDictionary<Guid, EntityRole> overrides) =>
|
||||
overrides != null && overrides.TryGetValue(entity.Id, out var role) ? role : Default(entity);
|
||||
|
||||
/// <summary>
|
||||
/// The entities a program is built from: display-only entities are left out and entities
|
||||
/// whose override differs from their layer's role are replaced by copies, with the same
|
||||
/// ID, on a layer the converters read as that role. Inputs are never mutated.
|
||||
/// </summary>
|
||||
public static List<Entity> ForProgram(IEnumerable<Entity> entities, IReadOnlyDictionary<Guid, EntityRole> overrides)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (overrides == null || !overrides.TryGetValue(entity.Id, out var role) || role == Default(entity))
|
||||
{
|
||||
result.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (role == EntityRole.Display)
|
||||
continue;
|
||||
|
||||
var copy = entity.Clone();
|
||||
copy.Id = entity.Id;
|
||||
copy.Layer = new Layer(role == EntityRole.Etch ? SpecialLayers.Scribe.Name : SpecialLayers.Cut.Name)
|
||||
{
|
||||
Color = entity.Layer?.Color ?? SpecialLayers.Cut.Color,
|
||||
IsVisible = true,
|
||||
};
|
||||
result.Add(copy);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A drawing's unsuppressed display-only entities as copies in its program frame (source
|
||||
/// coordinates shifted by -<c>Source.Offset</c>, before any program rotation).
|
||||
/// </summary>
|
||||
public static List<Entity> DisplayGeometry(Drawing drawing)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
if (drawing?.SourceEntities == null || drawing.EntityRoles == null || drawing.EntityRoles.Count == 0)
|
||||
return result;
|
||||
|
||||
var offset = drawing.Source?.Offset ?? Vector.Zero;
|
||||
foreach (var entity in drawing.SourceEntities)
|
||||
{
|
||||
if (drawing.SuppressedEntityIds.Contains(entity.Id) || Of(entity, drawing.EntityRoles) != EntityRole.Display)
|
||||
continue;
|
||||
var copy = entity.Clone();
|
||||
copy.Id = entity.Id;
|
||||
copy.Offset(-offset.X, -offset.Y);
|
||||
result.Add(copy);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
-4
@@ -39,6 +39,72 @@ namespace OpenNest
|
||||
Drawing.Program = program;
|
||||
}
|
||||
|
||||
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
|
||||
internal (double Length, int SegmentCount) MeasureSegments(
|
||||
Plate plate,
|
||||
CutOffSettings settings,
|
||||
Dictionary<Part, Entity> cache = null
|
||||
)
|
||||
{
|
||||
var segments = ComputeSegments(plate, settings, cache);
|
||||
return (segments.Sum(s => s.End - s.Start), segments.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
||||
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
||||
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
||||
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
|
||||
/// </summary>
|
||||
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
|
||||
{
|
||||
if (part?.Program == null || definition == null || sheet == null
|
||||
|| part.Program.Mode != Mode.Absolute)
|
||||
return false;
|
||||
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||
var line = vertical ? definition.Position.X : definition.Position.Y;
|
||||
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
|
||||
var acrossMax = vertical ? sheet.Right : sheet.Top;
|
||||
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
||||
var alongMax = vertical ? sheet.Top : sheet.Right;
|
||||
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
|
||||
return false;
|
||||
|
||||
Vector? position = null;
|
||||
double? low = null, high = null;
|
||||
var cutting = false;
|
||||
foreach (var code in part.Program.Codes)
|
||||
{
|
||||
switch (code)
|
||||
{
|
||||
case RapidMove rapid:
|
||||
if (cutting)
|
||||
return false; // A second pierce leaves an uncut gap.
|
||||
position = rapid.EndPoint + part.Location;
|
||||
break;
|
||||
case LinearMove move when position != null:
|
||||
var end = move.EndPoint + part.Location;
|
||||
if (!OnLine(position.Value) || !OnLine(end))
|
||||
return false;
|
||||
foreach (var value in new[] { Along(position.Value), Along(end) })
|
||||
{
|
||||
low = low == null ? value : System.Math.Min(low.Value, value);
|
||||
high = high == null ? value : System.Math.Max(high.Value, value);
|
||||
}
|
||||
position = end;
|
||||
cutting = true;
|
||||
break;
|
||||
case Motion:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
|
||||
|
||||
bool OnLine(Vector point) =>
|
||||
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
|
||||
double Along(Vector point) => vertical ? point.Y : point.X;
|
||||
}
|
||||
|
||||
private string GetName()
|
||||
{
|
||||
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
||||
@@ -78,8 +144,21 @@ namespace OpenNest
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
Entity perimeter = null;
|
||||
cache?.TryGetValue(part, out perimeter);
|
||||
Entity perimeter;
|
||||
if (cache != null)
|
||||
{
|
||||
cache.TryGetValue(part, out perimeter);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No pre-built cache: this is a direct/uncached caller (e.g. an
|
||||
// external API consumer calling Regenerate without going through
|
||||
// Plate.RegenerateCutOffs or the UI handlers, which always pass a
|
||||
// cache). Build the same converted-material envelope on the fly so
|
||||
// this path gets the same safety as the cached one, instead of
|
||||
// silently trusting the part's bare BoundingBox.
|
||||
perimeter = Plate.BuildPartPerimeter(part);
|
||||
}
|
||||
var partExclusions = GetPartExclusions(
|
||||
part,
|
||||
perimeter,
|
||||
@@ -134,8 +213,22 @@ namespace OpenNest
|
||||
double clearance
|
||||
)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
var cached = part.BoundingBox;
|
||||
var roundoff = GetBoundsRoundoff(part);
|
||||
// Keep the farther of the cached and converted perimeter extents. The
|
||||
// perimeter's fine intersection/offset can miss a near-tangent sliver;
|
||||
// only a line in that narrow outer band needs the conservative box.
|
||||
var bb = cached;
|
||||
if (perimeter != null)
|
||||
{
|
||||
var converted = perimeter.BoundingBox;
|
||||
var left = System.Math.Min(cached.Left, converted.Left);
|
||||
var right = System.Math.Max(cached.Right, converted.Right);
|
||||
var bottom = System.Math.Min(cached.Bottom, converted.Bottom);
|
||||
var top = System.Math.Max(cached.Top, converted.Top);
|
||||
bb = new Box(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
var (cachedMin, cachedMax) = AxisBounds(cached, clearance);
|
||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||
// Match the planner's representational allowance in BOTH pruning and
|
||||
@@ -150,6 +243,8 @@ namespace OpenNest
|
||||
|
||||
if (perimeter != null)
|
||||
{
|
||||
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
|
||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||
var perimeterExclusions = IntersectPerimeter(
|
||||
perimeter,
|
||||
cutPosition,
|
||||
@@ -180,7 +275,16 @@ namespace OpenNest
|
||||
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;
|
||||
// Imported, nearly tangent arcs can amplify endpoint rounding when
|
||||
// ConvertProgram fits their centers to a chord. Keep that allowance
|
||||
// tiny and apply it to both pruning and fallback exclusions above;
|
||||
// larger refits still require an explicit geometry repair. The allowance is
|
||||
// absolute: rotated small arcs on ordinary sheets refit by ~5e-11, which a
|
||||
// magnitude-scaled step count would reject.
|
||||
var arcFitRoundoff = Math.Tolerance.Epsilon / 100000;
|
||||
return double.IsFinite(step)
|
||||
? System.Math.Min(System.Math.Max(8 * step, arcFitRoundoff), Math.Tolerance.Epsilon / 4)
|
||||
: 0;
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> IntersectPerimeter(
|
||||
@@ -192,6 +296,9 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
var offset = OffsetOutward(perimeter, clearance);
|
||||
if (offset != null && offset.All(target => target is Polygon))
|
||||
return SlicePolygons(offset.Cast<Polygon>(), cutPosition);
|
||||
|
||||
var usedOffset = offset != null;
|
||||
var targets = offset ?? new List<Entity> { perimeter };
|
||||
var cutLine = new Line(
|
||||
@@ -234,6 +341,51 @@ namespace OpenNest
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact cross-section of the clearance outline on the cut line. A vertex on the line
|
||||
/// counts as below it (half-open edges), so a line through a vertex counts one
|
||||
/// crossing when the outline passes through and zero or two when it only touches. Edges
|
||||
/// lying on the line are excluded whole. Unlike <see cref="Entity.Intersects"/>, which
|
||||
/// reports a shared vertex once per incident edge, this never needs the bounding-box
|
||||
/// fallback, so a lattice line through a vertex is trimmed like any other line.
|
||||
/// </summary>
|
||||
private List<(double Start, double End)> SlicePolygons(IEnumerable<Polygon> polygons, double cutPosition)
|
||||
{
|
||||
var crossings = new List<double>();
|
||||
var result = new List<(double Start, double End)>();
|
||||
foreach (var polygon in polygons)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
if (vertices.Count < 3)
|
||||
return null;
|
||||
var closed = vertices[0].X == vertices[^1].X && vertices[0].Y == vertices[^1].Y;
|
||||
var edges = closed ? vertices.Count - 1 : vertices.Count;
|
||||
for (var i = 0; i < edges; i++)
|
||||
{
|
||||
var from = vertices[i];
|
||||
var to = vertices[(i + 1) % vertices.Count];
|
||||
var (acrossFrom, alongFrom) = Split(from);
|
||||
var (acrossTo, alongTo) = Split(to);
|
||||
if (acrossFrom == cutPosition && acrossTo == cutPosition)
|
||||
result.Add((System.Math.Min(alongFrom, alongTo), System.Math.Max(alongFrom, alongTo)));
|
||||
else if (acrossFrom > cutPosition != acrossTo > cutPosition)
|
||||
crossings.Add(acrossFrom == cutPosition ? alongFrom
|
||||
: acrossTo == cutPosition ? alongTo
|
||||
: alongFrom + (cutPosition - acrossFrom) * (alongTo - alongFrom) / (acrossTo - acrossFrom));
|
||||
}
|
||||
}
|
||||
|
||||
if (crossings.Count % 2 != 0)
|
||||
return null;
|
||||
crossings.Sort();
|
||||
for (var i = 0; i < crossings.Count; i += 2)
|
||||
result.Add((crossings[i], crossings[i + 1]));
|
||||
return result;
|
||||
|
||||
(double Across, double Along) Split(Vector point) =>
|
||||
Axis == CutOffAxis.Vertical ? (point.X, point.Y) : (point.Y, point.X);
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
namespace OpenNest;
|
||||
|
||||
public class CutParameters
|
||||
{
|
||||
@@ -11,6 +11,18 @@ public class CutParameters
|
||||
public string PostProcessor { get; set; }
|
||||
public Units Units { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Acceleration limits; null estimates every move at full speed. Applied by
|
||||
/// <see cref="CNC.CutSimulation"/> and the plate/nest overloads of <see cref="Timing.CalculateTime(Plate, CutParameters)"/>.
|
||||
/// </summary>
|
||||
public MotionProfile Motion { get; set; }
|
||||
|
||||
/// <summary>Feed for interior contours (holes); null or zero uses <see cref="Feedrate"/>.</summary>
|
||||
public double? HoleFeedrate { get; set; }
|
||||
|
||||
/// <summary>Leave out contours that only scribe/etch, as the CI Fiber post does by default.</summary>
|
||||
public bool SkipScribe { get; set; }
|
||||
|
||||
public static CutParameters Default =>
|
||||
new()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Cutting process a library drawing is prepared for, stored by name so new
|
||||
/// processes can be added without a migration.
|
||||
/// </summary>
|
||||
public enum CutType
|
||||
{
|
||||
Laser,
|
||||
Waterjet,
|
||||
Plasma,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>A nominal manual cut-off line meeting an active, physical clamp footprint.</summary>
|
||||
public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only axis-aligned clamp contact checks. Nominal cut-off definitions are checked
|
||||
/// even if their current regenerated program is stale or trimmed by nearby parts.
|
||||
/// A removed clamp is absent from Plate.ClampZones and therefore cannot be reported.
|
||||
/// Zones are compared in the plate's signed placement frame (<see cref="ClampZoneFrame"/>).
|
||||
/// </summary>
|
||||
public static class ClampCutDiagnostics
|
||||
{
|
||||
public static IReadOnlyList<CutOffClampFinding> Find(Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
var findings = new List<CutOffClampFinding>();
|
||||
if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0)
|
||||
return findings.AsReadOnly();
|
||||
var zones = ClampZoneFrame.ToPlacement(plate);
|
||||
var sheet = plate.BoundingBox(false);
|
||||
for (var index = 0; index < plate.CutOffs.Count; index++)
|
||||
{
|
||||
var cut = plate.CutOffs[index];
|
||||
if (cut == null || !Enum.IsDefined(cut.Axis))
|
||||
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
|
||||
var start = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
|
||||
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
|
||||
var end = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
|
||||
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
|
||||
foreach (var zone in zones)
|
||||
if (Intersects(start, end, zone.Bounds, out var location))
|
||||
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
||||
}
|
||||
return findings.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>True for contact with the whole rectangle, including a boundary touch.</summary>
|
||||
public static bool Intersects(Vector from, Vector to, Box box, out Vector location)
|
||||
{
|
||||
location = default;
|
||||
if (box == null || !Finite(from) || !Finite(to) || !Finite(box.Location)
|
||||
|| !double.IsFinite(box.Right) || !double.IsFinite(box.Top)
|
||||
|| box.Length <= 0 || box.Width <= 0)
|
||||
throw new ArgumentException("Clamp contact geometry must be finite and nonempty.");
|
||||
var dx = to.X - from.X;
|
||||
var dy = to.Y - from.Y;
|
||||
if (!double.IsFinite(dx) || !double.IsFinite(dy)
|
||||
|| !double.IsFinite(from.X - box.Left) || !double.IsFinite(box.Right - from.X)
|
||||
|| !double.IsFinite(from.Y - box.Bottom) || !double.IsFinite(box.Top - from.Y))
|
||||
throw new ArgumentException("Clamp contact motion is too large.");
|
||||
var enter = 0.0;
|
||||
var exit = 1.0;
|
||||
if (!Clip(-dx, from.X - box.Left, ref enter, ref exit)
|
||||
|| !Clip(dx, box.Right - from.X, ref enter, ref exit)
|
||||
|| !Clip(-dy, from.Y - box.Bottom, ref enter, ref exit)
|
||||
|| !Clip(dy, box.Top - from.Y, ref enter, ref exit))
|
||||
return false;
|
||||
var midpoint = (enter + exit) * 0.5;
|
||||
location = new Vector(from.X + dx * midpoint, from.Y + dy * midpoint);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Clip(double p, double q, ref double enter, ref double exit)
|
||||
{
|
||||
if (p == 0)
|
||||
return q >= 0;
|
||||
var ratio = q / p;
|
||||
if (p < 0)
|
||||
enter = System.Math.Max(enter, ratio);
|
||||
else
|
||||
exit = System.Math.Min(exit, ratio);
|
||||
return enter <= exit;
|
||||
}
|
||||
|
||||
private static bool Finite(Vector value) => double.IsFinite(value.X) && double.IsFinite(value.Y);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Shared material of two hole-aware outlines as connected islands, through Clipper2.
|
||||
/// Callers pass part-local coordinates (rebased near the origin) and add the origin back.
|
||||
/// <para>
|
||||
/// Coordinates are rounded to a 1e-8 model-unit grid, far below the 0.001 chord tolerance
|
||||
/// and the <see cref="OpenNest.Math.Tolerance.Epsilon"/> area threshold. Each operand is
|
||||
/// its outline minus the union of its holes (outline positive, holes negative,
|
||||
/// <see cref="FillRule.Positive"/>).
|
||||
/// Nonfinite or out-of-range coordinates throw <see cref="ArithmeticException"/>, which the
|
||||
/// analyzer turns into an incomplete-check issue rather than a clear result.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class OverlapClipping
|
||||
{
|
||||
private const double Scale = 1e8;
|
||||
|
||||
// Scaled coordinates stay several orders of magnitude inside Clipper2's int64 range.
|
||||
private const double MaxCoordinate = 1e9;
|
||||
|
||||
/// <summary>One connected island: a counterclockwise outline and clockwise holes, both closed.</summary>
|
||||
internal sealed record Island(List<Vector> Outline, List<List<Vector>> Holes, PolygonAreaMoments Moments);
|
||||
|
||||
internal static List<Island> Intersect(Polygon outerA, IReadOnlyList<Polygon> holesA,
|
||||
Polygon outerB, IReadOnlyList<Polygon> holesB)
|
||||
{
|
||||
var clipper = new Clipper64();
|
||||
clipper.AddSubject(Region(outerA, holesA));
|
||||
clipper.AddClip(Region(outerB, holesB));
|
||||
var tree = new PolyTree64();
|
||||
if (!clipper.Execute(ClipType.Intersection, FillRule.Positive, tree))
|
||||
throw new InvalidOperationException("Clipper2 could not intersect the overlap material.");
|
||||
var islands = new List<Island>();
|
||||
Collect(tree, islands);
|
||||
return islands;
|
||||
}
|
||||
|
||||
private static void Collect(PolyPath64 node, List<Island> islands)
|
||||
{
|
||||
for (var i = 0; i < node.Count; i++)
|
||||
{
|
||||
var outer = (PolyPath64)node[i];
|
||||
var outline = ToVectors(outer.Polygon, positive: true);
|
||||
var holes = new List<List<Vector>>();
|
||||
for (var h = 0; h < outer.Count; h++)
|
||||
{
|
||||
var hole = (PolyPath64)outer[h];
|
||||
holes.Add(ToVectors(hole.Polygon, positive: false));
|
||||
// An island can sit inside a hole formed by both operands' holes together.
|
||||
Collect(hole, islands);
|
||||
}
|
||||
if (TryMoments(outline, holes, out var moments))
|
||||
islands.Add(new Island(outline, holes, moments));
|
||||
else if (Clipper.Area(outer.Polygon) / (Scale * Scale) >= OpenNest.Math.Tolerance.Epsilon)
|
||||
throw new ArithmeticException("Overlap island area moments are invalid.");
|
||||
// Otherwise a degenerate dust island below the reporting threshold is skipped.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Net area and centroid of an outline minus its holes. Fails for degenerate,
|
||||
/// nonfinite or nonpositive results.
|
||||
/// </summary>
|
||||
private static bool TryMoments(List<Vector> outline, List<List<Vector>> holes, out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (!PolygonAreaMoments.TryCompute(outline, out var outer))
|
||||
return false;
|
||||
var removed = new List<PolygonAreaMoments>(holes.Count);
|
||||
foreach (var hole in holes)
|
||||
{
|
||||
if (!PolygonAreaMoments.TryCompute(hole, out var hollow))
|
||||
return false;
|
||||
removed.Add(hollow);
|
||||
}
|
||||
return PolygonAreaMoments.TrySubtract(outer, removed, out moments);
|
||||
}
|
||||
|
||||
private static Paths64 Region(Polygon outer, IReadOnlyList<Polygon> holes)
|
||||
{
|
||||
var region = new Paths64 { ToPath(outer, positive: true) };
|
||||
if (holes != null)
|
||||
foreach (var hole in holes)
|
||||
region.Add(ToPath(hole, positive: false));
|
||||
return region;
|
||||
}
|
||||
|
||||
private static Path64 ToPath(Polygon polygon, bool positive)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
var count = vertices.Count;
|
||||
if (count > 1 && vertices[count - 1].X == vertices[0].X && vertices[count - 1].Y == vertices[0].Y)
|
||||
count--;
|
||||
var path = new Path64(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var point = vertices[i];
|
||||
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|
||||
|| System.Math.Abs(point.X) > MaxCoordinate || System.Math.Abs(point.Y) > MaxCoordinate)
|
||||
throw new ArithmeticException("Overlap material is outside the exact clipping range.");
|
||||
path.Add(new Point64(System.Math.Round(point.X * Scale), System.Math.Round(point.Y * Scale)));
|
||||
}
|
||||
if (Clipper.IsPositive(path) != positive)
|
||||
path.Reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static List<Vector> ToVectors(Path64 path, bool positive)
|
||||
{
|
||||
var points = new List<Vector>(path.Count + 1);
|
||||
foreach (var point in path)
|
||||
points.Add(new Vector(point.X / Scale, point.Y / Scale));
|
||||
if (Clipper.IsPositive(path) != positive)
|
||||
points.Reverse();
|
||||
points.Add(points[0]); // Regions repeat their closing vertex exactly.
|
||||
return points;
|
||||
}
|
||||
}
|
||||
@@ -11,20 +11,71 @@ public sealed class OverlapGeometryStamp
|
||||
{
|
||||
private readonly Plate plate;
|
||||
private readonly Entry[] entries;
|
||||
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
|
||||
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
|
||||
private readonly (long Width, long Length, int Quadrant) sheet;
|
||||
|
||||
private OverlapGeometryStamp(Plate plate)
|
||||
{
|
||||
this.plate = plate;
|
||||
sheet = SheetState(plate);
|
||||
entries = new Entry[plate.Parts.Count];
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
entries[i] = new Entry(plate.Parts[i]);
|
||||
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
|
||||
for (var i = 0; i < zones.Length; i++)
|
||||
zones[i] = ZoneState(plate.ClampZones[i]);
|
||||
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
|
||||
for (var i = 0; i < cutoffs.Length; i++)
|
||||
cutoffs[i] = CutOffState(plate.CutOffs[i]);
|
||||
}
|
||||
|
||||
// Missing entries are malformed input the analysis itself reports; freshness must not
|
||||
// throw first (the desktop captures it before its error boundary).
|
||||
private static (string, long, long, long, long) ZoneState(ClampZone zone) =>
|
||||
zone?.Bounds is not { } box ? default
|
||||
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
|
||||
|
||||
private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
|
||||
cut == null ? default
|
||||
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
||||
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
||||
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
|
||||
private static (long, long, int) SheetState(Plate plate) =>
|
||||
(Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
|
||||
|
||||
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
||||
|
||||
internal bool SameZones(Plate current)
|
||||
{
|
||||
// Stored zones are sheet-relative: a size or quadrant change moves them in the
|
||||
// parts' frame without changing their bits.
|
||||
if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length
|
||||
|| sheet != SheetState(current))
|
||||
return false;
|
||||
for (var i = 0; i < zones.Length; i++)
|
||||
if (zones[i] != ZoneState(current.ClampZones[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool SameCutOffs(Plate current)
|
||||
{
|
||||
if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length
|
||||
|| sheet != SheetState(current))
|
||||
return false;
|
||||
for (var i = 0; i < cutoffs.Length; i++)
|
||||
if (cutoffs[i] != CutOffState(current.CutOffs[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Matches(Plate current)
|
||||
{
|
||||
if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
|
||||
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
|
||||
return false;
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
if (!entries[i].Matches(current.Parts[i]))
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// A marker point on one island of shared material. The area centroid of a concave,
|
||||
/// holed or split overlap can lie off the material, so markers use the island's pole of
|
||||
/// inaccessibility instead, with a guaranteed-interior fallback.
|
||||
/// </summary>
|
||||
internal static class OverlapMarker
|
||||
{
|
||||
// Distance evaluations per island; a long sliver otherwise refines along its whole ridge.
|
||||
internal const int MaxCells = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// The island's pole of inaccessibility (searched to a tenth of its area/perimeter, a
|
||||
/// lower bound on a thin island's half-width) when that bounded search lands inside;
|
||||
/// otherwise the midpoint of the widest interior run of a horizontal scan line; otherwise
|
||||
/// <paramref name="fallback"/>. Rings are closed, as the clipping returns them.
|
||||
/// </summary>
|
||||
internal static Vector Find(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, double area,
|
||||
Vector fallback, int maxCells = MaxCells)
|
||||
{
|
||||
var outer = ToPolygon(outline);
|
||||
var hollow = holes.Select(ToPolygon).ToList();
|
||||
var perimeter = outer.Perimeter() + hollow.Sum(hole => hole.Perimeter());
|
||||
var precision = 0.1 * area / perimeter;
|
||||
if (double.IsFinite(precision) && precision > 0)
|
||||
{
|
||||
var pole = PolyLabel.Find(outer, hollow, precision, maxCells);
|
||||
if (Inside(outline, holes, pole))
|
||||
return pole;
|
||||
}
|
||||
return TryScanLine(outline, holes, out var point) ? point : fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Midpoint of the widest interior run along a horizontal line through the middle of the
|
||||
/// island, placed between vertex heights so crossings are unambiguous (as JTS's
|
||||
/// interior-point-of-area). Even-odd pairing of outline and hole crossings keeps it
|
||||
/// inside the material.
|
||||
/// </summary>
|
||||
private static bool TryScanLine(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, out Vector point)
|
||||
{
|
||||
point = default;
|
||||
var minY = outline.Min(vertex => vertex.Y);
|
||||
var maxY = outline.Max(vertex => vertex.Y);
|
||||
var centre = (minY + maxY) / 2;
|
||||
double below = minY, above = maxY;
|
||||
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
|
||||
{
|
||||
if (vertex.Y <= centre && vertex.Y > below)
|
||||
below = vertex.Y;
|
||||
else if (vertex.Y > centre && vertex.Y < above)
|
||||
above = vertex.Y;
|
||||
}
|
||||
var y = (below + above) / 2;
|
||||
if (!(y > minY && y < maxY))
|
||||
return false;
|
||||
|
||||
var crossings = new List<double>();
|
||||
AddCrossings(outline, y, crossings);
|
||||
foreach (var hole in holes)
|
||||
AddCrossings(hole, y, crossings);
|
||||
crossings.Sort();
|
||||
var widest = 0.0;
|
||||
for (var i = 0; i + 1 < crossings.Count; i += 2)
|
||||
{
|
||||
var width = crossings[i + 1] - crossings[i];
|
||||
if (width > widest)
|
||||
{
|
||||
widest = width;
|
||||
point = new Vector((crossings[i] + crossings[i + 1]) / 2, y);
|
||||
}
|
||||
}
|
||||
return widest > 0;
|
||||
}
|
||||
|
||||
private static void AddCrossings(IReadOnlyList<Vector> ring, double y, List<double> crossings)
|
||||
{
|
||||
for (var i = 0; i + 1 < ring.Count; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[i + 1];
|
||||
if ((a.Y > y) != (b.Y > y))
|
||||
crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Inside(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, Vector point) =>
|
||||
double.IsFinite(point.X) && double.IsFinite(point.Y)
|
||||
&& InsideRing(outline, point) && !holes.Any(hole => InsideRing(hole, point));
|
||||
|
||||
// Crossing-number test over a closed ring (half-open edges, so a vertex counts once).
|
||||
private static bool InsideRing(IReadOnlyList<Vector> ring, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = 0; i + 1 < ring.Count; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[i + 1];
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static Polygon ToPolygon(IReadOnlyList<Vector> ring)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(ring);
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,21 @@ 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}";
|
||||
/// <summary>True for part-vs-clamp findings carried on the report (negative clamp slot).</summary>
|
||||
public static bool IsClampFinding(PlateOverlapPair pair) => pair.PartBId < 0;
|
||||
|
||||
public static string Label(PlateOverlapPair pair) =>
|
||||
IsClampFinding(pair) ? $"{pair.PartAId + 1}/clamp" : $"{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"
|
||||
var head = IsClampFinding(pair)
|
||||
? $"Clamp overlap: {pair.PartAName} / {pair.PartBName}"
|
||||
: $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}";
|
||||
return head + "\n"
|
||||
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
||||
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
|
||||
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
||||
}
|
||||
|
||||
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||
@@ -27,7 +34,7 @@ public static class OverlapPairPresentation
|
||||
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
|
||||
|
||||
/// <summary>
|
||||
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
|
||||
/// Projects cached markers 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>
|
||||
@@ -37,7 +44,7 @@ public static class OverlapPairPresentation
|
||||
var radius = HitRadius(deviceDpi);
|
||||
return pairs.Where(pair =>
|
||||
{
|
||||
var center = worldToScreen(pair.Centroid);
|
||||
var center = worldToScreen(pair.Marker);
|
||||
var dx = pointer.X - center.X;
|
||||
var dy = pointer.Y - center.Y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
|
||||
@@ -21,14 +21,22 @@ public sealed class OverlapReportState
|
||||
public PlateOverlapReport Report { get; private set; }
|
||||
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
|
||||
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
||||
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
|
||||
Array.Empty<CutOffClampFinding>();
|
||||
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.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
|
||||
? (Report.Pairs.Count == 0 ? "No material overlaps detected"
|
||||
: $"Overlaps: {Report.Pairs.Count} pairs")
|
||||
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
|
||||
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
|
||||
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
|
||||
+ (Report.CutOffClampFindings.Count == 0 ? ""
|
||||
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
|
||||
+ $"{uncheckedPartCount} parts could not be checked",
|
||||
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||
@@ -55,7 +63,9 @@ public sealed class OverlapReportState
|
||||
if (!CanComplete(generation, plate))
|
||||
return false;
|
||||
Report = report;
|
||||
DisplayPairs = report.Pairs;
|
||||
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
|
||||
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
|
||||
DisplayCutOffClampFindings = report.CutOffClampFindings;
|
||||
displayStamp = stamp;
|
||||
observedDisplayStamp = stamp;
|
||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||
@@ -106,10 +116,14 @@ public sealed class OverlapReportState
|
||||
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
|
||||
return;
|
||||
var unchanged = displayStamp.UnchangedSlots(plate);
|
||||
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
|
||||
var sameZones = displayStamp.SameZones(plate);
|
||||
if (!sameZones || !displayStamp.SameCutOffs(plate))
|
||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId]
|
||||
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
|
||||
if (retained.Count != DisplayPairs.Count)
|
||||
DisplayPairs = retained.AsReadOnly();
|
||||
if (DisplayPairs.Count == 0)
|
||||
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
|
||||
ClearDisplayPairs();
|
||||
else
|
||||
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
||||
@@ -118,6 +132,7 @@ public sealed class OverlapReportState
|
||||
private void ClearDisplayPairs()
|
||||
{
|
||||
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||
displayStamp = null;
|
||||
observedDisplayStamp = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Pairs of placed parts closer than the plate's part spacing. Candidates are found exactly as Auto
|
||||
/// Nest's layout check finds them: each part's clean nesting material
|
||||
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
|
||||
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
|
||||
/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin
|
||||
/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is
|
||||
/// confirmed on the native contours flattened at <see cref="FineTolerance"/> and reported only when
|
||||
/// that gap is short of the spacing by more than the slack. Zero spacing enforces nothing, so
|
||||
/// contact stays valid.
|
||||
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
|
||||
/// that check. The sheet edge and clamps are not spacing-checked here.
|
||||
/// </summary>
|
||||
internal static class PartSpacingReview
|
||||
{
|
||||
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
|
||||
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
|
||||
internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
|
||||
|
||||
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spacing = plate.PartSpacing;
|
||||
if (!double.IsFinite(spacing) || spacing < 0)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
|
||||
return;
|
||||
}
|
||||
var inflateBy = SpacingOutline.InflationFor(spacing);
|
||||
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
|
||||
return;
|
||||
|
||||
var unreadable = overlap.Issues.Where(issue => issue.PartBId == null)
|
||||
.Select(issue => issue.PartAId).ToHashSet();
|
||||
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
|
||||
var profiles = new Dictionary<(Program, long), ShapeProfile>();
|
||||
var shapes = new ShapeProfile[parts.Count];
|
||||
var fine = new FineRings[parts.Count];
|
||||
var raw = new SpacingOutline[parts.Count];
|
||||
var inflated = new SpacingOutline[parts.Count];
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (unreadable.Contains(i))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var profile = Profile(parts[i], profiles);
|
||||
if (profile == null)
|
||||
continue;
|
||||
shapes[i] = profile;
|
||||
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0);
|
||||
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy);
|
||||
if (raw[i] == null || inflated[i] == null)
|
||||
throw new ArgumentException("Part material has no closed perimeter.");
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
raw[i] = inflated[i] = null;
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null,
|
||||
"Part spacing check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
var order = Enumerable.Range(0, parts.Count)
|
||||
.Where(i => raw[i] != null)
|
||||
.OrderBy(i => raw[i].Perimeter.BoundingBox.Left)
|
||||
.ToList();
|
||||
for (var a = 0; a < order.Count; a++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var i = order[a];
|
||||
var reach = inflated[i].Perimeter.BoundingBox;
|
||||
for (var b = a + 1; b < order.Count; b++)
|
||||
{
|
||||
var j = order[b];
|
||||
var other = raw[j].Perimeter.BoundingBox;
|
||||
// Sorted by Left, so nothing further along can reach part i either.
|
||||
if (other.Left > reach.Right + Tolerance.Epsilon)
|
||||
break;
|
||||
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j))
|
||||
|| !inflated[i].Overlaps(raw[j]))
|
||||
continue;
|
||||
double gap;
|
||||
Vector marker;
|
||||
try
|
||||
{
|
||||
fine[i] ??= FineRings.Of(shapes[i], parts[i].Location);
|
||||
fine[j] ??= FineRings.Of(shapes[j], parts[j].Location);
|
||||
gap = Gap(fine[i], fine[j], spacing, out marker);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber,
|
||||
System.Math.Min(partNumbers[i], partNumbers[j]),
|
||||
System.Math.Max(partNumbers[i], partNumbers[j]),
|
||||
"Part spacing check: " + exception.Message));
|
||||
continue;
|
||||
}
|
||||
if (!(gap < spacing - SpacingOutline.SpacingSlack))
|
||||
continue;
|
||||
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
|
||||
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
|
||||
partNumbers[second],
|
||||
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
|
||||
+ $"plate's part spacing ({spacing:F3}).")
|
||||
{ Location = marker });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clean drawing material, rotated to the part's pose; null when the part has none.
|
||||
/// Converted once per program and rotation.</summary>
|
||||
private static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
|
||||
{
|
||||
var program = part.BaseDrawing.Program;
|
||||
var rotation = part.Rotation - program.Rotation;
|
||||
if (!double.IsFinite(rotation))
|
||||
throw new ArgumentException("Part pose must be finite.");
|
||||
var key = (program, BitConverter.DoubleToInt64Bits(rotation));
|
||||
if (profiles.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
// Conversion creates fresh geometry, so rotating it never touches the live program.
|
||||
var entities = ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout)
|
||||
.ForNesting();
|
||||
if (rotation != 0)
|
||||
foreach (var entity in entities)
|
||||
entity.Rotate(rotation);
|
||||
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
|
||||
if (profile != null && profile.Perimeter == null)
|
||||
throw new ArgumentException("Part material has no closed perimeter.");
|
||||
profiles[key] = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>Closest boundary approach between two parts, including a part inside the other's
|
||||
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
|
||||
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
|
||||
/// skipped. Infinity when no edges are that near.</summary>
|
||||
private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
|
||||
{
|
||||
var best = double.PositiveInfinity;
|
||||
var middle = a.Bounds.Center;
|
||||
var perimeterA = Near(a.Perimeter, b.Bounds, reach);
|
||||
var perimeterB = Near(b.Perimeter, a.Bounds, reach);
|
||||
Measure(perimeterA, perimeterB);
|
||||
foreach (var hole in a.Holes)
|
||||
Measure(Near(hole, b.Bounds, reach), perimeterB);
|
||||
foreach (var hole in b.Holes)
|
||||
Measure(perimeterA, Near(hole, a.Bounds, reach));
|
||||
marker = middle;
|
||||
return best;
|
||||
|
||||
void Measure(List<Line> ringA, List<Line> ringB)
|
||||
{
|
||||
if (ringA.Count == 0 || ringB.Count == 0)
|
||||
return;
|
||||
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
|
||||
if (distance < best)
|
||||
{
|
||||
best = distance;
|
||||
middle = (onA + onB) * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
|
||||
lines.Where(line =>
|
||||
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
|
||||
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
|
||||
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
|
||||
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
|
||||
|
||||
/// <summary>A part's native contours in world space, flattened at <see cref="FineTolerance"/>.
|
||||
/// Circumscribed, so the measured gap never exceeds the true one by more than rounding.</summary>
|
||||
private sealed class FineRings
|
||||
{
|
||||
public List<Line> Perimeter { get; private init; }
|
||||
public List<List<Line>> Holes { get; private init; }
|
||||
public Box Bounds { get; private init; }
|
||||
|
||||
public static FineRings Of(ShapeProfile profile, Vector location)
|
||||
{
|
||||
var perimeter = World(profile.Perimeter, location, out var bounds);
|
||||
return new FineRings
|
||||
{
|
||||
Perimeter = perimeter.ToLines(),
|
||||
Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
|
||||
Bounds = bounds,
|
||||
};
|
||||
}
|
||||
|
||||
private static Polygon World(Shape shape, Vector location, out Box bounds)
|
||||
{
|
||||
var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
|
||||
polygon.Offset(location);
|
||||
polygon.UpdateBounds();
|
||||
bounds = polygon.BoundingBox;
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
|
||||
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
|
||||
|
||||
private static string Name(Part part) => part.BaseDrawing.Name;
|
||||
|
||||
private static bool BoxesTouch(Box a, Box b) =>
|
||||
a.Left <= b.Right + Tolerance.Epsilon
|
||||
&& b.Left <= a.Right + Tolerance.Epsilon
|
||||
&& a.Bottom <= b.Top + Tolerance.Epsilon
|
||||
&& b.Bottom <= a.Top + Tolerance.Epsilon;
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
@@ -16,13 +17,41 @@ public static class PlateOverlapAnalyzer
|
||||
{
|
||||
public const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Capture a whole plate including active clamps and nominal cut-off definitions.
|
||||
/// Use this entry for the desktop overlap check so manual cut-off contacts
|
||||
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
|
||||
/// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
|
||||
/// placement frame; a malformed zone throws <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
|
||||
cache ?? new OverlapMaterialCache(), cancellationToken);
|
||||
var cutoffs = ClampCutDiagnostics.Find(plate);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
|
||||
material.Clamps.ToList(), cutoffs.ToList());
|
||||
}
|
||||
|
||||
/// <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);
|
||||
Capture(parts, null, new OverlapMaterialCache(), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, additionally capturing
|
||||
/// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings
|
||||
/// (C1) alongside part pairs. Null means no zones.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<ClampZone> clampZones, CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
|
||||
@@ -30,6 +59,12 @@ public static class PlateOverlapAnalyzer
|
||||
/// 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) =>
|
||||
Capture(parts, null, cache, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="Capture(IReadOnlyList{Part}, IReadOnlyList{ClampZone}, CancellationToken)"/>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<ClampZone> clampZones, OverlapMaterialCache cache,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parts);
|
||||
@@ -63,8 +98,21 @@ public static class PlateOverlapAnalyzer
|
||||
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
var clamps = new List<CapturedClamp>();
|
||||
if (clampZones != null)
|
||||
{
|
||||
for (var ordinal = 0; ordinal < clampZones.Count; ordinal++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var zone = clampZones[ordinal];
|
||||
// ClampZone validates finiteness and positive size at construction; the
|
||||
// snapshot still owns its own copy so later plate edits never move it.
|
||||
clamps.Add(new CapturedClamp(ordinal, zone.Id,
|
||||
new Box(zone.Bounds.X, zone.Bounds.Y, zone.Bounds.Length, zone.Bounds.Width)));
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(captured, issues);
|
||||
return new PlateOverlapSnapshot(captured, issues, clamps);
|
||||
}
|
||||
|
||||
private static OverlapSource CaptureSource(Program program)
|
||||
@@ -73,8 +121,9 @@ public static class PlateOverlapAnalyzer
|
||||
{
|
||||
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
|
||||
// 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)
|
||||
// no cloning/rotation of a live program or subprogram is necessary. A baked-in
|
||||
// seam overcut is trimmed on an owned copy: it encloses no area.
|
||||
return new OverlapSource(program, ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
||||
&& entity.Layer != SpecialLayers.Leadin
|
||||
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
||||
@@ -91,7 +140,7 @@ public static class PlateOverlapAnalyzer
|
||||
Analyze(Capture(parts, cancellationToken), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
|
||||
/// Returns deterministic pair reports containing closed world-coordinate overlap islands.
|
||||
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
|
||||
@@ -156,34 +205,15 @@ public static class PlateOverlapAnalyzer
|
||||
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.
|
||||
// restore output to world space.
|
||||
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)
|
||||
if (!TryShare(localA, localB.Outer, localB.Holes, origin, cancellationToken,
|
||||
out var regions, out var centroid, out var marker))
|
||||
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));
|
||||
a.Input.Name, b.Input.Name, regions, centroid, marker));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
@@ -192,9 +222,148 @@ public static class PlateOverlapAnalyzer
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
|
||||
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
|
||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
|
||||
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
|
||||
snapshot.CutOffClampFindings.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C1 part-vs-clamp findings: the prepared hole-aware material against each captured
|
||||
/// zone rectangle with the same exact Collision oracle as part pairs. Deterministic in
|
||||
/// (part input id, clamp ordinal) order; failures become part issues like any other
|
||||
/// pair geometry failure. Empty zone lists return without touching the material.
|
||||
/// </summary>
|
||||
private static List<PlateOverlapPair> FindClampOverlaps(List<PreparedPart> prepared,
|
||||
PlateOverlapSnapshot snapshot, List<PlateOverlapIssue> issues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var findings = new List<PlateOverlapPair>();
|
||||
if (snapshot.Clamps.Count == 0)
|
||||
return findings;
|
||||
|
||||
foreach (var part in prepared)
|
||||
{
|
||||
var partBounds = part.Material.Outer.BoundingBox;
|
||||
foreach (var clamp in snapshot.Clamps)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var zone = clamp.Bounds;
|
||||
// The prepared material is inscribed at ChordTolerance, so real material can
|
||||
// reach that far past its bounds; a tiny clamp there must still be checked.
|
||||
var reach = KeepOutOverlap.NeedsToleranceFreeTest(zone) ? ChordTolerance : 0;
|
||||
if (partBounds.Right + reach <= zone.Left || zone.Right <= partBounds.Left - reach
|
||||
|| partBounds.Top + reach <= zone.Bottom || zone.Top <= partBounds.Bottom - reach)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// A clamp below the polygon tolerance collapses in Collision.Check and would
|
||||
// read as clear (PM aa995123): judge it tolerance-free and report the whole
|
||||
// (negligibly small) clamp rectangle as the overlap fragment.
|
||||
if (KeepOutOverlap.NeedsToleranceFreeTest(zone))
|
||||
{
|
||||
// Re-read the native contours conservatively and finely enough for
|
||||
// this clamp: the shared 0.001 chords can hide material between an
|
||||
// arc and its chord.
|
||||
var profile = new ShapeProfile(part.Input.Source.Entities.Select(e => e.Clone()).ToList());
|
||||
if (profile.Perimeter == null)
|
||||
throw new ArgumentException("Drawing has no closed material contour.");
|
||||
if (KeepOutOverlap.ConservativePolygons(profile, zone)
|
||||
.Select(polygons => new OverlapMaterial(polygons.Perimeter, polygons.Holes)
|
||||
.Transform(part.Input.Rotation, part.Input.Location))
|
||||
.Any(fine => KeepOutOverlap.MaterialEnters(fine.Outer, fine.Holes, zone)))
|
||||
findings.Add(TinyClampFinding(part, clamp));
|
||||
continue;
|
||||
}
|
||||
var origin = part.Material.Outer.Vertices[0];
|
||||
var local = part.Material.Transform(0, origin * -1);
|
||||
var rect = RectanglePolygon(zone, origin * -1);
|
||||
if (!TryShare(local, rect, null, origin, cancellationToken,
|
||||
out var regions, out var centroid, out var marker))
|
||||
continue;
|
||||
findings.Add(new PlateOverlapPair(part.Input.Id,
|
||||
ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", regions, centroid, marker));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared material of a rebased operand with another rebased outline/holes, as world
|
||||
/// regions (connected islands) plus the area centroid of them all and a marker point
|
||||
/// inside the largest island. Returns false when the
|
||||
/// total shared area does not exceed <see cref="OpenNest.Math.Tolerance.Epsilon"/>: contact and rounding
|
||||
/// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather
|
||||
/// than clear or marked at the origin.
|
||||
/// </summary>
|
||||
private static bool TryShare(OverlapMaterial localA, Polygon outerB, List<Polygon> holesB, Vector origin,
|
||||
CancellationToken cancellationToken, out List<PlateOverlapRegion> regions, out Vector centroid,
|
||||
out Vector marker)
|
||||
{
|
||||
regions = null;
|
||||
centroid = default;
|
||||
marker = default;
|
||||
var islands = OverlapClipping.Intersect(localA.Outer, localA.Holes, outerB, holesB);
|
||||
var area = islands.Sum(island => island.Moments.Area);
|
||||
if (!double.IsFinite(area))
|
||||
throw new ArithmeticException("Overlap area moments are invalid.");
|
||||
if (area <= OpenNest.Math.Tolerance.Epsilon)
|
||||
return false;
|
||||
regions = new List<PlateOverlapRegion>(islands.Count);
|
||||
foreach (var island in islands)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
regions.Add(new PlateOverlapRegion(island.Outline.Select(point => point + origin), island.Moments.Area,
|
||||
island.Holes.Select(hole => hole.Select(point => point + origin))));
|
||||
}
|
||||
if (!PolygonAreaMoments.TryCombine(islands.Select(island => island.Moments), out var combined))
|
||||
throw new ArithmeticException("Combined overlap area moments are invalid.");
|
||||
centroid = combined.Centroid + origin;
|
||||
if (!OverlapMaterial.IsFinite(centroid))
|
||||
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||
// The area centroid can lie between islands or in a hole: mark the largest island.
|
||||
var largest = islands.Aggregate((best, island) => island.Moments.Area > best.Moments.Area ? island : best);
|
||||
marker = OverlapMarker.Find(largest.Outline, largest.Holes, largest.Moments.Area, largest.Moments.Centroid)
|
||||
+ origin;
|
||||
if (!OverlapMaterial.IsFinite(marker))
|
||||
throw new ArithmeticException("Overlap marker is not finite in world coordinates.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PlateOverlapPair TinyClampFinding(PreparedPart part, CapturedClamp clamp)
|
||||
{
|
||||
var zone = clamp.Bounds;
|
||||
var corners = new[]
|
||||
{
|
||||
new Vector(zone.Left, zone.Bottom), new Vector(zone.Right, zone.Bottom),
|
||||
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
|
||||
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
|
||||
};
|
||||
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
||||
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
|
||||
centre);
|
||||
}
|
||||
|
||||
private static Polygon RectanglePolygon(Box box, Vector offset)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Bottom + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Bottom + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Top + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Top + offset.Y));
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -8,11 +8,14 @@ namespace OpenNest.Diagnostics;
|
||||
public sealed class PlateOverlapReport
|
||||
{
|
||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||
PlateOverlapSnapshot snapshot)
|
||||
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
|
||||
List<CutOffClampFinding> cutOffClampFindings = null)
|
||||
{
|
||||
Pairs = pairs.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Snapshot = snapshot;
|
||||
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
|
||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||
@@ -20,6 +23,16 @@ public sealed class PlateOverlapReport
|
||||
|
||||
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
||||
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a
|
||||
/// negative <see cref="PlateOverlapPair.PartBId"/> slot encoding the zone ordinal. Findings
|
||||
/// are reported like other overlap findings; they do not change <see cref="IsComplete"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PlateOverlapPair> ClampFindings { get; }
|
||||
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
|
||||
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||
|
||||
public bool IsComplete => Issues.Count == 0;
|
||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||
}
|
||||
@@ -39,6 +52,7 @@ public sealed class PlateOverlapPair
|
||||
Regions = source.Regions;
|
||||
Area = source.Area;
|
||||
Centroid = source.Centroid;
|
||||
Marker = source.Marker;
|
||||
bounds = source.bounds;
|
||||
}
|
||||
|
||||
@@ -47,7 +61,7 @@ public sealed class PlateOverlapPair
|
||||
new(this, partAId, partBId, partAName, partBName);
|
||||
|
||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||
List<PlateOverlapRegion> regions, Vector centroid)
|
||||
List<PlateOverlapRegion> regions, Vector centroid, Vector marker)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
@@ -56,6 +70,7 @@ public sealed class PlateOverlapPair
|
||||
Regions = regions.AsReadOnly();
|
||||
Area = regions.Sum(region => region.Area);
|
||||
Centroid = centroid;
|
||||
Marker = marker;
|
||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||
var left = points.Min(point => point.X);
|
||||
var bottom = points.Min(point => point.Y);
|
||||
@@ -67,7 +82,7 @@ public sealed class PlateOverlapPair
|
||||
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>
|
||||
/// <summary>Connected islands of shared material; no mutable kernel polygons are exposed.</summary>
|
||||
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||
public double Area { get; }
|
||||
/// <summary>
|
||||
@@ -75,21 +90,47 @@ public sealed class PlateOverlapPair
|
||||
/// This can lie outside disconnected or concave shared material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Centroid { get; }
|
||||
/// <summary>
|
||||
/// Finite world-coordinate point on the shared material, inside the pair's largest island:
|
||||
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
|
||||
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
|
||||
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
|
||||
/// its conservative whole-clamp region, so its marker is the clamp's centre, within half
|
||||
/// the clamp's size of the material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Marker { 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>
|
||||
/// <summary>
|
||||
/// One connected island of shared material in world coordinates, after hole subtraction:
|
||||
/// a counterclockwise outline and its clockwise holes.
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapRegion
|
||||
{
|
||||
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
|
||||
private static readonly IReadOnlyList<IReadOnlyList<Vector>> NoHoles = System.Array.AsReadOnly(
|
||||
System.Array.Empty<IReadOnlyList<Vector>>());
|
||||
|
||||
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area,
|
||||
IEnumerable<IEnumerable<Vector>> holes = null)
|
||||
{
|
||||
Vertices = System.Array.AsReadOnly(vertices.ToArray());
|
||||
Area = area;
|
||||
Holes = holes == null
|
||||
? NoHoles
|
||||
: System.Array.AsReadOnly(holes
|
||||
.Select(hole => (IReadOnlyList<Vector>)System.Array.AsReadOnly(hole.ToArray()))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
|
||||
/// <summary>Read-only counterclockwise outline with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<Vector> Vertices { get; }
|
||||
|
||||
/// <summary>Read-only clockwise hole rings, each with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<IReadOnlyList<Vector>> Holes { get; }
|
||||
|
||||
/// <summary>Positive net area: the outline's area minus its holes'.</summary>
|
||||
public double Area { get; }
|
||||
}
|
||||
|
||||
@@ -102,15 +143,32 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapSnapshot
|
||||
{
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
|
||||
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
|
||||
{
|
||||
Parts = parts.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
|
||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||
}
|
||||
|
||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
|
||||
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
|
||||
internal IReadOnlyList<CapturedClamp> Clamps { get; }
|
||||
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||
}
|
||||
|
||||
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||
double Rotation, Vector Location);
|
||||
|
||||
/// <summary>A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index.</summary>
|
||||
internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds);
|
||||
|
||||
/// <summary>Deterministic negative PartBId for a part-vs-clamp finding's clamp slot.</summary>
|
||||
internal static class ClampFindingSlot
|
||||
{
|
||||
public static int IdFor(int ordinal) => -2 - ordinal;
|
||||
public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId;
|
||||
}
|
||||
@@ -56,6 +56,51 @@ public static class PostVerificationAnalyzer
|
||||
}
|
||||
var materialParts = new List<Part>();
|
||||
var indices = new List<int>();
|
||||
// Stored zones use the sheet's lower-left frame; motions and parts are signed.
|
||||
// Validate them before any early exit: zone bounds are publicly mutable.
|
||||
IReadOnlyList<ClampZone> clamps;
|
||||
try
|
||||
{
|
||||
clamps = ClampZoneFrame.ToPlacement(plate);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
clamps = null;
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Clamp check: " + exception.Message));
|
||||
}
|
||||
// One clamp warning per cut-off part and clamp: the placed-program check below
|
||||
// reports only what the nominal definition check has not already reported.
|
||||
var cutOffClampContacts = new HashSet<(int PartNumber, string ClampId)>();
|
||||
if (clamps != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var contact in ClampCutDiagnostics.Find(plate))
|
||||
{
|
||||
var cut = plate.CutOffs[contact.CutOffIndex];
|
||||
var partIndex = -1;
|
||||
for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++)
|
||||
if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing))
|
||||
{
|
||||
partIndex = partSlot;
|
||||
break;
|
||||
}
|
||||
if (partIndex >= 0)
|
||||
cutOffClampContacts.Add((partIndex + 1, contact.ClampId));
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber,
|
||||
partIndex < 0 ? null : partIndex + 1, null,
|
||||
$"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. "
|
||||
+ "Review the clamp and cut before posting; this warning is not a repair.")
|
||||
{ Location = contact.Location, ClampId = contact.ClampId });
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Clamp/cut-off check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
var obstacles = new ReleasedContourState();
|
||||
Vector? position = startPoint;
|
||||
for (var index = 0; index < plate.Parts.Count; index++)
|
||||
@@ -94,7 +139,48 @@ public static class PostVerificationAnalyzer
|
||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
|
||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken,
|
||||
proveOpenCuts: true);
|
||||
if (clamps is { Count: > 0 })
|
||||
foreach (var move in moves)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!move.Rapid || move.Start is not { } from
|
||||
|| from.X == move.End.X && from.Y == move.End.Y)
|
||||
continue;
|
||||
foreach (var zone in clamps)
|
||||
if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact))
|
||||
findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber,
|
||||
partNumber, null,
|
||||
$"Direct XY rapid crosses active clamp '{zone.Id}'. "
|
||||
+ "Head height is not modeled; review the posted route and clamp setup.")
|
||||
{
|
||||
Location = (from + move.End) * 0.5,
|
||||
RapidStart = from,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = new[] { contact },
|
||||
ClampId = zone.Id
|
||||
});
|
||||
}
|
||||
// The placed program is what posts: a definition edited without
|
||||
// regenerating it can still cut through a clamp its nominal line misses.
|
||||
if (cutoff && clamps is { Count: > 0 })
|
||||
foreach (var move in moves)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (move.Rapid || move.Start is not { } from)
|
||||
continue;
|
||||
foreach (var zone in clamps)
|
||||
if (!cutOffClampContacts.Contains((partNumber, zone.Id))
|
||||
&& CutTouches(move, from, zone.Bounds, out var contact))
|
||||
{
|
||||
cutOffClampContacts.Add((partNumber, zone.Id));
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, partNumber, null,
|
||||
$"The placed cut-off program crosses active clamp '{zone.Id}'. "
|
||||
+ "Regenerate or move the cut-off before posting; this warning is not a repair.")
|
||||
{ Location = contact, ClampId = zone.Id });
|
||||
}
|
||||
}
|
||||
position = moves[^1].End;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
@@ -109,19 +195,48 @@ public static class PostVerificationAnalyzer
|
||||
plateNumber, partNumber, null, message));
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// Leads Plan Cutting accepted as given (locked and drawing-pinned) are checked again here.
|
||||
var fixedMotions = new Dictionary<int, Func<int, bool>>();
|
||||
FixedLeadReview.Add(plate, plateNumber, findings, clamps, cancellationToken, fixedMotions);
|
||||
// Parts nested in cutouts: inner-before-host order and leads across the pair.
|
||||
NestedPartReview.Add(plate, plateNumber, findings, fixedMotions, cancellationToken);
|
||||
// Lead-in pierces from any source keep the pierce diameter's zone clear of material.
|
||||
PierceZoneReview.Add(plate, plateNumber, findings, cancellationToken);
|
||||
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
|
||||
PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty<ClampZone>()).ToArray(),
|
||||
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)."));
|
||||
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
|
||||
{ Location = pair.Marker, Overlap = pair });
|
||||
foreach (var pair in overlap.ClampFindings)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).")
|
||||
{ Location = pair.Marker, Overlap = pair });
|
||||
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));
|
||||
// Parts that do not overlap may still be closer than the plate's part spacing.
|
||||
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
// Lines are tested exactly; an arc conservatively by its whole supporting extent.
|
||||
internal static bool CutTouches(ExecutionMotion move, Vector from, Box zone, out Vector contact)
|
||||
{
|
||||
if (move.Curve == null || move.Curve.IsLine)
|
||||
return ClampCutDiagnostics.Intersects(from, move.End, zone, out contact);
|
||||
var extent = move.Curve.Extent;
|
||||
contact = new Vector(
|
||||
System.Math.Clamp(move.Curve.Midpoint.X, zone.Left, zone.Right),
|
||||
System.Math.Clamp(move.Curve.Midpoint.Y, zone.Bottom, zone.Top));
|
||||
return !(extent.MinX > zone.Right || extent.MaxX < zone.Left
|
||||
|| extent.MinY > zone.Top || extent.MaxY < zone.Bottom);
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ namespace OpenNest.Diagnostics;
|
||||
internal static class PostVerificationGeometry
|
||||
{
|
||||
internal const double Epsilon = 1e-8;
|
||||
// Matches NominalContourClosure.Tolerance: the largest gap capture admits as a closed ring.
|
||||
// Ray parity snaps vertex Y comparisons within this band so an admitted seam cannot flip an
|
||||
// exterior point to interior (see Curve.CrossesRay).
|
||||
internal const double RaySeamTolerance = 1e-6;
|
||||
private const double TwoPi = 2 * System.Math.PI;
|
||||
|
||||
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
|
||||
@@ -40,9 +44,37 @@ internal static class PostVerificationGeometry
|
||||
/// are more than <see cref="ClearMargin"/> apart on some axis, so no native query can count
|
||||
/// anything in one as touching or entering the other. Everything else must be checked.
|
||||
/// </summary>
|
||||
internal bool IsClearOf(Extent other) => IsWellConditioned && other.IsWellConditioned
|
||||
&& (MaxX + ClearMargin < other.MinX || other.MaxX + ClearMargin < MinX
|
||||
|| MaxY + ClearMargin < other.MinY || other.MaxY + ClearMargin < MinY);
|
||||
internal bool IsClearOf(Extent other) => IsClearOf(other, ClearMargin);
|
||||
|
||||
/// <summary>
|
||||
/// The relative rounding allowance of native line/circle intersections. Their discriminant
|
||||
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
|
||||
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a
|
||||
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
|
||||
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
|
||||
/// small circle needs the full check; a short lead beside a nearby part does not.
|
||||
/// </summary>
|
||||
internal const double SpanRounding = 1e-6;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="IsClearOf(Extent)"/>, with a gap that also covers the rounding of native
|
||||
/// line/circle queries over the span of both extents (<see cref="SpanRounding"/>), plus
|
||||
/// <paramref name="reach"/>. Lead checks skip a material only on this test.
|
||||
/// </summary>
|
||||
internal bool IsClearOfQueriesFrom(Extent other, double reach = 0)
|
||||
{
|
||||
var union = Union(other);
|
||||
var span = System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
|
||||
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
|
||||
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
|
||||
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
|
||||
|
||||
private bool IsClearOf(Extent other, double margin) => IsWellConditioned && other.IsWellConditioned
|
||||
&& (MaxX + margin < other.MinX || other.MaxX + margin < MinX
|
||||
|| MaxY + margin < other.MinY || other.MaxY + margin < MinY);
|
||||
|
||||
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
|
||||
// (including the empty extent's) fail this test too.
|
||||
@@ -89,6 +121,30 @@ internal static class PostVerificationGeometry
|
||||
return inside;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
|
||||
IReadOnlyList<Curve> curves, CancellationToken token)
|
||||
{
|
||||
var length = start.DistanceTo(end);
|
||||
if (length <= Epsilon)
|
||||
return Array.Empty<Vector>();
|
||||
var direction = (end - start) * (1 / length);
|
||||
var distances = new List<double>();
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
curve.ContactAfterStart(start, direction, length, distances);
|
||||
}
|
||||
distances.Sort();
|
||||
var points = new List<Vector>();
|
||||
foreach (var distance in distances)
|
||||
{
|
||||
var point = start + direction * distance;
|
||||
if (points.Count == 0 || points[^1].DistanceTo(point) > Epsilon)
|
||||
points.Add(point);
|
||||
}
|
||||
return points.AsReadOnly();
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -97,6 +153,10 @@ internal static class PostVerificationGeometry
|
||||
return angle < 0 ? angle + TwoPi : angle;
|
||||
}
|
||||
|
||||
/// <summary>Exact identity of a <see cref="Curve"/>'s defining values (see <see cref="Curve.Key"/>).</summary>
|
||||
internal readonly record struct CurveKey(long StartX, long StartY, long EndX, long EndY,
|
||||
bool HasCenter, long CenterX, long CenterY, long Radius, long Sweep);
|
||||
|
||||
internal sealed class Curve
|
||||
{
|
||||
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
|
||||
@@ -106,14 +166,20 @@ internal static class PostVerificationGeometry
|
||||
Center = center;
|
||||
Radius = radius;
|
||||
Sweep = sweep;
|
||||
nativeEntity = new Lazy<Entity>(ToEntity);
|
||||
}
|
||||
|
||||
// Never expose this mutable native wrapper; its geometry is read-only after creation.
|
||||
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
|
||||
private readonly Lazy<Entity> nativeEntity;
|
||||
|
||||
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 bool IsLine => !Center.HasValue;
|
||||
|
||||
// Native entities are freshly allocated; the immutable curve never exposes state.
|
||||
internal Entity ToEntity() => Center is { } center
|
||||
@@ -138,20 +204,41 @@ internal static class PostVerificationGeometry
|
||||
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
|
||||
: (Start + End) * 0.5;
|
||||
|
||||
internal bool SameSupport(Curve other)
|
||||
/// <summary>
|
||||
/// The exact bits of every value a native query reads, so two curves with equal keys
|
||||
/// give identical query results. Not a geometric tolerance: equal shapes built from
|
||||
/// different roundoff get different keys.
|
||||
/// </summary>
|
||||
internal CurveKey Key => new(Bits(Start.X), Bits(Start.Y), Bits(End.X), Bits(End.Y),
|
||||
Center.HasValue, Bits(Center?.X ?? 0), Bits(Center?.Y ?? 0), Bits(Radius), Bits(Sweep));
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
|
||||
internal bool SameSupport(Curve other) => SameSupport(other, Epsilon);
|
||||
|
||||
/// <summary>
|
||||
/// Support agreement with an explicit positional allowance. Used only where an upstream
|
||||
/// stage has already admitted a larger nominal discrepancy — currently the inclusive
|
||||
/// <see cref="NominalContourClosure.Tolerance"/> joint that a replayed ring bridges. It is
|
||||
/// never a collision tolerance: safety queries keep the exact epsilon.
|
||||
/// </summary>
|
||||
internal bool SameSupport(Curve other, double positionTolerance)
|
||||
{
|
||||
if (Center is { } center)
|
||||
return other.Center is { } c && center.DistanceTo(c) <= Epsilon
|
||||
&& System.Math.Abs(Radius - other.Radius) <= Epsilon;
|
||||
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon)
|
||||
return other.Center is { } c && center.DistanceTo(c) <= positionTolerance
|
||||
&& System.Math.Abs(Radius - other.Radius) <= positionTolerance;
|
||||
if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance)
|
||||
return false;
|
||||
var direction = (End - Start) * (1 / Length);
|
||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
|
||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance;
|
||||
}
|
||||
|
||||
// Directed native arc-length coordinates for contour replay, not collision queries.
|
||||
internal bool SameDirection(Curve other) => SameSupport(other)
|
||||
internal bool SameDirection(Curve other) => SameDirection(other, Epsilon);
|
||||
|
||||
/// <summary>Directed coverage check with an admitted positional allowance; direction sign stays exact.</summary>
|
||||
internal bool SameDirection(Curve other, double positionTolerance) => SameSupport(other, positionTolerance)
|
||||
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
|
||||
: Dot(End - Start, other.End - other.Start) > 0);
|
||||
|
||||
@@ -160,6 +247,25 @@ internal static class PostVerificationGeometry
|
||||
: Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
|
||||
: Dot(point - Start, (End - Start) * (1 / Length));
|
||||
|
||||
/// <summary>Unit direction of travel at the curve's start or end.</summary>
|
||||
internal Vector Tangent(bool atEnd)
|
||||
{
|
||||
if (Center is not { } center)
|
||||
return (End - Start) * (1 / Length);
|
||||
var radial = (atEnd ? End : Start) - center;
|
||||
var turn = System.Math.Sign(Sweep);
|
||||
return new Vector(-radial.Y * turn, radial.X * turn) * (1 / Radius);
|
||||
}
|
||||
|
||||
/// <summary>How far the curve reaches from <paramref name="origin"/> along unit <paramref name="direction"/>.</summary>
|
||||
internal double Reach(Vector origin, Vector direction)
|
||||
{
|
||||
var reach = System.Math.Max(Dot(Start - origin, direction), Dot(End - origin, direction));
|
||||
if (Center is { } center && OnArc(center + direction * Radius))
|
||||
reach = System.Math.Max(reach, Dot(center - origin, direction) + Radius);
|
||||
return reach;
|
||||
}
|
||||
|
||||
internal Vector PointAtLength(double distance)
|
||||
{
|
||||
if (distance <= 0) return Start;
|
||||
@@ -170,12 +276,28 @@ internal static class PostVerificationGeometry
|
||||
: Start + (End - Start) * (distance / Length);
|
||||
}
|
||||
|
||||
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
|
||||
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
|
||||
|
||||
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
||||
|
||||
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
|
||||
internal bool Contains(Vector point, double positionTolerance)
|
||||
=> nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= positionTolerance;
|
||||
|
||||
/// <summary>
|
||||
/// Filters supporting-circle contacts against the actual finite arc span. The
|
||||
/// native angular padding is larger and must not turn an arc extension into a
|
||||
/// self-intersection at an adjacent nominal joint. This is not a radial test.
|
||||
/// </summary>
|
||||
internal bool IsOutsideArcSpan(Vector point) => Center.HasValue && !OnArc(point);
|
||||
|
||||
internal bool IsFiniteArc => Center.HasValue && System.Math.Abs(Sweep) < TwoPi;
|
||||
|
||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||
{
|
||||
var entity = ToEntity();
|
||||
var candidate = other.ToEntity();
|
||||
var entity = nativeEntity.Value;
|
||||
var candidate = other.nativeEntity.Value;
|
||||
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
||||
// both unfiltered queries first: roundoff can differ by operand direction.
|
||||
// Coincident supports have separate overlap handling below.
|
||||
@@ -203,18 +325,21 @@ internal static class PostVerificationGeometry
|
||||
points.Clear();
|
||||
// Coincident circles produce NaNs in the native discrete-contact query.
|
||||
// Their support overlap is handled separately, without inventing crossings.
|
||||
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other)
|
||||
|| InteriorWitness(other.Midpoint, this)
|
||||
|| InteriorWitness(Start, other) || InteriorWitness(End, other)
|
||||
|| InteriorWitness(other.Start, this) || InteriorWitness(other.End, this));
|
||||
// Supports that agree only within Epsilon (arcs of one circle whose centers were
|
||||
// each derived from their own endpoints) put a witness up to that far off the other
|
||||
// support. Test those spans at the admitted seam tolerance instead: shared spans
|
||||
// still refuse, while arcs that only meet at a joint or lie apart are not overlap.
|
||||
var witness = Center is { } c && other.Center is { } oc
|
||||
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
|
||||
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other, witness)
|
||||
|| InteriorWitness(other.Midpoint, this, witness)
|
||||
|| InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
|
||||
|| InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
|
||||
if (SameSupport(other))
|
||||
points.Clear();
|
||||
foreach (var point in points)
|
||||
Validate(point);
|
||||
var nativeContactCount = points.Count;
|
||||
if (Center is { } c && other.Center is { } oc && SameSupport(other)
|
||||
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius))
|
||||
overlap = true; // Nearly coincident supports are uncertain, never clear.
|
||||
foreach (var point in new[] { Start, End, other.Start, other.End })
|
||||
if (Contains(point) && other.Contains(point)
|
||||
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
|
||||
@@ -227,9 +352,9 @@ internal static class PostVerificationGeometry
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
return points;
|
||||
|
||||
static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point)
|
||||
static bool InteriorWitness(Vector point, Curve curve, double tolerance) => curve.Contains(point, tolerance)
|
||||
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
|
||||
|| (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon));
|
||||
|| (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -281,7 +406,8 @@ internal static class PostVerificationGeometry
|
||||
// The largest distance from the centre that ContactAfterStart can count as contact.
|
||||
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
||||
|
||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
|
||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
|
||||
List<double> contacts = null)
|
||||
{
|
||||
if (Center is { } center)
|
||||
{
|
||||
@@ -294,10 +420,13 @@ internal static class PostVerificationGeometry
|
||||
if (square < -ContactSlack)
|
||||
return false;
|
||||
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||
return Hit(projection - offset) || Hit(projection + offset);
|
||||
return contacts == null
|
||||
? Hit(projection - offset) || Hit(projection + offset)
|
||||
: Hit(projection - offset) | Hit(projection + offset);
|
||||
|
||||
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
||||
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
|
||||
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length))
|
||||
&& Record(distance);
|
||||
}
|
||||
var edge = End - Start;
|
||||
var relativeStart = Start - origin;
|
||||
@@ -310,19 +439,36 @@ internal static class PostVerificationGeometry
|
||||
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;
|
||||
return high > Epsilon && low <= high + Epsilon && Record(low) && Record(high);
|
||||
}
|
||||
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);
|
||||
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon)
|
||||
&& Record(distanceAlongRapid);
|
||||
|
||||
bool Record(double distance)
|
||||
{
|
||||
contacts?.Add(System.Math.Clamp(distance, 0, length));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool CrossesRay(Vector point)
|
||||
{
|
||||
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
|
||||
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
|
||||
// PEP-converted or rotated geometry carries sub-micro-inch vertex jogs, so an exact
|
||||
// comparison can split one vertex across the ray and report far-away exterior points as
|
||||
// interior — which made LeadPathValidator refuse leads that could touch nothing.
|
||||
// Snapping within the admitted seam tolerance keeps the decision consistent on both
|
||||
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
|
||||
// still count. Containment of a point that itself lies within the tolerance of a
|
||||
// boundary edge stays ambiguous by design, exactly as it was before.
|
||||
var rayY = point.Y + RaySeamTolerance;
|
||||
if (Center is not { } center)
|
||||
return (Start.Y > point.Y) != (End.Y > point.Y)
|
||||
return (Start.Y > rayY) != (End.Y > rayY)
|
||||
&& 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
|
||||
@@ -343,7 +489,7 @@ internal static class PostVerificationGeometry
|
||||
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))
|
||||
if ((ya > rayY) == (yb > rayY))
|
||||
continue;
|
||||
var dy = point.Y - center.Y;
|
||||
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
@@ -10,12 +11,45 @@ public enum PostVerificationKind
|
||||
Overlap,
|
||||
MissingLeadIn,
|
||||
RapidCrossing,
|
||||
Incomplete
|
||||
Incomplete,
|
||||
|
||||
/// <summary>
|
||||
/// A violation inside a fixed emission (a locked program, or a contour cut exactly as its
|
||||
/// drawing pins it): accepted as given, never repaired, always reported for review.
|
||||
/// </summary>
|
||||
FixedLead,
|
||||
|
||||
/// <summary>A part inside another part's cutout is cut after that host, whose cutout releases it.</summary>
|
||||
NestedCutOrder,
|
||||
|
||||
/// <summary>
|
||||
/// A lead of a nested part or of its host contacts or enters the other's material; for a host
|
||||
/// this is typically an enclosing-hole lead crossing the insert.
|
||||
/// </summary>
|
||||
NestedLead,
|
||||
|
||||
/// <summary>A lead-in pierce is closer to part material than half the pierce diameter.</summary>
|
||||
PierceZone,
|
||||
/// <summary>
|
||||
/// Two parts are closer than the plate's part spacing without sharing material, judged as
|
||||
/// Auto Nest's layout check judges them.
|
||||
/// </summary>
|
||||
PartSpacing
|
||||
}
|
||||
|
||||
/// <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);
|
||||
int? PartNumber, int? OtherPartNumber, string Message)
|
||||
{
|
||||
public Vector? Location { get; init; }
|
||||
public Vector? RapidStart { get; init; }
|
||||
public Vector? RapidEnd { get; init; }
|
||||
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
|
||||
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
|
||||
public PlateOverlapPair Overlap { get; init; }
|
||||
/// <summary>Active clamp contacted by a cut-off or direct XY rapid, if applicable.</summary>
|
||||
public string ClampId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
||||
public sealed class PostVerificationReport
|
||||
@@ -29,14 +63,27 @@ public sealed class PostVerificationReport
|
||||
public bool HasWarnings => Findings.Count != 0;
|
||||
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
||||
|
||||
public string ToDisplayText()
|
||||
public string ToDisplayText() => ToDisplayText("Pre-post verification");
|
||||
|
||||
public string ToDisplayText(string title)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine("Pre-post verification");
|
||||
text.AppendLine(title);
|
||||
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||
Summary(PostVerificationKind.Overlap, "Overlap");
|
||||
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
||||
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
|
||||
// Only nests with fixed-lead findings gain the line, so other reports read as before.
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.FixedLead))
|
||||
Summary(PostVerificationKind.FixedLead, "Fixed leads");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedCutOrder))
|
||||
Summary(PostVerificationKind.NestedCutOrder, "Nested part order");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedLead))
|
||||
Summary(PostVerificationKind.NestedLead, "Nested part leads");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PierceZone))
|
||||
Summary(PostVerificationKind.PierceZone, "Pierce zones");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
|
||||
Summary(PostVerificationKind.PartSpacing, "Part spacing");
|
||||
foreach (var finding in Findings)
|
||||
{
|
||||
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||
@@ -46,16 +93,16 @@ public sealed class PostVerificationReport
|
||||
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.");
|
||||
text.AppendLine("These checks cover direct XY rapids in plate/program order; the post may " +
|
||||
"change order, routing or retracts. Inspect the posted machine program and machine setup " +
|
||||
"before cutting, and confirm actual contour gaps give 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." : "."));
|
||||
(incomplete ? "; verification incomplete — review before posting." : "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,14 +104,53 @@ namespace OpenNest
|
||||
|
||||
public Color Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when someone chose <see cref="Color"/> on purpose. Otherwise the color comes
|
||||
/// from the active scheme's palette and is reassigned when the scheme changes or the
|
||||
/// nest is opened.
|
||||
/// </summary>
|
||||
public bool HasCustomColor { get; set; }
|
||||
|
||||
public bool IsCutOff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cut this drawing's parts before every other part when automatic sequencing runs,
|
||||
/// e.g. a test coupon checked before the rest of the sheet is cut.
|
||||
/// </summary>
|
||||
public bool CutFirst { get; set; }
|
||||
|
||||
public NestConstraints Constraints { get; set; }
|
||||
|
||||
public SourceInfo Source { get; set; }
|
||||
|
||||
public List<Bend> Bends { get; set; } = new List<Bend>();
|
||||
|
||||
/// <summary>
|
||||
/// The library revision this drawing was copied from, or null when it did not come
|
||||
/// from a drawing library. Editing the drawing in a nest keeps the link as provenance.
|
||||
/// </summary>
|
||||
public DrawingLibraryLink Library { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contours this drawing says must be cut a fixed way, in the source frame. At most one
|
||||
/// pin per contour is used; <see cref="CNC.CuttingPlanning.CutPinResolver"/> maps them to
|
||||
/// a placement. Pins do not follow geometry into new drawings (split pieces).
|
||||
/// </summary>
|
||||
public List<CNC.CuttingStrategy.ContourCutPin> CutPins { get; set; } = new List<CNC.CuttingStrategy.ContourCutPin>();
|
||||
|
||||
/// <summary>
|
||||
/// Scrap pieces tabbed to the part, in the source frame. <see cref="Program"/> already has
|
||||
/// them applied; they are kept so an editor can change them from <see cref="SourceEntities"/>.
|
||||
/// </summary>
|
||||
public List<Breakoffs.Breakoff> Breakoffs { get; set; } = new List<Breakoffs.Breakoff>();
|
||||
|
||||
/// <summary>
|
||||
/// Holes whose slugs stay attached by tabs, in the source frame. <see cref="Program"/> already
|
||||
/// has them applied (cut pieces plus <see cref="CNC.LayerType.Tab"/> spans); they are kept so
|
||||
/// an editor can change them from <see cref="SourceEntities"/>.
|
||||
/// </summary>
|
||||
public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new List<TabbedCutouts.TabbedCutout>();
|
||||
|
||||
/// <summary>
|
||||
/// Complete set of source entities with stable GUIDs.
|
||||
/// Null when the drawing was created from G-code or an older nest file.
|
||||
@@ -124,6 +163,14 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
|
||||
|
||||
/// <summary>
|
||||
/// Role overrides for entities in <see cref="SourceEntities"/>; entities not listed keep the
|
||||
/// role their layer implies (see <see cref="Converters.EntityRoles"/>). <see cref="Program"/> already has
|
||||
/// them applied; display-only entities are not in it and come from
|
||||
/// <see cref="Converters.EntityRoles.DisplayGeometry"/>.
|
||||
/// </summary>
|
||||
public Dictionary<Guid, EntityRole> EntityRoles { get; set; } = new Dictionary<Guid, EntityRole>();
|
||||
|
||||
public double Area { get; protected set; }
|
||||
|
||||
public void UpdateArea()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Where a nest drawing came from in a server drawing library: the exact revision that
|
||||
/// was copied in and a snapshot of its library details at that time. The nest owns its
|
||||
/// copy; the link never changes the library and the library never changes the copy.
|
||||
/// Immutable, so drawing copies may share one instance.
|
||||
/// </summary>
|
||||
public sealed class DrawingLibraryLink
|
||||
{
|
||||
public DrawingLibraryLink(string serverKey, Guid id, int revision, CutType cutType, double thickness,
|
||||
string materialName, string materialGrade, Units units, string notes, string packageSha256)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverKey))
|
||||
throw new ArgumentException("A library link needs the server it came from.", nameof(serverKey));
|
||||
if (id == Guid.Empty)
|
||||
throw new ArgumentException("A library link needs a drawing id.", nameof(id));
|
||||
if (revision < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(revision), revision, "Revisions start at 1.");
|
||||
if (!Enum.IsDefined(cutType))
|
||||
throw new ArgumentOutOfRangeException(nameof(cutType), cutType, "Unsupported cut type.");
|
||||
if (!double.IsFinite(thickness) || thickness < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(thickness), thickness, "Thickness must be finite and not negative.");
|
||||
if (!Enum.IsDefined(units))
|
||||
throw new ArgumentOutOfRangeException(nameof(units), units, "Unsupported units.");
|
||||
|
||||
ServerKey = serverKey;
|
||||
Id = id;
|
||||
Revision = revision;
|
||||
CutType = cutType;
|
||||
Thickness = thickness;
|
||||
MaterialName = materialName ?? "";
|
||||
MaterialGrade = materialGrade ?? "";
|
||||
Units = units;
|
||||
Notes = notes ?? "";
|
||||
PackageSha256 = packageSha256 ?? "";
|
||||
}
|
||||
|
||||
/// <summary>Normalized base URL of the server holding the library (no trailing slash).</summary>
|
||||
public string ServerKey { get; }
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
/// <summary>The library revision this drawing is a copy of.</summary>
|
||||
public int Revision { get; }
|
||||
|
||||
public CutType CutType { get; }
|
||||
|
||||
/// <summary>Library thickness in <see cref="Units"/>.</summary>
|
||||
public double Thickness { get; }
|
||||
|
||||
public string MaterialName { get; }
|
||||
|
||||
public string MaterialGrade { get; }
|
||||
|
||||
public Units Units { get; }
|
||||
|
||||
public string Notes { get; }
|
||||
|
||||
/// <summary>Lower-case hex SHA-256 of the library package for <see cref="Revision"/>.</summary>
|
||||
public string PackageSha256 { get; }
|
||||
|
||||
/// <summary>True when both links name the same revision of the same library drawing.</summary>
|
||||
public bool IsSameRevision(DrawingLibraryLink other) =>
|
||||
other != null && Id == other.Id && Revision == other.Revision
|
||||
&& string.Equals(ServerKey, other.ServerKey, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
@@ -10,11 +11,11 @@ namespace OpenNest;
|
||||
/// </summary>
|
||||
public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
private readonly Dictionary<Drawing, string> programs;
|
||||
private readonly Dictionary<Drawing, Captured> programs;
|
||||
private readonly Func<Program, string> fingerprint;
|
||||
|
||||
private DrawingProgramSnapshot(
|
||||
Dictionary<Drawing, string> programs,
|
||||
Dictionary<Drawing, Captured> programs,
|
||||
Func<Program, string> fingerprint)
|
||||
{
|
||||
this.programs = programs;
|
||||
@@ -32,12 +33,12 @@ public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawings);
|
||||
ArgumentNullException.ThrowIfNull(fingerprint);
|
||||
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
|
||||
var programs = new Dictionary<Drawing, Captured>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
|
||||
programs.Add(drawing, fingerprint(drawing.Program));
|
||||
programs.Add(drawing, new Captured(fingerprint(drawing.Program), SourceOffset(drawing)));
|
||||
}
|
||||
|
||||
return new DrawingProgramSnapshot(programs, fingerprint);
|
||||
@@ -45,20 +46,22 @@ public sealed class DrawingProgramSnapshot
|
||||
|
||||
/// <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.
|
||||
/// Part.Update clears obsolete lead-ins, tabs and locks; the placement is anchored to the
|
||||
/// source frame, so a rebuild that re-origins the program (Source.Offset moved, e.g. a new
|
||||
/// first pierce) leaves the geometry where it was on the plate.
|
||||
/// 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);
|
||||
var originShifts = new Dictionary<Drawing, Vector>(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);
|
||||
&& !string.Equals(entry.Value.Fingerprint, fingerprint(entry.Key.Program), StringComparison.Ordinal))
|
||||
originShifts.Add(entry.Key, SourceOffset(entry.Key) - entry.Value.SourceOffset);
|
||||
}
|
||||
|
||||
var updated = new List<Part>();
|
||||
@@ -66,14 +69,22 @@ public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (!changed.Contains(part.BaseDrawing))
|
||||
if (!originShifts.TryGetValue(part.BaseDrawing, out var shift))
|
||||
continue;
|
||||
|
||||
// A source point P sat at Location + R(P - old); keep it there with the new origin.
|
||||
var rotation = part.Rotation;
|
||||
part.Update();
|
||||
if (shift != Vector.Zero)
|
||||
part.Offset(shift.Rotate(rotation));
|
||||
updated.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static Vector SourceOffset(Drawing drawing) => drawing.Source?.Offset ?? Vector.Zero;
|
||||
|
||||
private readonly record struct Captured(string Fingerprint, Vector SourceOffset);
|
||||
}
|
||||
@@ -80,16 +80,60 @@ namespace OpenNest.Geometry
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>Distance from a closed boundary's segments to every segment
|
||||
/// of an intentional open cutting path. Unlike polygonizing that path,
|
||||
/// this retains segment interiors and disjoint subpaths.</summary>
|
||||
internal static ClearanceResult BoundaryDistance(List<Line> closed, List<Line> open)
|
||||
{
|
||||
if (closed.Count == 0 || open.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
return Separation(closed, open);
|
||||
}
|
||||
|
||||
/// <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 = ClosestPoints(linesA, linesB, out var pa, out var pb);
|
||||
|
||||
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>
|
||||
/// Minimum boundary (edge-to-edge) distance between two rings and the point on each
|
||||
/// boundary where it occurs. Like <see cref="BoundaryDistance(Polygon, Polygon)"/>, never
|
||||
/// tests overlap. Infinity, with zero points, when either ring has no edges.
|
||||
/// </summary>
|
||||
public static double ClosestPoints(Polygon a, Polygon b, out Vector onA, out Vector onB)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
{
|
||||
onA = onB = Vector.Zero;
|
||||
return double.PositiveInfinity;
|
||||
}
|
||||
|
||||
return ClosestPoints(linesA, linesB, out onA, out onB);
|
||||
}
|
||||
|
||||
internal static double ClosestPoints(List<Line> linesA, List<Line> linesB, out Vector pa, out Vector pb)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var pa = Vector.Zero;
|
||||
var pb = Vector.Zero;
|
||||
pa = Vector.Zero;
|
||||
pb = Vector.Zero;
|
||||
|
||||
var boxes = new Box[linesB.Count];
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
@@ -114,15 +158,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,12 +5,14 @@ using OpenNest.Math;
|
||||
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).
|
||||
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair
|
||||
/// checks. It was written as the reference for a GPU kernel; that port is on hold in
|
||||
/// favour of Clipper2, starting with the material-overlap diagnostic. Engine callers
|
||||
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate
|
||||
/// pass.
|
||||
/// <para>
|
||||
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
|
||||
/// Former GPU-port contract, kept for reference. 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,
|
||||
|
||||
@@ -953,7 +953,7 @@ namespace OpenNest.Geometry
|
||||
OldSampleCount = old.TotalSamples,
|
||||
DiagnosticIoU = iou,
|
||||
FailureMessage = (reasons & (AlignmentReasons.FailedConvergence | AlignmentReasons.InsufficientSupport)) != 0
|
||||
? "Automatic alignment is not trustworthy for this pair; use the manual overlay."
|
||||
? "Use the manual overlay to align this pair."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance-free test of material against an axis-aligned keep-out rectangle, for
|
||||
/// rectangles too small for the tolerance-based overlap machinery (validation offsets,
|
||||
/// Clipper grids, triangulation and vector equality work at 1e-3 to 1e-5 scale and collapse
|
||||
/// such a rectangle, PM 6859db53). Positive-area overlap only: flush contact is clear, as
|
||||
/// everywhere else.
|
||||
/// </summary>
|
||||
public static class KeepOutOverlap
|
||||
{
|
||||
/// <summary>The scale below which tolerance-based overlap tests are not trusted for a
|
||||
/// keep-out rectangle (the validation outline chord tolerance).</summary>
|
||||
public const double ToleranceFreeBelow = 0.001;
|
||||
|
||||
/// <summary>A keep-out narrower than <see cref="ToleranceFreeBelow"/> on either axis.</summary>
|
||||
public static bool NeedsToleranceFreeTest(Box keepOut) =>
|
||||
keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow;
|
||||
|
||||
/// <summary>
|
||||
/// Chord tolerance for flattening real part material against <paramref name="keepOut"/>:
|
||||
/// a quarter of its smaller side, at most <see cref="ToleranceFreeBelow"/>.
|
||||
/// </summary>
|
||||
public static double FlatteningTolerance(Box keepOut) =>
|
||||
System.Math.Min(ToleranceFreeBelow, System.Math.Min(keepOut.Length, keepOut.Width) / 4);
|
||||
|
||||
/// <summary>
|
||||
/// A part's native contours flattened two ways for a tiny keep-out, never through a
|
||||
/// grid-rounding offset: lines exact, every arc (perimeter and cutouts) inscribed in the
|
||||
/// first and circumscribed in the second, at <see cref="FlatteningTolerance"/>. Along a
|
||||
/// convex arc the circumscribed version holds all real material, along a concave arc
|
||||
/// (a notch, a round hole) the inscribed one does, so together they hold all of it and
|
||||
/// testing both finds any real overlap. The price is a possible report within that
|
||||
/// tolerance of an arc, never a false clear.
|
||||
/// </summary>
|
||||
public static IEnumerable<(Polygon Perimeter, List<Polygon> Holes)> ConservativePolygons(
|
||||
ShapeProfile profile, Box keepOut)
|
||||
{
|
||||
var tolerance = FlatteningTolerance(keepOut);
|
||||
foreach (var circumscribe in new[] { false, true })
|
||||
{
|
||||
var perimeter = profile.Perimeter.ToPolygonWithTolerance(tolerance, circumscribe);
|
||||
var holes = new List<Polygon>(profile.Cutouts.Count);
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
holes.Add(cutout.ToPolygonWithTolerance(tolerance, circumscribe));
|
||||
yield return (perimeter, holes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when either of the <see cref="ConservativePolygons"/>, translated by
|
||||
/// <paramref name="offset"/>, enters <paramref name="keepOut"/>.
|
||||
/// </summary>
|
||||
public static bool ProfileEnters(ShapeProfile profile, Vector offset, Box keepOut)
|
||||
{
|
||||
foreach (var (perimeter, holes) in ConservativePolygons(profile, keepOut))
|
||||
{
|
||||
perimeter.Offset(offset);
|
||||
foreach (var hole in holes)
|
||||
hole.Offset(offset);
|
||||
if (MaterialEnters(perimeter, holes, keepOut))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the material (perimeter minus holes) shares positive area with the open
|
||||
/// rectangle: some boundary edge runs through its interior, or its interior lies wholly
|
||||
/// inside the material (tested at its centre).
|
||||
/// </summary>
|
||||
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box)
|
||||
{
|
||||
if (RingEnters(perimeter, box))
|
||||
return true;
|
||||
if (holes != null)
|
||||
foreach (var hole in holes)
|
||||
if (RingEnters(hole, box))
|
||||
return true;
|
||||
|
||||
var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
|
||||
if (!Contains(perimeter, centre))
|
||||
return false;
|
||||
if (holes != null)
|
||||
foreach (var hole in holes)
|
||||
if (Contains(hole, centre))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RingEnters(Polygon ring, Box box)
|
||||
{
|
||||
var vertices = ring.Vertices;
|
||||
var count = vertices.Count;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var next = vertices[(i + 1) % count];
|
||||
if (SegmentEntersOpenBox(vertices[i], next, box))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Liang-Barsky clip against the open rectangle; a positive-length part of the
|
||||
/// segment must lie strictly inside it.</summary>
|
||||
private static bool SegmentEntersOpenBox(Vector p, Vector q, Box box)
|
||||
{
|
||||
var dx = q.X - p.X;
|
||||
var dy = q.Y - p.Y;
|
||||
var t0 = 0.0;
|
||||
var t1 = 1.0;
|
||||
return Clip(-dx, p.X - box.Left, ref t0, ref t1)
|
||||
&& Clip(dx, box.Right - p.X, ref t0, ref t1)
|
||||
&& Clip(-dy, p.Y - box.Bottom, ref t0, ref t1)
|
||||
&& Clip(dy, box.Top - p.Y, ref t0, ref t1)
|
||||
&& t0 < t1;
|
||||
}
|
||||
|
||||
/// <summary>Restricts [t0, t1] to the parameters where <c>denominator * t < numerator</c>.</summary>
|
||||
private static bool Clip(double denominator, double numerator, ref double t0, ref double t1)
|
||||
{
|
||||
if (denominator == 0)
|
||||
return numerator > 0;
|
||||
var r = numerator / denominator;
|
||||
if (denominator < 0)
|
||||
{
|
||||
if (r >= t1)
|
||||
return false;
|
||||
if (r > t0)
|
||||
t0 = r;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (r <= t0)
|
||||
return false;
|
||||
if (r < t1)
|
||||
t1 = r;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Even-odd ray cast; exact for a point that is not on the ring (callers only
|
||||
/// ask after ruling out any edge through the rectangle containing the point).</summary>
|
||||
private static bool Contains(Polygon ring, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
var vertices = ring.Vertices;
|
||||
var count = vertices.Count;
|
||||
for (int i = 0, j = count - 1; i < count; j = i++)
|
||||
{
|
||||
var a = vertices[i];
|
||||
var b = vertices[j];
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Smallest circle containing every point (Welzl's algorithm, expected linear time). Points are
|
||||
/// visited in a fixed shuffled order, so the result is repeatable.
|
||||
/// </summary>
|
||||
public static class MinimumEnclosingCircle
|
||||
{
|
||||
public static Circle Compute(IEnumerable<Vector> points)
|
||||
{
|
||||
var shuffled = points.ToList();
|
||||
|
||||
if (shuffled.Count == 0)
|
||||
throw new ArgumentException("At least one point is required.", nameof(points));
|
||||
|
||||
var random = new Random(0);
|
||||
|
||||
for (var i = shuffled.Count - 1; i > 0; i--)
|
||||
{
|
||||
var j = random.Next(i + 1);
|
||||
(shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]);
|
||||
}
|
||||
|
||||
var center = shuffled[0];
|
||||
var radius = 0.0;
|
||||
|
||||
for (var i = 1; i < shuffled.Count; i++)
|
||||
{
|
||||
if (Contains(center, radius, shuffled[i]))
|
||||
continue;
|
||||
|
||||
center = shuffled[i];
|
||||
radius = 0;
|
||||
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (Contains(center, radius, shuffled[j]))
|
||||
continue;
|
||||
|
||||
center = (shuffled[i] + shuffled[j]) * 0.5;
|
||||
radius = center.DistanceTo(shuffled[i]);
|
||||
|
||||
for (var k = 0; k < j; k++)
|
||||
{
|
||||
if (Contains(center, radius, shuffled[k]))
|
||||
continue;
|
||||
|
||||
(center, radius) = Circumcircle(shuffled[i], shuffled[j], shuffled[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rounding can leave a defining point a hair outside; grow to cover every point exactly.
|
||||
radius = shuffled.Max(p => p.DistanceTo(center));
|
||||
|
||||
return new Circle(center, radius);
|
||||
}
|
||||
|
||||
private static bool Contains(Vector center, double radius, Vector point) =>
|
||||
point.DistanceTo(center) <= radius * (1 + 1e-12) + 1e-12;
|
||||
|
||||
private static (Vector Center, double Radius) Circumcircle(Vector a, Vector b, Vector c)
|
||||
{
|
||||
var bx = b.X - a.X;
|
||||
var by = b.Y - a.Y;
|
||||
var cx = c.X - a.X;
|
||||
var cy = c.Y - a.Y;
|
||||
var d = 2 * (bx * cy - by * cx);
|
||||
|
||||
if (System.Math.Abs(d) <= double.Epsilon)
|
||||
{
|
||||
// Collinear: the circle on the two farthest-apart points covers the third.
|
||||
var pairs = new[] { (a, b), (a, c), (b, c) };
|
||||
var (p, q) = pairs.OrderByDescending(pair => pair.Item1.DistanceTo(pair.Item2)).First();
|
||||
var mid = (p + q) * 0.5;
|
||||
return (mid, mid.DistanceTo(p));
|
||||
}
|
||||
|
||||
var b2 = bx * bx + by * by;
|
||||
var c2 = cx * cx + cy * cy;
|
||||
var center = new Vector(a.X + (cy * b2 - by * c2) / d, a.Y + (bx * c2 - cx * b2) / d);
|
||||
|
||||
return (center, center.DistanceTo(a));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
/// <summary>
|
||||
/// Strict positive-length entry of an open native line path into polygon material.
|
||||
/// Boundary-only contact is not entry; holes subtract material. This deliberately
|
||||
/// does not infer material from a path's bounding box or close the open path.
|
||||
/// Curved contours are flattened by the caller, which must handle chord uncertainty.
|
||||
/// </summary>
|
||||
internal static class OpenPathMaterial
|
||||
{
|
||||
public static bool Enters(Polygon outer, IReadOnlyList<Polygon> holes, IReadOnlyList<Line> path)
|
||||
{
|
||||
if (outer?.Vertices.Count < 4 || path == null)
|
||||
return true; // Unresolved geometry is not a certificate of clear material.
|
||||
|
||||
foreach (var line in path)
|
||||
{
|
||||
var start = line.StartPoint;
|
||||
var end = line.EndPoint;
|
||||
var travel = end - start;
|
||||
var lengthSquared = travel.DotProduct(travel);
|
||||
if (!double.IsFinite(lengthSquared) || lengthSquared <= 0)
|
||||
return true;
|
||||
|
||||
var cuts = new List<double> { 0, 1 };
|
||||
CollectCuts(outer, start, travel, lengthSquared, cuts);
|
||||
if (holes != null)
|
||||
foreach (var hole in holes)
|
||||
CollectCuts(hole, start, travel, lengthSquared, cuts);
|
||||
cuts.Sort();
|
||||
for (var i = 1; i < cuts.Count; i++)
|
||||
{
|
||||
if (cuts[i] <= cuts[i - 1])
|
||||
continue;
|
||||
var midpoint = start + travel * ((cuts[i - 1] + cuts[i]) / 2);
|
||||
if (!StrictlyInside(outer, midpoint))
|
||||
continue;
|
||||
var inHole = false;
|
||||
if (holes != null)
|
||||
foreach (var hole in holes)
|
||||
if (InsideOrOnBoundary(hole, midpoint))
|
||||
{
|
||||
inHole = true;
|
||||
break;
|
||||
}
|
||||
if (!inHole)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void CollectCuts(Polygon ring, Vector start, Vector travel,
|
||||
double lengthSquared, List<double> cuts)
|
||||
{
|
||||
if (ring?.Vertices.Count < 4)
|
||||
{
|
||||
// A degenerate hole cannot be subtracted reliably. The caller's
|
||||
// unresolved-topology guard handles this case before this helper.
|
||||
return;
|
||||
}
|
||||
var vertices = ring.Vertices;
|
||||
for (var i = 1; i < vertices.Count; i++)
|
||||
{
|
||||
var a = vertices[i - 1];
|
||||
var edge = vertices[i] - a;
|
||||
var difference = a - start;
|
||||
var denominator = Cross(travel, edge);
|
||||
if (denominator == 0)
|
||||
{
|
||||
if (Cross(difference, travel) != 0)
|
||||
continue;
|
||||
AddParameter(difference.DotProduct(travel) / lengthSquared, cuts);
|
||||
AddParameter((vertices[i] - start).DotProduct(travel) / lengthSquared, cuts);
|
||||
continue;
|
||||
}
|
||||
var t = Cross(difference, edge) / denominator;
|
||||
var u = Cross(difference, travel) / denominator;
|
||||
if (u >= -1e-12 && u <= 1 + 1e-12)
|
||||
AddParameter(t, cuts);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddParameter(double t, List<double> cuts)
|
||||
{
|
||||
if (double.IsFinite(t) && t >= -1e-12 && t <= 1 + 1e-12)
|
||||
cuts.Add(System.Math.Clamp(t, 0, 1));
|
||||
}
|
||||
|
||||
private static bool StrictlyInside(Polygon ring, Vector point) =>
|
||||
Contains(ring, point, boundaryIsInside: false);
|
||||
|
||||
private static bool InsideOrOnBoundary(Polygon ring, Vector point) =>
|
||||
Contains(ring, point, boundaryIsInside: true);
|
||||
|
||||
private static bool Contains(Polygon ring, Vector point, bool boundaryIsInside)
|
||||
{
|
||||
var vertices = ring.Vertices;
|
||||
var inside = false;
|
||||
for (var i = 1; i < vertices.Count; i++)
|
||||
{
|
||||
var a = vertices[i - 1];
|
||||
var b = vertices[i];
|
||||
var edge = b - a;
|
||||
var relative = point - a;
|
||||
if (Cross(edge, relative) == 0 &&
|
||||
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))
|
||||
return boundaryIsInside;
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y) &&
|
||||
point.X < a.X + (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||
}
|
||||
@@ -2,14 +2,74 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Pole of inaccessibility: the interior point farthest from the outline and holes,
|
||||
/// found by best-first quadtree refinement to within <c>precision</c>.
|
||||
/// </summary>
|
||||
public static class PolyLabel
|
||||
{
|
||||
public static Vector Find(
|
||||
Polygon outer,
|
||||
IList<Polygon> holes = null,
|
||||
double precision = 0.5
|
||||
) => Find(outer, holes, precision, int.MaxValue, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Bounded search for callers that cannot afford refinement along a long thin shape:
|
||||
/// stops after about <paramref name="maxCells"/> distance evaluations and returns the best
|
||||
/// cell centre so far. When the starting grid would exceed a quarter of the budget it is
|
||||
/// coarsened so the whole shape is sampled first. The result can lie outside a shape
|
||||
/// thinner than the remaining refinement, so callers must check it.
|
||||
/// </summary>
|
||||
public static Vector Find(Polygon outer, IList<Polygon> holes, double precision, int maxCells) =>
|
||||
Find(outer, holes, precision, maxCells, out _);
|
||||
|
||||
/// <summary>
|
||||
/// A precision proportional to the shape's mean material width (twice the net area over
|
||||
/// the total outline length, holes included), so small or slender shapes refine further
|
||||
/// than large solid ones. A fixed precision near the shape's size stops at the first
|
||||
/// interior cell.
|
||||
/// </summary>
|
||||
public static double ThicknessPrecision(Polygon outer, IList<Polygon> holes = null, double fraction = 0.05)
|
||||
{
|
||||
var area = outer.Area();
|
||||
var length = ClosedLength(outer);
|
||||
|
||||
if (holes != null)
|
||||
{
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
area -= holes[i].Area();
|
||||
length += ClosedLength(holes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(area > 0) || !(length > 0))
|
||||
return 1e-9;
|
||||
|
||||
return System.Math.Max(fraction * 2 * area / length, 1e-9);
|
||||
}
|
||||
|
||||
private static double ClosedLength(Polygon polygon)
|
||||
{
|
||||
var verts = polygon.Vertices;
|
||||
var sum = 0.0;
|
||||
|
||||
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
|
||||
sum += verts[j].DistanceTo(verts[i]);
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
internal static Vector Find(
|
||||
Polygon outer,
|
||||
IList<Polygon> holes,
|
||||
double precision,
|
||||
int maxCells,
|
||||
out int evaluatedCells
|
||||
)
|
||||
{
|
||||
evaluatedCells = 0;
|
||||
if (outer.Vertices.Count < 3)
|
||||
return outer.Vertices.Count > 0 ? outer.Vertices[0] : new Vector();
|
||||
|
||||
@@ -38,29 +98,53 @@ namespace OpenNest.Geometry
|
||||
if (cellSize == 0)
|
||||
return new Vector((minX + maxX) / 2, (minY + maxY) / 2);
|
||||
|
||||
if (maxCells < int.MaxValue)
|
||||
{
|
||||
var gridLimit = System.Math.Max(1, maxCells / 4);
|
||||
while (System.Math.Ceiling(width / cellSize) * System.Math.Ceiling(height / cellSize) > gridLimit)
|
||||
cellSize *= 2;
|
||||
}
|
||||
|
||||
var halfCell = cellSize / 2;
|
||||
|
||||
var queue = new List<Cell>();
|
||||
var initial = new List<Cell>();
|
||||
|
||||
for (var x = minX; x < maxX; x += cellSize)
|
||||
for (var y = minY; y < maxY; y += cellSize)
|
||||
queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
|
||||
{
|
||||
for (var y = minY; y < maxY; y += cellSize)
|
||||
{
|
||||
initial.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
|
||||
// Far from the origin a step smaller than the coordinate's precision
|
||||
// no longer advances; the grid would never end.
|
||||
if (y + cellSize == y)
|
||||
break;
|
||||
}
|
||||
if (x + cellSize == x)
|
||||
break;
|
||||
}
|
||||
|
||||
queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
|
||||
initial.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
|
||||
|
||||
var bestCell = GetCentroidCell(outer, holes);
|
||||
evaluatedCells = initial.Count + 1;
|
||||
|
||||
for (var i = 0; i < queue.Count; i++)
|
||||
if (queue[i].Dist > bestCell.Dist)
|
||||
for (var i = 0; i < initial.Count; i++)
|
||||
if (initial[i].Dist > bestCell.Dist)
|
||||
{
|
||||
bestCell = queue[i];
|
||||
bestCell = initial[i];
|
||||
break;
|
||||
}
|
||||
|
||||
// Highest MaxDist first. Equal bounds pop newest first, and the initial cells in
|
||||
// sorted order, exactly as the former sorted list did (insertion ahead of equals).
|
||||
var queue = new PriorityQueue<Cell, (double, long)>();
|
||||
for (var i = 0; i < initial.Count; i++)
|
||||
queue.Enqueue(initial[i], (-initial[i].MaxDist, i - (long)initial.Count));
|
||||
var sequence = (long)initial.Count;
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var cell = queue[0];
|
||||
queue.RemoveAt(0);
|
||||
var cell = queue.Dequeue();
|
||||
|
||||
if (cell.Dist > bestCell.Dist)
|
||||
bestCell = cell;
|
||||
@@ -68,8 +152,17 @@ namespace OpenNest.Geometry
|
||||
if (cell.MaxDist - bestCell.Dist <= precision)
|
||||
continue;
|
||||
|
||||
if (evaluatedCells > maxCells - 4)
|
||||
break;
|
||||
|
||||
halfCell = cell.HalfSize / 2;
|
||||
|
||||
// Far from the origin a half-step may no longer move the centre; such a cell
|
||||
// cannot be split, and its identical children would multiply without end.
|
||||
if (cell.X - halfCell == cell.X || cell.X + halfCell == cell.X
|
||||
|| cell.Y - halfCell == cell.Y || cell.Y + halfCell == cell.Y)
|
||||
continue;
|
||||
|
||||
var newCells = new[]
|
||||
{
|
||||
new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
||||
@@ -77,25 +170,18 @@ namespace OpenNest.Geometry
|
||||
new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
||||
};
|
||||
evaluatedCells += newCells.Length;
|
||||
|
||||
for (var i = 0; i < newCells.Length; i++)
|
||||
{
|
||||
if (newCells[i].MaxDist > bestCell.Dist + precision)
|
||||
InsertSorted(queue, newCells[i]);
|
||||
queue.Enqueue(newCells[i], (-newCells[i].MaxDist, -++sequence));
|
||||
}
|
||||
}
|
||||
|
||||
return new Vector(bestCell.X, bestCell.Y);
|
||||
}
|
||||
|
||||
private static void InsertSorted(List<Cell> list, Cell cell)
|
||||
{
|
||||
var idx = 0;
|
||||
while (idx < list.Count && list[idx].MaxDist > cell.MaxDist)
|
||||
idx++;
|
||||
list.Insert(idx, cell);
|
||||
}
|
||||
|
||||
private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
var area = 0.0;
|
||||
|
||||
@@ -97,5 +97,35 @@ public readonly struct PolygonAreaMoments
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Net moments of an outline minus nonoverlapping holes that lie inside it, all given as
|
||||
/// positive moments (for example from <see cref="TryCompute"/>). Moments are taken about
|
||||
/// the outline's centroid. Fails for an invalid input or a nonpositive or nonfinite result.
|
||||
/// </summary>
|
||||
public static bool TrySubtract(PolygonAreaMoments outline, IEnumerable<PolygonAreaMoments> holes,
|
||||
out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (holes == null || !double.IsFinite(outline.Area) || outline.Area <= 0 || !IsFinite(outline.Centroid))
|
||||
return false;
|
||||
var area = outline.Area;
|
||||
var firstMoment = Vector.Zero;
|
||||
foreach (var hole in holes)
|
||||
{
|
||||
if (!double.IsFinite(hole.Area) || hole.Area <= 0 || !IsFinite(hole.Centroid))
|
||||
return false;
|
||||
firstMoment -= (hole.Centroid - outline.Centroid) * hole.Area;
|
||||
area -= hole.Area;
|
||||
}
|
||||
|
||||
if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
|
||||
return false;
|
||||
var centroid = outline.Centroid + firstMoment / area;
|
||||
if (!IsFinite(centroid))
|
||||
return false;
|
||||
moments = new PolygonAreaMoments(area, centroid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
@@ -76,8 +76,15 @@ namespace OpenNest.Geometry
|
||||
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
||||
{
|
||||
var cloned = entities.CloneAll();
|
||||
// Break cuts are open and their direction says which side is scrap: never re-chain or
|
||||
// re-wind them. They follow the contours, in their original order.
|
||||
var breakCuts = cloned.Where(e => e.Layer == SpecialLayers.Break).ToList();
|
||||
if (breakCuts.Count > 0)
|
||||
cloned.RemoveAll(e => e.Layer == SpecialLayers.Break);
|
||||
var profile = new ShapeProfile(cloned);
|
||||
return profile.ToNormalizedEntities();
|
||||
var result = profile.ToNormalizedEntities();
|
||||
result.AddRange(breakCuts);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EnsureWinding(Shape shape, RotationType desired)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// A part's material as world-space polygons for part-spacing checks: the perimeter and its
|
||||
/// cutouts, optionally grown by a clearance (perimeter offset outward, cutouts inward, in one
|
||||
/// Clipper region offset). Auto Nest's layout check and the pre-post review both judge
|
||||
/// spacing with this outline and <see cref="InflationFor"/>, so they cannot disagree.
|
||||
/// </summary>
|
||||
public sealed class SpacingOutline
|
||||
{
|
||||
/// <summary>Arc chord tolerance of the outline. Perimeter arcs are circumscribed and
|
||||
/// cutout arcs inscribed, so flattening never hides material.</summary>
|
||||
public const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>How far under the part spacing a gap may fall and still pass. Layouts placed
|
||||
/// exactly at the spacing land up to ~1e-4 short once rotated, rounded coordinates
|
||||
/// (e.g. PEP's 4-decimal exports) meet the Clipper grid; 0.0005 covers that with margin
|
||||
/// and is far below anything a cutting machine can resolve.</summary>
|
||||
public const double SpacingSlack = 0.0005;
|
||||
|
||||
private bool prepared;
|
||||
private TriangulatedRegion triangles;
|
||||
|
||||
public SpacingOutline(Polygon perimeter, List<Polygon> holes)
|
||||
{
|
||||
Perimeter = perimeter ?? throw new ArgumentNullException(nameof(perimeter));
|
||||
Holes = holes ?? new List<Polygon>();
|
||||
}
|
||||
|
||||
public Polygon Perimeter { get; }
|
||||
public List<Polygon> Holes { get; }
|
||||
|
||||
/// <summary>Prepared on the first overlap test; null preparation is cached too.</summary>
|
||||
public TriangulatedRegion Triangles
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!prepared)
|
||||
{
|
||||
triangles = TriangulatedRegion.Build(Perimeter, Holes);
|
||||
prepared = true;
|
||||
}
|
||||
return triangles;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Inflation that enforces <paramref name="spacing"/> less <see cref="SpacingSlack"/>.
|
||||
/// Inflating one part of a pair by it and testing against the other's raw outline covers
|
||||
/// both sides: a part inside the other's (shrunk) cutout, or the inflated outline inside
|
||||
/// the other's raw cutout.</summary>
|
||||
public static double InflationFor(double spacing) =>
|
||||
System.Math.Max(0, spacing - SpacingSlack);
|
||||
|
||||
/// <summary>
|
||||
/// Outlines <paramref name="profile"/> (already rotated) at <paramref name="location"/>,
|
||||
/// grown by <paramref name="inflateBy"/>. A cutout that closes up under the offset is
|
||||
/// dropped, which treats it as solid: conservative, since it has no room for another part
|
||||
/// at the required spacing anyway. Null when the offset leaves no perimeter.
|
||||
/// </summary>
|
||||
public static SpacingOutline Build(ShapeProfile profile, Vector location, double inflateBy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 segments per
|
||||
// arc): arc-heavy real parts otherwise produce thousands of vertices.
|
||||
var region = ClipperBridge.OffsetForValidation(
|
||||
profile,
|
||||
inflateBy > Tolerance.Epsilon ? inflateBy : 0,
|
||||
ChordTolerance
|
||||
);
|
||||
|
||||
var perimeter = region.LargestOuter();
|
||||
|
||||
if (perimeter == null)
|
||||
return null;
|
||||
|
||||
ToWorld(perimeter, location);
|
||||
|
||||
foreach (var hole in region.Holes)
|
||||
ToWorld(hole, location);
|
||||
|
||||
return new SpacingOutline(perimeter, region.Holes);
|
||||
}
|
||||
|
||||
/// <summary>True when the two outlines share material (holes subtracted).</summary>
|
||||
public bool Overlaps(SpacingOutline other)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(other);
|
||||
var at = Triangles;
|
||||
var bt = other.Triangles;
|
||||
// Keeping translations at zero preserves the reference's floating-point operation order.
|
||||
return (at != null && bt != null ? at.Overlaps(bt, 0, 0, 0, 0) : null)
|
||||
?? Collision.HasOverlap(Perimeter, other.Perimeter, Holes, other.Holes);
|
||||
}
|
||||
|
||||
private static void ToWorld(Polygon polygon, Vector location)
|
||||
{
|
||||
polygon.Offset(location);
|
||||
polygon.UpdateBounds();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +264,9 @@ namespace OpenNest.Geometry
|
||||
[ThreadStatic]
|
||||
private static double[]? s_tmpB;
|
||||
|
||||
[ThreadStatic]
|
||||
private static double[]? s_tmpC;
|
||||
|
||||
private static double[] AcquireBuffer()
|
||||
{
|
||||
var pool = s_pool ??= new List<double[]>();
|
||||
@@ -282,11 +285,12 @@ namespace OpenNest.Geometry
|
||||
pool.Add(buf);
|
||||
}
|
||||
|
||||
private static (double[] Tmp, double[] Inside) ScratchPair()
|
||||
private static (double[] Tmp, double[] Inside, double[] Spare) Scratch()
|
||||
{
|
||||
s_tmpA ??= new double[MaxClipVertices * 2];
|
||||
s_tmpB ??= new double[MaxClipVertices * 2];
|
||||
return (s_tmpA, s_tmpB);
|
||||
s_tmpC ??= new double[MaxClipVertices * 2];
|
||||
return (s_tmpA, s_tmpB, s_tmpC);
|
||||
}
|
||||
|
||||
private (bool hasHoles, bool undecided, bool survived) SubtractAllHoles(
|
||||
@@ -309,9 +313,21 @@ namespace OpenNest.Geometry
|
||||
return (false, false, false);
|
||||
|
||||
// pieces[0] is the caller's own buffer - never release it back to the pool.
|
||||
var pieces = new List<(double[] Buf, int Count)> { (piece, count) };
|
||||
// A piece's buffer never changes once listed, so its bbox is measured once.
|
||||
var pieces = new List<Piece> { Measure(piece, count) };
|
||||
var spareList = new List<Piece>();
|
||||
var owned = new HashSet<double[]>();
|
||||
|
||||
// Built-in uses <=: touching skips subtraction, and a hole triangle no piece
|
||||
// reaches passes every piece on unchanged, so it is skipped without rebuilding.
|
||||
bool AnyReaches(double hMinX, double hMinY, double hMaxX, double hMaxY)
|
||||
{
|
||||
foreach (var p in pieces)
|
||||
if (!(p.MaxX <= hMinX || hMaxX <= p.MinX || p.MaxY <= hMinY || hMaxY <= p.MinY))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SubtractOwner(TriangulatedRegion owner, double odx, double ody)
|
||||
{
|
||||
if (owner.Holes == null)
|
||||
@@ -321,41 +337,32 @@ namespace OpenNest.Geometry
|
||||
var hole = owner.Holes[h];
|
||||
if (hole == null)
|
||||
continue; // untriangulatable hole: nothing to subtract
|
||||
// Every triangle's bbox lies within the hole's, translated alike.
|
||||
if (!AnyReaches(hole.MinX + odx, hole.MinY + ody, hole.MaxX + odx, hole.MaxY + ody))
|
||||
continue;
|
||||
for (var t = 0; t < hole._ia.Length && pieces.Count > 0; t++)
|
||||
{
|
||||
var hMinX = hole._tMinX[t] + odx;
|
||||
var hMaxX = hole._tMaxX[t] + odx;
|
||||
var hMinY = hole._tMinY[t] + ody;
|
||||
var hMaxY = hole._tMaxY[t] + ody;
|
||||
if (!AnyReaches(hMinX, hMinY, hMaxX, hMaxY))
|
||||
continue;
|
||||
|
||||
var next = new List<(double[], int)>();
|
||||
var next = spareList;
|
||||
next.Clear();
|
||||
for (var p = 0; p < pieces.Count; p++)
|
||||
{
|
||||
var (buf, pc) = pieces[p];
|
||||
var current = pieces[p];
|
||||
var buf = current.Buf;
|
||||
var pc = current.Count;
|
||||
|
||||
// Piece bbox (built-in uses <=: touching skips subtraction).
|
||||
var pMinX = double.MaxValue;
|
||||
var pMinY = double.MaxValue;
|
||||
var pMaxX = double.MinValue;
|
||||
var pMaxY = double.MinValue;
|
||||
for (var v = 0; v < pc; v++)
|
||||
{
|
||||
var px = buf[v * 2];
|
||||
var py = buf[v * 2 + 1];
|
||||
if (px < pMinX)
|
||||
pMinX = px;
|
||||
if (px > pMaxX)
|
||||
pMaxX = px;
|
||||
if (py < pMinY)
|
||||
pMinY = py;
|
||||
if (py > pMaxY)
|
||||
pMaxY = py;
|
||||
}
|
||||
if (pMaxX <= hMinX || hMaxX <= pMinX || pMaxY <= hMinY || hMaxY <= pMinY)
|
||||
if (current.MaxX <= hMinX || hMaxX <= current.MinX
|
||||
|| current.MaxY <= hMinY || hMaxY <= current.MinY)
|
||||
{
|
||||
if (next.Count >= MaxPieces)
|
||||
return false;
|
||||
next.Add((buf, pc));
|
||||
next.Add(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -363,11 +370,11 @@ namespace OpenNest.Geometry
|
||||
// outside of each edge survives as its own piece; the inside
|
||||
// remainder continues into the next edge. The remainder inside
|
||||
// all three edges is consumed (the hole ate it).
|
||||
var rem = AcquireBuffer();
|
||||
owned.Add(rem);
|
||||
Array.Copy(buf, rem, pc * 2);
|
||||
// The remainder reads the piece itself first, then alternates
|
||||
// between two scratch buffers; it is never kept.
|
||||
var rem = buf;
|
||||
var remCount = pc;
|
||||
var (tmp, insideBuf) = ScratchPair();
|
||||
var (tmp, insideBuf, spareBuf) = Scratch();
|
||||
for (var e = 0; e < 3 && remCount >= 3; e++)
|
||||
{
|
||||
var ei = e == 0 ? hole._ia[t] : e == 1 ? hole._ib[t] : hole._ic[t];
|
||||
@@ -388,20 +395,20 @@ namespace OpenNest.Geometry
|
||||
var keep = AcquireBuffer();
|
||||
owned.Add(keep);
|
||||
Array.Copy(tmp, keep, outCount * 2);
|
||||
next.Add((keep, outCount));
|
||||
next.Add(Measure(keep, outCount));
|
||||
}
|
||||
remCount =
|
||||
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, true, insideBuf);
|
||||
if (remCount >= MaxClipVertices)
|
||||
return false; // undecided
|
||||
Array.Copy(insideBuf, rem, remCount * 2);
|
||||
rem = insideBuf;
|
||||
(insideBuf, spareBuf) = (spareBuf, insideBuf);
|
||||
}
|
||||
// The inside-all-edges remainder is consumed by the hole: drop it.
|
||||
owned.Remove(rem);
|
||||
ReleaseBuffer(rem);
|
||||
if (owned.Remove(buf))
|
||||
ReleaseBuffer(buf);
|
||||
}
|
||||
spareList = pieces;
|
||||
pieces = next;
|
||||
}
|
||||
}
|
||||
@@ -413,8 +420,8 @@ namespace OpenNest.Geometry
|
||||
if (!SubtractOwner(this, adx, ady) || !SubtractOwner(other, bdx, bdy))
|
||||
return (true, true, false);
|
||||
|
||||
foreach (var (buf, pc) in pieces)
|
||||
if (pc >= 3 && TwiceArea(buf, pc) > areaFloor)
|
||||
foreach (var p in pieces)
|
||||
if (p.Count >= 3 && TwiceArea(p.Buf, p.Count) > areaFloor)
|
||||
return (true, false, true);
|
||||
return (true, false, false);
|
||||
}
|
||||
@@ -425,6 +432,31 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct Piece(double[] Buf, int Count, double MinX, double MinY,
|
||||
double MaxX, double MaxY);
|
||||
|
||||
private static Piece Measure(double[] buf, int count)
|
||||
{
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
for (var v = 0; v < count; v++)
|
||||
{
|
||||
var px = buf[v * 2];
|
||||
var py = buf[v * 2 + 1];
|
||||
if (px < minX)
|
||||
minX = px;
|
||||
if (px > maxX)
|
||||
maxX = px;
|
||||
if (py < minY)
|
||||
minY = py;
|
||||
if (py > maxY)
|
||||
maxY = py;
|
||||
}
|
||||
return new Piece(buf, count, minX, minY, maxX, maxY);
|
||||
}
|
||||
|
||||
/// <summary>Clip this' triangle against other's triangle; returns count into piece.</summary>
|
||||
private int ClipTriangle(
|
||||
int ta,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// A machine's motion limits for cut-time estimates, in the units of the
|
||||
/// <see cref="CutParameters"/> it is attached to (inches or millimeters, per second squared).
|
||||
/// </summary>
|
||||
public class MotionProfile
|
||||
{
|
||||
/// <summary>Acceleration while cutting or scribing, and the centripetal limit on arcs.</summary>
|
||||
public double CutAcceleration { get; set; }
|
||||
|
||||
/// <summary>Acceleration for head-up travel.</summary>
|
||||
public double RapidAcceleration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Junction deviation: how far the path may round a corner, which sets how fast the head
|
||||
/// may take it. Zero stops at every corner.
|
||||
/// </summary>
|
||||
public double CornerDeviation { get; set; }
|
||||
}
|
||||
+25
-1
@@ -65,6 +65,21 @@ namespace OpenNest
|
||||
return plate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes <paramref name="rail"/> this nest's machine clamp rail: new plates follow it
|
||||
/// (<see cref="PlateSettings.ClampRail"/>) and every existing plate re-resolves its
|
||||
/// zones from it. Null, or a rail without clamps, removes the rail and every plate's
|
||||
/// zones; the rail is nest-wide, so per-plate zones are replaced either way.
|
||||
/// </summary>
|
||||
public void SetClampRail(ClampRail rail)
|
||||
{
|
||||
if (rail != null && rail.Clamps.Count == 0)
|
||||
rail = null;
|
||||
PlateDefaults.ClampRail = rail;
|
||||
foreach (var plate in Plates)
|
||||
plate.SetClampRail(rail);
|
||||
}
|
||||
|
||||
public void UpdateDrawingQuantities()
|
||||
{
|
||||
foreach (var drawing in Drawings)
|
||||
@@ -114,6 +129,12 @@ namespace OpenNest
|
||||
set { plate.PartSpacing = value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The machine clamp rail new plates follow, or null for none. Set through
|
||||
/// <see cref="Nest.SetClampRail"/> to also update existing plates.
|
||||
/// </summary>
|
||||
public ClampRail ClampRail { get; set; }
|
||||
|
||||
public void SetFromExisting(Plate plate)
|
||||
{
|
||||
Quadrant = plate.Quadrant;
|
||||
@@ -124,7 +145,7 @@ namespace OpenNest
|
||||
|
||||
public Plate CreateNew()
|
||||
{
|
||||
return new Plate()
|
||||
var created = new Plate()
|
||||
{
|
||||
Size = Size,
|
||||
EdgeSpacing = EdgeSpacing,
|
||||
@@ -132,6 +153,9 @@ namespace OpenNest
|
||||
Quadrant = Quadrant,
|
||||
Quantity = 1,
|
||||
};
|
||||
if (ClampRail != null)
|
||||
created.SetClampRail(ClampRail);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,5 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Clipper2" Version="2.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+12
-1
@@ -64,6 +64,15 @@ namespace OpenNest
|
||||
|
||||
public bool LeadInsLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this placement ignores its drawing's cut pins and is cut with the nest's
|
||||
/// settings instead. Saved with the nest; never changes the drawing or its library.
|
||||
/// </summary>
|
||||
public bool DrawingPinsOff { get; set; }
|
||||
|
||||
/// <summary>True when the drawing has cut pins and this placement has not switched them off.</summary>
|
||||
public bool HasActiveDrawingPins => !DrawingPinsOff && BaseDrawing?.CutPins is { Count: > 0 };
|
||||
|
||||
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||
|
||||
public void ApplyLeadIns(
|
||||
@@ -343,7 +352,7 @@ namespace OpenNest
|
||||
PerfCounters.CountOverlapPolygonPreparation();
|
||||
return ConvertProgram
|
||||
.ToGeometry(program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ForNesting()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -426,11 +435,13 @@ namespace OpenNest
|
||||
/// Copies the lead-in state that goes with a copied program. Without it a copy of a
|
||||
/// lead-in part reads its rotation from the rebuilt program (zero), and lead-in
|
||||
/// assignment does not know to strip the copied lead-ins before adding new ones.
|
||||
/// A copy of a placement with drawing pins off keeps them off.
|
||||
/// </summary>
|
||||
private void CopyLeadInStateFrom(Part source)
|
||||
{
|
||||
HasManualLeadIns = source.HasManualLeadIns;
|
||||
LeadInsLocked = source.LeadInsLocked;
|
||||
DrawingPinsOff = source.DrawingPinsOff;
|
||||
CuttingParameters = source.CuttingParameters;
|
||||
preLeadInRotation = source.preLeadInRotation;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace OpenNest
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(
|
||||
entities.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
entities.ForNesting()
|
||||
);
|
||||
var lines = new List<Line>();
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace OpenNest
|
||||
PerfCounters.CountOffsetPerimeterEntities();
|
||||
var geoEntities = ConvertProgram.ToGeometry(program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
|
||||
geoEntities.ForNesting().ToList()
|
||||
);
|
||||
|
||||
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
|
||||
@@ -75,7 +75,7 @@ namespace OpenNest
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
|
||||
geoEntities.ForNesting().ToList()
|
||||
);
|
||||
var entities = new List<Entity>();
|
||||
|
||||
@@ -108,7 +108,7 @@ namespace OpenNest
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
|
||||
geoEntities.ForNesting().ToList()
|
||||
);
|
||||
|
||||
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||
@@ -122,7 +122,7 @@ namespace OpenNest
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
|
||||
geoEntities.ForNesting().ToList()
|
||||
);
|
||||
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||
|
||||
@@ -167,7 +167,7 @@ namespace OpenNest
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(
|
||||
entities.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
entities.ForNesting()
|
||||
);
|
||||
var lines = new List<Line>();
|
||||
|
||||
|
||||
+220
-44
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
@@ -55,6 +56,10 @@ namespace OpenNest
|
||||
Parts.ItemAdded += Parts_PartAdded;
|
||||
Parts.ItemRemoved += Parts_PartRemoved;
|
||||
CutOffs = new ObservableList<CutOff>();
|
||||
ClampZones = new ObservableList<ClampZone>();
|
||||
ClampZones.ItemAdded += (_, _) => OnClampZonesChanged();
|
||||
ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged();
|
||||
ClampZones.ItemChanged += (_, _) => OnClampZonesChanged();
|
||||
Quadrant = 1;
|
||||
}
|
||||
|
||||
@@ -81,9 +86,20 @@ namespace OpenNest
|
||||
public Spacing EdgeSpacing;
|
||||
|
||||
/// <summary>
|
||||
/// The size of the plate.
|
||||
/// The size of the plate. A plate following a <see cref="ClampRail"/> re-resolves
|
||||
/// its clamp zones for the new size.
|
||||
/// </summary>
|
||||
public Size Size { get; set; }
|
||||
public Size Size
|
||||
{
|
||||
get { return size; }
|
||||
set
|
||||
{
|
||||
size = value;
|
||||
ResolveClampRail();
|
||||
}
|
||||
}
|
||||
|
||||
private Size size;
|
||||
|
||||
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||
|
||||
@@ -102,6 +118,145 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public ObservableList<CutOff> CutOffs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed rectangular machine-clamp keep-outs in plate coordinates
|
||||
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
|
||||
/// cut sequence and CNC output; placement rules and display consume them. Mutate
|
||||
/// through this list; <see cref="ClampZonesChanged"/> fires for every change.
|
||||
/// </summary>
|
||||
public ObservableList<ClampZone> ClampZones { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
|
||||
/// the zone overlay. Carries no geometry arguments.
|
||||
/// </summary>
|
||||
public event EventHandler? ClampZonesChanged;
|
||||
|
||||
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// The machine clamp rail this plate follows, or null when its
|
||||
/// <see cref="ClampZones"/> are explicit (engine sheets, files without a rail). While
|
||||
/// set, the zones are derived: re-resolved whenever <see cref="Size"/> or
|
||||
/// <see cref="Quadrant"/> changes, because the clamps are fixed to the machine.
|
||||
/// </summary>
|
||||
public ClampRail ClampRail { get; private set; }
|
||||
|
||||
private static readonly IReadOnlySet<string> NoRemovedClamps = ImmutableHashSet<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Rail clamp ids taken off this sheet, by Auto Nest as a last resort (D8) so a part
|
||||
/// that could not be nested anywhere with every clamp in place still fits, or by hand
|
||||
/// (<see cref="SetClampRemoved"/>). Empty unless
|
||||
/// <see cref="ClampRail"/> is set; <see cref="ClampZones"/> already excludes them, so
|
||||
/// nothing else needs to consult this list to avoid them. Ids stay listed across a
|
||||
/// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it
|
||||
/// re-appears removed if a later resize brings it back onto the sheet (C13).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> RemovedClamps { get; private set; } = NoRemovedClamps;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes this sheet's cut-time estimate gains from <see cref="RemovedClamps"/> (D9):
|
||||
/// each listed clamp the rail places on this sheet adds
|
||||
/// <see cref="OpenNest.ClampRail.RemovalMinutes"/> once; a listed clamp that is beyond
|
||||
/// the sheet (or unknown to the rail) is not pulled and costs nothing. Zero when there
|
||||
/// is no rail (removal is meaningless without one to remove clamps from).
|
||||
/// </summary>
|
||||
public double ClampRemovalMinutes =>
|
||||
ClampRail == null || RemovedClamps.Count == 0
|
||||
? 0
|
||||
: ClampRail.ResolveFor(Size, Quadrant).Count(zone => RemovedClamps.Contains(zone.Id))
|
||||
* ClampRail.RemovalMinutes;
|
||||
|
||||
/// <summary>
|
||||
/// Follows <paramref name="rail"/> and replaces the zones with its resolution for
|
||||
/// this sheet; null detaches and removes every zone. Unchanged zones raise no events.
|
||||
/// </summary>
|
||||
public void SetClampRail(ClampRail rail) => SetClampRail(rail, null);
|
||||
|
||||
/// <summary>
|
||||
/// Follows <paramref name="rail"/> with the clamps named in <paramref name="removedClamps"/>
|
||||
/// taken off this sheet (D8): zones become the rail's resolution for this sheet minus
|
||||
/// those ids. Null <paramref name="rail"/> detaches, clears every zone and forgets the
|
||||
/// removed set (removal only means something while following a rail).
|
||||
/// </summary>
|
||||
public void SetClampRail(ClampRail rail, IEnumerable<string> removedClamps)
|
||||
{
|
||||
ClampRail = rail;
|
||||
RemovedClamps = rail == null || removedClamps == null
|
||||
? NoRemovedClamps
|
||||
: removedClamps.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
ReplaceClampZones(ResolveRailZones());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rail clamp this sheet places at <paramref name="point"/> (the parts' placement
|
||||
/// frame, see <see cref="ClampZoneFrame"/>), whether in place or removed, grown by
|
||||
/// <paramref name="tolerance"/> on every side; null without a rail or a clamp there.
|
||||
/// Returned bounds are in the placement frame.
|
||||
/// </summary>
|
||||
public ClampZone RailClampAt(Vector point, double tolerance = 0)
|
||||
{
|
||||
if (ClampRail == null)
|
||||
return null;
|
||||
var left = Quadrant is 2 or 3 ? -Size.Length : 0;
|
||||
var bottom = Quadrant is 3 or 4 ? -Size.Width : 0;
|
||||
foreach (var zone in ClampRail.ResolveFor(Size, Quadrant))
|
||||
{
|
||||
var box = zone.Bounds.Translate(left, bottom);
|
||||
if (box.Offset(tolerance).Contains(point))
|
||||
return new ClampZone(zone.Id, box);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the rail clamp <paramref name="id"/> off this sheet, or puts it back, as a
|
||||
/// manual counterpart to Auto Nest's last-resort removal (D8): the zones and
|
||||
/// <see cref="ClampRemovalMinutes"/> follow. False when nothing changed: no rail, a clamp
|
||||
/// the rail does not place on this sheet, or one already in the requested state.
|
||||
/// </summary>
|
||||
public bool SetClampRemoved(string id, bool removed)
|
||||
{
|
||||
if (ClampRail == null || id == null || RemovedClamps.Contains(id) == removed)
|
||||
return false;
|
||||
if (removed && !ClampRail.ResolveFor(Size, Quadrant).Any(zone => zone.Id == id))
|
||||
return false;
|
||||
var ids = removed ? RemovedClamps.Append(id) : RemovedClamps.Where(x => x != id);
|
||||
SetClampRail(ClampRail, ids);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ResolveClampRail()
|
||||
{
|
||||
// Called from the constructor's Size assignment before the zone list exists.
|
||||
if (ClampRail != null)
|
||||
ReplaceClampZones(ResolveRailZones());
|
||||
}
|
||||
|
||||
private IReadOnlyList<ClampZone> ResolveRailZones()
|
||||
{
|
||||
if (ClampRail == null)
|
||||
return Array.Empty<ClampZone>();
|
||||
var resolved = ClampRail.ResolveFor(Size, Quadrant);
|
||||
if (RemovedClamps.Count == 0)
|
||||
return resolved;
|
||||
var kept = new List<ClampZone>(resolved.Count);
|
||||
foreach (var zone in resolved)
|
||||
if (!RemovedClamps.Contains(zone.Id))
|
||||
kept.Add(zone);
|
||||
return kept;
|
||||
}
|
||||
|
||||
private void ReplaceClampZones(IReadOnlyList<ClampZone> zones)
|
||||
{
|
||||
if (ClampRail.SameZones(ClampZones, zones))
|
||||
return;
|
||||
ClampZones.Clear();
|
||||
foreach (var zone in zones)
|
||||
ClampZones.Add(zone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regenerates all cut-off drawings and materializes them as parts.
|
||||
/// Existing cut-off parts are removed first, then each cut-off is
|
||||
@@ -175,52 +330,61 @@ namespace OpenNest
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
Geometry.Entity perimeter = null;
|
||||
try
|
||||
{
|
||||
var entities = Converters
|
||||
.ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
// Leads are cutting paths in scrap, not boundary edges. Chaining them
|
||||
// into the outline can make a closed part appear open and replace its
|
||||
// recesses with a convex hull. Keep the full geometry for the existing
|
||||
// conservative fallback when the material contour really is open.
|
||||
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
|
||||
e.Layer != SpecialLayers.Leadout).ToList();
|
||||
var profile = new Geometry.ShapeProfile(outline);
|
||||
|
||||
if (profile.Perimeter.IsClosed())
|
||||
{
|
||||
perimeter = profile.Perimeter;
|
||||
perimeter.Offset(part.Location);
|
||||
}
|
||||
else
|
||||
{
|
||||
var points = entities.CollectPoints();
|
||||
if (points.Count >= 3)
|
||||
{
|
||||
var hull = Geometry.ConvexHull.Compute(points);
|
||||
hull.Offset(part.Location);
|
||||
perimeter = hull;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
perimeter = null;
|
||||
}
|
||||
|
||||
cache[part] = perimeter;
|
||||
cache[part] = BuildPartPerimeter(part);
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts one part's own program into its placed perimeter entity (closed
|
||||
/// profile, or a convex hull fallback for a genuinely open contour). This is
|
||||
/// the same conversion <see cref="BuildPerimeterCache"/> runs per part; callers
|
||||
/// without a pre-built cache (e.g. a direct, uncached <see cref="CutOff.Regenerate"/>
|
||||
/// call) use it to get the same conservative, converted-material envelope instead
|
||||
/// of trusting the part's cached bounding box alone.
|
||||
/// </summary>
|
||||
public static Geometry.Entity BuildPartPerimeter(Part part)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = Converters
|
||||
.ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
|
||||
// Leads are cutting paths in scrap, not boundary edges. Chaining them
|
||||
// into the outline can make a closed part appear open and replace its
|
||||
// recesses with a convex hull. Keep the full geometry for the existing
|
||||
// conservative fallback when the material contour really is open.
|
||||
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
|
||||
e.Layer != SpecialLayers.Leadout).ToList();
|
||||
var profile = new Geometry.ShapeProfile(outline);
|
||||
|
||||
if (profile.Perimeter.IsClosed())
|
||||
{
|
||||
var perimeter = profile.Perimeter;
|
||||
perimeter.Offset(part.Location);
|
||||
return perimeter;
|
||||
}
|
||||
|
||||
var points = entities.CollectPoints();
|
||||
if (points.Count < 3)
|
||||
return null;
|
||||
|
||||
var hull = Geometry.ConvexHull.Compute(points);
|
||||
hull.Offset(part.Location);
|
||||
return hull;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of times to cut the plate.
|
||||
/// </summary>
|
||||
@@ -236,7 +400,11 @@ namespace OpenNest
|
||||
public int Quadrant
|
||||
{
|
||||
get { return quadrant; }
|
||||
set { quadrant = value <= 4 && value > 0 ? value : 1; }
|
||||
set
|
||||
{
|
||||
quadrant = value <= 4 && value > 0 ? value : 1;
|
||||
ResolveClampRail();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -542,6 +710,14 @@ namespace OpenNest
|
||||
return box;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The offset that centers the parts' combined bounds in the <see cref="WorkArea"/>,
|
||||
/// so uneven edge spacing (such as a wider clamp-side margin) shifts them with it.
|
||||
/// Zero when the plate has no parts.
|
||||
/// </summary>
|
||||
public Vector OffsetToCenterPartsInWorkArea() =>
|
||||
Parts.Count == 0 ? new Vector() : WorkArea().Center - Parts.GetBoundingBox().Center;
|
||||
|
||||
/// <summary>
|
||||
/// Automatically sizes the plate to fit the parts.
|
||||
/// </summary>
|
||||
|
||||
@@ -48,17 +48,24 @@ namespace OpenNest
|
||||
return plate != null && plate.Parts.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>Header text for the plate at <paramref name="storageIndex"/>.</summary>
|
||||
public static string FormatHeader(IList<Plate> plates, int storageIndex, string plateSizeText)
|
||||
/// <summary>
|
||||
/// Header text for the plate at <paramref name="storageIndex"/>. A quantity above one is
|
||||
/// appended as "×N"; a single sheet shows no quantity.
|
||||
/// </summary>
|
||||
public static string FormatHeader(IList<Plate> plates, int storageIndex, string plateSizeText, int quantity = 1)
|
||||
{
|
||||
var displayed = DisplayedPlateNumber(plates, storageIndex);
|
||||
if (displayed == null)
|
||||
return IsTrailingSentinel(plates, storageIndex)
|
||||
? "New plate (empty)"
|
||||
: "No plates";
|
||||
return string.IsNullOrEmpty(plateSizeText)
|
||||
? string.Format("Plate {0} of {1}", displayed.Value, DisplayedPlateCount(plates))
|
||||
: string.Format("Plate {0} of {1} | {2}", displayed.Value, DisplayedPlateCount(plates), plateSizeText);
|
||||
|
||||
var text = string.Format("Plate {0} of {1}", displayed.Value, DisplayedPlateCount(plates));
|
||||
if (!string.IsNullOrEmpty(plateSizeText))
|
||||
text += " | " + plateSizeText;
|
||||
if (quantity > 1)
|
||||
text += " | ×" + quantity;
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,50 @@ namespace OpenNest
|
||||
SubscribeToTailPlates();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a nest-defaults edit, moving only an untouched trailing empty sheet
|
||||
/// that matched the old defaults to the new defaults.
|
||||
/// </summary>
|
||||
public void ChangePlateDefaults(Action changeDefaults)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changeDefaults);
|
||||
var defaults = nest.PlateDefaults;
|
||||
var trailing = Count > 0 ? nest.Plates[^1] : null;
|
||||
// Exact configuration equality, not geometric tolerance: even a small edit
|
||||
// makes this configured stock rather than the untouched workspace sentinel.
|
||||
var followsDefaults = trailing != null
|
||||
&& trailing.Parts.Count == 0
|
||||
&& trailing.CutOffs.Count == 0
|
||||
&& trailing.Size.Width == defaults.Size.Width
|
||||
&& trailing.Size.Length == defaults.Size.Length
|
||||
&& trailing.Quadrant == defaults.Quadrant
|
||||
&& trailing.EdgeSpacing.Left == defaults.EdgeSpacing.Left
|
||||
&& trailing.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom
|
||||
&& trailing.EdgeSpacing.Right == defaults.EdgeSpacing.Right
|
||||
&& trailing.EdgeSpacing.Top == defaults.EdgeSpacing.Top
|
||||
&& trailing.PartSpacing == defaults.PartSpacing
|
||||
&& trailing.RemovedClamps.Count == 0
|
||||
&& Equals(trailing.ClampRail, defaults.ClampRail)
|
||||
&& (defaults.ClampRail == null
|
||||
? trailing.ClampZones.Count == 0
|
||||
: defaults.ClampRail.Matches(trailing));
|
||||
|
||||
changeDefaults();
|
||||
|
||||
if (!followsDefaults)
|
||||
return;
|
||||
|
||||
defaults = nest.PlateDefaults;
|
||||
trailing.Size = defaults.Size;
|
||||
trailing.Quadrant = defaults.Quadrant;
|
||||
trailing.EdgeSpacing = defaults.EdgeSpacing;
|
||||
trailing.PartSpacing = defaults.PartSpacing;
|
||||
trailing.SetClampRail(defaults.ClampRail);
|
||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||
if (ReferenceEquals(CurrentPlate, trailing))
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public void BeginBatch()
|
||||
{
|
||||
batching = true;
|
||||
@@ -151,6 +195,54 @@ namespace OpenNest
|
||||
nest.Plates.RemoveAt(CurrentIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <see cref="ClearAll"/> would change anything: more than one plate, or the
|
||||
/// remaining plate still holds parts or cut-off definitions.
|
||||
/// </summary>
|
||||
public bool CanClearAll
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Count == 0)
|
||||
return false;
|
||||
|
||||
var remaining = nest.Plates[0];
|
||||
return Count > 1
|
||||
|| remaining.Parts.Count > 0
|
||||
|| remaining.CutOffs.Count > 0
|
||||
|| remaining.Quantity > 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the nest: removes every plate after the first and empties the remaining
|
||||
/// plate (parts and cut-off definitions), resetting its repeat quantity. Nest metadata
|
||||
/// and plate defaults are preserved; the first plate's configuration stays as the
|
||||
/// workspace sentinel — the same result as removing every other plate one at a time.
|
||||
/// The plate count never reaches zero, so observers never see a null current plate.
|
||||
/// </summary>
|
||||
public void ClearAll()
|
||||
{
|
||||
if (Count == 0)
|
||||
return;
|
||||
|
||||
BeginBatch();
|
||||
try
|
||||
{
|
||||
while (nest.Plates.Count > 1)
|
||||
nest.Plates.RemoveAt(nest.Plates.Count - 1);
|
||||
|
||||
var remaining = nest.Plates[0];
|
||||
remaining.CutOffs.Clear();
|
||||
remaining.Parts.Clear();
|
||||
remaining.Quantity = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndBatch();
|
||||
}
|
||||
}
|
||||
|
||||
private void SubscribeToTailPlates()
|
||||
{
|
||||
UnsubscribeFromTailPlates();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Sequencing
|
||||
{
|
||||
/// <summary>Moves one existing part to a one-based cutting sequence position.</summary>
|
||||
public static class ManualPartSequencing
|
||||
{
|
||||
public static bool Move(Plate plate, Part part, int sequenceNumber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
ArgumentNullException.ThrowIfNull(part);
|
||||
var order = plate.Parts.ToList();
|
||||
if (sequenceNumber < 1 || sequenceNumber > order.Count)
|
||||
return false;
|
||||
|
||||
var oldIndex = order.FindIndex(p => ReferenceEquals(p, part));
|
||||
if (oldIndex < 0)
|
||||
return false;
|
||||
if (oldIndex == sequenceNumber - 1)
|
||||
return true;
|
||||
|
||||
order.RemoveAt(oldIndex);
|
||||
order.Insert(sequenceNumber - 1, part);
|
||||
plate.Parts.Reorder(order);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
@@ -19,11 +19,18 @@ namespace OpenNest
|
||||
|
||||
public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta };
|
||||
|
||||
public static readonly Layer Break = new Layer("BREAK") { Color = Color.Orange };
|
||||
|
||||
/// <summary>A tabbed cutout's uncut tabs: material boundary that is never cut.</summary>
|
||||
public static readonly Layer Tab = new Layer("TAB") { Color = Color.Orange };
|
||||
|
||||
/// <summary>
|
||||
/// True when an entity converted from a part program describes part material. Rapids
|
||||
/// and scribe/etch marks are excluded: marks are only on the surface, so they never
|
||||
/// bound material and must not affect nesting, collision, area, or validation.
|
||||
/// bound material and must not affect nesting, collision, area, or validation. Break
|
||||
/// cuts are excluded too: they run through material that stays attached at tabs. Tabs are
|
||||
/// material: they close a tabbed cutout's boundary.
|
||||
/// </summary>
|
||||
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe;
|
||||
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break;
|
||||
}
|
||||
}
|
||||
@@ -128,10 +128,14 @@ public static class DrawingSplitter
|
||||
var pgm = ConvertGeometry.ToProgram(entities);
|
||||
var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm);
|
||||
piece.Color = source.Color;
|
||||
piece.HasCustomColor = source.HasCustomColor;
|
||||
piece.Priority = source.Priority;
|
||||
piece.CutFirst = source.CutFirst;
|
||||
piece.Material = source.Material;
|
||||
piece.Constraints = source.Constraints;
|
||||
piece.Customer = source.Customer;
|
||||
// A piece is new geometry, not the library revision the source was copied from.
|
||||
piece.Library = null;
|
||||
piece.Source = source.Source;
|
||||
piece.Quantity.Required = source.Quantity.Required;
|
||||
|
||||
@@ -269,15 +273,15 @@ public static class DrawingSplitter
|
||||
|
||||
var regions = new List<Box>();
|
||||
for (var yi = 0; yi < yEdges.Count - 1; yi++)
|
||||
for (var xi = 0; xi < xEdges.Count - 1; xi++)
|
||||
regions.Add(
|
||||
new Box(
|
||||
xEdges[xi],
|
||||
yEdges[yi],
|
||||
xEdges[xi + 1] - xEdges[xi],
|
||||
yEdges[yi + 1] - yEdges[yi]
|
||||
)
|
||||
);
|
||||
for (var xi = 0; xi < xEdges.Count - 1; xi++)
|
||||
regions.Add(
|
||||
new Box(
|
||||
xEdges[xi],
|
||||
yEdges[yi],
|
||||
xEdges[xi + 1] - xEdges[xi],
|
||||
yEdges[yi + 1] - yEdges[yi]
|
||||
)
|
||||
);
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class NestingMaterial
|
||||
{
|
||||
/// <summary>
|
||||
/// The material entities nesting and layout validation place parts against: those
|
||||
/// <see cref="SpecialLayers.IsMaterial"/> keeps, except that a tabbed cutout's slug stays
|
||||
/// attached while the sheet is cut, so a closed hole with <see cref="SpecialLayers.Tab"/>
|
||||
/// spans is dropped and its slug counts as part. Area, overlap diagnostics and cutting
|
||||
/// keep the hole. Entities without tab spans pass through unchanged.
|
||||
/// </summary>
|
||||
public static List<Entity> ForNesting(this IEnumerable<Entity> entities)
|
||||
{
|
||||
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
|
||||
if (!material.Exists(e => e.Layer == SpecialLayers.Tab))
|
||||
return material;
|
||||
|
||||
var kept = new List<Entity>();
|
||||
foreach (var shape in ShapeBuilder.GetShapes(material))
|
||||
if (!shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab))
|
||||
kept.AddRange(shape.Entities);
|
||||
return kept;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.TabbedCutouts
|
||||
{
|
||||
/// <summary>
|
||||
/// A cutout (a closed hole in a part) whose slug stays attached by uncut tabs. It is identified
|
||||
/// by the source entities of the hole's contour. Building the drawing cuts the contour as open
|
||||
/// pieces between the tabs, each entered from the slug with no lead-out, and keeps the tabs on
|
||||
/// the <see cref="SpecialLayers.Tab"/> layer so the hole still bounds material. All values are
|
||||
/// in the source frame.
|
||||
/// </summary>
|
||||
public sealed class TabbedCutout
|
||||
{
|
||||
public const double DefaultTabWidth = 0.0625;
|
||||
public const int DefaultTabCount = 2;
|
||||
public const int MinTabCount = 1;
|
||||
|
||||
/// <summary>Source entity IDs of the hole's closed contour.</summary>
|
||||
public List<Guid> EdgeIds { get; set; } = new();
|
||||
|
||||
/// <summary>Uncut length of each tab along the contour.</summary>
|
||||
public double TabWidth { get; set; } = DefaultTabWidth;
|
||||
|
||||
/// <summary>
|
||||
/// Tab centers as distances along the contour, counterclockwise from its lowest-left
|
||||
/// vertex (the smallest X, then Y), ascending. Empty means <see cref="DefaultTabCount"/>
|
||||
/// evenly spaced.
|
||||
/// </summary>
|
||||
public List<double> TabCenters { get; set; } = new();
|
||||
|
||||
public TabbedCutout Clone() =>
|
||||
new()
|
||||
{
|
||||
EdgeIds = new List<Guid>(EdgeIds),
|
||||
TabWidth = TabWidth,
|
||||
TabCenters = new List<double>(TabCenters),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Breakoffs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.TabbedCutouts
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves and applies <see cref="TabbedCutout"/>s: the hole's contour is replaced by open
|
||||
/// pieces between tabs (on its own layer) and the tabs themselves (on the tab layer), in
|
||||
/// contour order, so the hole stays closed as material while only the pieces are cut.
|
||||
/// </summary>
|
||||
public static class TabbedCutoutBuilder
|
||||
{
|
||||
/// <summary>How close the hole's entities must meet to form one contour.</summary>
|
||||
public const double JoinTolerance = BreakoffBuilder.JoinTolerance;
|
||||
|
||||
private const double ArcTolerance = 0.0005;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="entities"/> with every valid tabbed cutout applied. Inputs are not
|
||||
/// modified; generated entities are new. Cutouts that cannot be resolved are skipped and
|
||||
/// described in <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
public static List<Entity> Apply(IReadOnlyList<Entity> entities, IReadOnlyList<TabbedCutout> cutouts,
|
||||
out List<string> errors)
|
||||
{
|
||||
errors = new List<string>();
|
||||
var working = entities.ToList();
|
||||
if (cutouts == null || cutouts.Count == 0)
|
||||
return working;
|
||||
|
||||
for (var i = 0; i < cutouts.Count; i++)
|
||||
{
|
||||
var layout = Layout(working, cutouts[i]);
|
||||
if (!layout.IsValid)
|
||||
{
|
||||
errors.Add($"Tabbed cutout {i + 1}: {layout.Error}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var replaced = new HashSet<Entity>(layout.ReplacedEntities, ReferenceEqualityComparer.Instance);
|
||||
var at = working.FindIndex(replaced.Contains);
|
||||
working.RemoveAll(replaced.Contains);
|
||||
var generated = new List<Entity>();
|
||||
for (var p = 0; p < layout.Pieces.Count; p++)
|
||||
{
|
||||
generated.AddRange(layout.Pieces[p].Select(e => e.Clone()));
|
||||
generated.AddRange(layout.Tabs[p].Select(e => e.Clone()));
|
||||
}
|
||||
working.InsertRange(System.Math.Clamp(at, 0, working.Count), generated);
|
||||
}
|
||||
return working;
|
||||
}
|
||||
|
||||
/// <summary>Resolves <paramref name="cutout"/> against <paramref name="entities"/> (the entities that will be cut).</summary>
|
||||
public static TabbedCutoutLayout Layout(IReadOnlyList<Entity> entities, TabbedCutout cutout)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
ArgumentNullException.ThrowIfNull(cutout);
|
||||
if (!(cutout.TabWidth > 0) || !double.IsFinite(cutout.TabWidth))
|
||||
return Fail("The tab width must be greater than zero.");
|
||||
|
||||
var edgeIds = cutout.EdgeIds.ToHashSet();
|
||||
var sources = entities.Where(e => edgeIds.Contains(e.Id)).ToList();
|
||||
if (edgeIds.Count == 0 || sources.Count != edgeIds.Count)
|
||||
return Fail("Its contour is hidden or missing.");
|
||||
|
||||
var chain = ChainClosed(sources);
|
||||
if (chain == null)
|
||||
return Fail("Its contour must be one closed loop of lines and arcs, or a circle.");
|
||||
|
||||
var loop = BreakoffBuilder.CloseExactly(chain);
|
||||
var points = BreakoffPath.ToPoints(loop, ArcTolerance);
|
||||
if (BreakoffPath.SignedArea(points) < 0)
|
||||
loop = BreakoffPath.Reversed(loop);
|
||||
loop = StartAtLowestLeft(loop);
|
||||
points = BreakoffPath.ToPoints(loop, ArcTolerance);
|
||||
|
||||
if (!InsideAnOutline(entities, edgeIds, points))
|
||||
return Fail("It must be a hole inside the part's outline.");
|
||||
|
||||
var length = BreakoffPath.Length(loop);
|
||||
var tabWidth = cutout.TabWidth;
|
||||
var centers = cutout.TabCenters.Count == 0
|
||||
? EvenTabCenters(length, TabbedCutout.DefaultTabCount, loop[0].Length / 2)
|
||||
: cutout.TabCenters.Select(c => Wrap(c, length)).OrderBy(c => c).ToList();
|
||||
if (centers.Count < TabbedCutout.MinTabCount || length <= 2 * tabWidth * centers.Count)
|
||||
return Fail("The contour is too short for its tabs.");
|
||||
if (centers.Any(c => !double.IsFinite(c)))
|
||||
return Fail("A tab is off the contour; space the tabs evenly again.");
|
||||
for (var i = 0; i < centers.Count; i++)
|
||||
{
|
||||
var next = i + 1 < centers.Count ? centers[i + 1] : centers[0] + length;
|
||||
if (centers.Count > 1 && next - centers[i] <= tabWidth + JoinTolerance)
|
||||
return Fail("Two tabs overlap; space the tabs evenly again.");
|
||||
}
|
||||
|
||||
// Walk a doubled loop from the end of the first tab, so every span is one increasing range.
|
||||
var doubled = loop.Concat(loop).ToList();
|
||||
var origin = centers[0] + tabWidth / 2;
|
||||
if (origin >= length)
|
||||
origin -= length;
|
||||
var pieces = new List<IReadOnlyList<Entity>>();
|
||||
var tabs = new List<IReadOnlyList<Entity>>();
|
||||
for (var i = 0; i < centers.Count; i++)
|
||||
{
|
||||
var nextCenter = i + 1 < centers.Count ? centers[i + 1] : centers[0] + length;
|
||||
var from = centers[i] + tabWidth / 2;
|
||||
var cutTo = nextCenter - tabWidth / 2;
|
||||
var tabTo = nextCenter + tabWidth / 2;
|
||||
pieces.Add(Span(doubled, Unwrap(from), Unwrap(cutTo), null));
|
||||
tabs.Add(Span(doubled, Unwrap(cutTo), Unwrap(tabTo), SpecialLayers.Tab));
|
||||
}
|
||||
|
||||
return new TabbedCutoutLayout
|
||||
{
|
||||
Loop = loop,
|
||||
Length = length,
|
||||
Pieces = pieces,
|
||||
Tabs = tabs,
|
||||
TabCenters = centers,
|
||||
TabWidth = tabWidth,
|
||||
ReplacedEntities = sources,
|
||||
};
|
||||
|
||||
// A distance at or after the walk's origin, in the doubled loop.
|
||||
double Unwrap(double distance)
|
||||
{
|
||||
while (distance < origin - 1e-12)
|
||||
distance += length;
|
||||
return distance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="count"/> tab centers spaced evenly around a contour of
|
||||
/// <paramref name="length"/>, one of them at <paramref name="firstCenter"/>. The builder
|
||||
/// centers one on the middle of the contour's first entity, so tabs avoid its start corner.
|
||||
/// </summary>
|
||||
public static List<double> EvenTabCenters(double length, int count, double firstCenter)
|
||||
{
|
||||
count = System.Math.Max(count, TabbedCutout.MinTabCount);
|
||||
var spacing = length / count;
|
||||
var start = Wrap(firstCenter, spacing);
|
||||
return Enumerable.Range(0, count).Select(i => start + spacing * i).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The visible lines and arcs forming the closed contour through <paramref name="picked"/>,
|
||||
/// or the picked circle alone. Empty when <paramref name="picked"/> is on no closed contour
|
||||
/// that continues as a single path.
|
||||
/// </summary>
|
||||
public static List<Entity> FindHole(IReadOnlyList<Entity> entities, Entity picked)
|
||||
{
|
||||
var pool = entities.Where(e => e is Line or Arc or Circle && e.IsVisible && e.Layer?.IsVisible != false
|
||||
&& !BreakoffBuilder.IsMark(e) && e.Layer != SpecialLayers.Break).ToList();
|
||||
if (!pool.Contains(picked))
|
||||
return new List<Entity>();
|
||||
if (picked is Circle)
|
||||
return new List<Entity> { picked };
|
||||
|
||||
var lines = pool.Where(e => e is Line or Arc).ToList();
|
||||
var chain = new List<Entity> { picked };
|
||||
var start = BreakoffPath.Start(picked);
|
||||
var tip = BreakoffPath.End(picked);
|
||||
while (tip.DistanceTo(start) > JoinTolerance || chain.Count == 1)
|
||||
{
|
||||
var touching = lines.Where(e => !chain.Contains(e)
|
||||
&& (BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|
||||
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance)).ToList();
|
||||
if (touching.Count != 1)
|
||||
return new List<Entity>();
|
||||
var next = touching[0];
|
||||
chain.Add(next);
|
||||
tip = BreakoffPath.Start(next).DistanceTo(tip) <= JoinTolerance
|
||||
? BreakoffPath.End(next)
|
||||
: BreakoffPath.Start(next);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static TabbedCutoutLayout Fail(string error) => new(error);
|
||||
|
||||
private static double Wrap(double distance, double length)
|
||||
{
|
||||
var wrapped = distance % length;
|
||||
return wrapped < 0 ? wrapped + length : wrapped;
|
||||
}
|
||||
|
||||
// Copies of the span from `from` to `to`, with new IDs and, for tabs, the tab layer.
|
||||
private static List<Entity> Span(IReadOnlyList<Entity> path, double from, double to, Layer layer)
|
||||
{
|
||||
var span = BreakoffPath.Sub(path, from, to);
|
||||
foreach (var e in span)
|
||||
{
|
||||
e.Id = Guid.NewGuid();
|
||||
if (layer != null)
|
||||
e.Layer = layer;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="edges"/> copied into one closed chain in travel order, a circle as two
|
||||
/// half arcs; null when they are not exactly one closed loop.
|
||||
/// </summary>
|
||||
private static List<Entity> ChainClosed(IReadOnlyList<Entity> edges)
|
||||
{
|
||||
if (edges.Count == 1 && edges[0] is Circle circle)
|
||||
{
|
||||
var reversed = circle.Rotation == RotationType.CW;
|
||||
Arc Half(double from, double to) => new(circle.Center, circle.Radius, from, to, reversed)
|
||||
{
|
||||
Layer = circle.Layer,
|
||||
Color = circle.Color,
|
||||
LineTypeName = circle.LineTypeName,
|
||||
};
|
||||
return new List<Entity> { Half(System.Math.PI, 0), Half(0, System.Math.PI) };
|
||||
}
|
||||
if (edges.Count == 0 || edges.Any(e => e is not (Line or Arc)))
|
||||
return null;
|
||||
|
||||
var remaining = edges.ToList();
|
||||
var chain = new List<Entity> { BreakoffPath.Copy(remaining[0]) };
|
||||
remaining.RemoveAt(0);
|
||||
while (remaining.Count > 0)
|
||||
{
|
||||
var tip = BreakoffPath.End(chain[^1]);
|
||||
var matches = remaining.Where(e => BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|
||||
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance).ToList();
|
||||
if (matches.Count != 1)
|
||||
return null;
|
||||
var next = matches[0];
|
||||
var copy = BreakoffPath.Copy(next);
|
||||
if (BreakoffPath.Start(next).DistanceTo(tip) > JoinTolerance)
|
||||
copy.Reverse();
|
||||
remaining.Remove(next);
|
||||
chain.Add(copy);
|
||||
}
|
||||
return BreakoffPath.Start(chain[0]).DistanceTo(BreakoffPath.End(chain[^1])) <= JoinTolerance ? chain : null;
|
||||
}
|
||||
|
||||
// Rotates a closed loop to start at its vertex with the smallest X (then Y), so tab
|
||||
// positions survive re-chaining.
|
||||
private static List<Entity> StartAtLowestLeft(List<Entity> loop)
|
||||
{
|
||||
var best = 0;
|
||||
for (var i = 1; i < loop.Count; i++)
|
||||
{
|
||||
var p = BreakoffPath.Start(loop[i]);
|
||||
var q = BreakoffPath.Start(loop[best]);
|
||||
if (p.X < q.X - JoinTolerance || (System.Math.Abs(p.X - q.X) <= JoinTolerance && p.Y < q.Y))
|
||||
best = i;
|
||||
}
|
||||
return loop.Skip(best).Concat(loop.Take(best)).ToList();
|
||||
}
|
||||
|
||||
// Whether the loop lies inside some other closed outline drawn with it.
|
||||
private static bool InsideAnOutline(IReadOnlyList<Entity> entities, HashSet<Guid> loopIds,
|
||||
IReadOnlyList<Vector> loop)
|
||||
{
|
||||
var others = entities.Where(e => !loopIds.Contains(e.Id) && e is Line or Arc or Circle
|
||||
&& !BreakoffBuilder.IsMark(e) && e.Layer != SpecialLayers.Break && e.Layer != SpecialLayers.Tab).ToList();
|
||||
var loopArea = System.Math.Abs(BreakoffPath.SignedArea(loop));
|
||||
foreach (var shape in ShapeBuilder.GetShapes(others))
|
||||
{
|
||||
if (!shape.IsClosed())
|
||||
continue;
|
||||
var outline = shape.ToPolygonWithTolerance(ArcTolerance).Vertices;
|
||||
if (System.Math.Abs(BreakoffPath.SignedArea(outline)) > loopArea
|
||||
&& loop.All(p => BreakoffPath.Contains(outline, p)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Breakoffs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.TabbedCutouts
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="TabbedCutout"/> resolved against a drawing's entities. When <see cref="Error"/>
|
||||
/// is set nothing else is meaningful and the cutout is not applied.
|
||||
/// </summary>
|
||||
public sealed class TabbedCutoutLayout
|
||||
{
|
||||
internal TabbedCutoutLayout(string error) => Error = error;
|
||||
|
||||
internal TabbedCutoutLayout() { }
|
||||
|
||||
public string Error { get; }
|
||||
|
||||
public bool IsValid => Error == null;
|
||||
|
||||
/// <summary>
|
||||
/// The hole's contour, counterclockwise from its lowest-left vertex with its joints made
|
||||
/// exact. It is also the slug's boundary.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Entity> Loop { get; internal set; }
|
||||
|
||||
public double Length { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// One open cut per span of the contour between tabs, in contour order, travelling
|
||||
/// counterclockwise (the slug on the left).
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; }
|
||||
|
||||
/// <summary>The uncut tabs on the tab layer, each following the piece before it.</summary>
|
||||
public IReadOnlyList<IReadOnlyList<Entity>> Tabs { get; internal set; }
|
||||
|
||||
/// <summary>Tab centers used, as distances along <see cref="Loop"/>.</summary>
|
||||
public IReadOnlyList<double> TabCenters { get; internal set; }
|
||||
|
||||
public double TabWidth { get; internal set; }
|
||||
|
||||
/// <summary>Where each piece is entered (its start) and the unit direction into the slug there.</summary>
|
||||
public IReadOnlyList<(Vector Entry, Vector SlugDirection)> Entries => Pieces
|
||||
.Select(piece =>
|
||||
{
|
||||
var tangent = BreakoffPath.TangentAt(piece, 0);
|
||||
return (BreakoffPath.Start(piece[0]), new Vector(-tangent.Y, tangent.X));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
/// <summary>Entities the pieces and tabs replace: the hole's original contour.</summary>
|
||||
internal IReadOnlyList<Entity> ReplacedEntities { get; set; }
|
||||
|
||||
/// <summary><paramref name="count"/> evenly spaced tab centers, as the builder places them by default.</summary>
|
||||
public List<double> EvenTabCenters(int count) =>
|
||||
TabbedCutoutBuilder.EvenTabCenters(Length, count, Loop[0].Length / 2);
|
||||
|
||||
/// <summary>The point at <paramref name="distance"/> along the contour, wrapping around it.</summary>
|
||||
public Vector PointAt(double distance) => BreakoffPath.PointAt(Loop, Wrap(distance));
|
||||
|
||||
/// <summary>The distance along the contour closest to <paramref name="point"/>.</summary>
|
||||
public double DistanceOf(Vector point) => BreakoffPath.DistanceOf(Loop, point);
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a
|
||||
/// gap from its neighbours around the contour and does not pass the contour's start
|
||||
/// (which would reorder the tabs).
|
||||
/// </summary>
|
||||
public double ClampTabCenter(int index, double center)
|
||||
{
|
||||
var count = TabCenters.Count;
|
||||
if (count == 1)
|
||||
return Wrap(center);
|
||||
var spacing = TabWidth + MinGap;
|
||||
var min = index == 0 ? System.Math.Max(0, TabCenters[^1] - Length + spacing) : TabCenters[index - 1] + spacing;
|
||||
var max = index == count - 1
|
||||
? System.Math.Min(Length - Tolerance, TabCenters[0] + Length - spacing)
|
||||
: TabCenters[index + 1] - spacing;
|
||||
return max < min ? TabCenters[index] : System.Math.Clamp(center, min, max);
|
||||
}
|
||||
|
||||
internal double Wrap(double distance)
|
||||
{
|
||||
if (!(Length > 0))
|
||||
return 0;
|
||||
var wrapped = distance % Length;
|
||||
return wrapped < 0 ? wrapped + Length : wrapped;
|
||||
}
|
||||
|
||||
// Wider than the builder's overlap tolerance, so a clamped drag always resolves.
|
||||
internal double MinGap => System.Math.Max(TabWidth / 2, 2 * TabbedCutoutBuilder.JoinTolerance);
|
||||
|
||||
private const double Tolerance = 1e-9;
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
@@ -43,6 +42,10 @@ namespace OpenNest
|
||||
pos = endpt;
|
||||
}
|
||||
|
||||
// D9: once per physical sheet, before the quantity multiply below scales it with
|
||||
// everything else cut on that sheet.
|
||||
info.ClampRemovalMinutes = plate.ClampRemovalMinutes;
|
||||
|
||||
info *= plate.Quantity;
|
||||
|
||||
return info;
|
||||
@@ -72,6 +75,21 @@ namespace OpenNest
|
||||
info.TravelDistance += entity.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A plate's cut time over its quantity: its <see cref="CutSimulation"/> timeline (hole and
|
||||
/// outside feeds, skipped etch, and acceleration when <see cref="CutParameters.Motion"/> is
|
||||
/// set) plus clamp removal.
|
||||
/// </summary>
|
||||
public static TimeSpan CalculateTime(Plate plate, CutParameters cutParams)
|
||||
{
|
||||
var sheet = CutSimulation.Build(plate, cutParams).TotalTime
|
||||
+ TimeSpan.FromMinutes(plate.ClampRemovalMinutes);
|
||||
return sheet * plate.Quantity;
|
||||
}
|
||||
|
||||
public static TimeSpan CalculateTime(Nest nest, CutParameters cutParams) =>
|
||||
nest.Plates.Aggregate(TimeSpan.Zero, (total, plate) => total + CalculateTime(plate, cutParams));
|
||||
|
||||
public static TimeSpan CalculateTime(TimingInfo info, CutParameters cutParams)
|
||||
{
|
||||
var time = new TimeSpan();
|
||||
@@ -91,6 +109,9 @@ namespace OpenNest
|
||||
|
||||
time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks);
|
||||
|
||||
// D9: a flat per-removed-clamp time, already in minutes regardless of cutParams.Units.
|
||||
time += TimeSpan.FromMinutes(info.ClampRemovalMinutes);
|
||||
|
||||
return time;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ namespace OpenNest
|
||||
|
||||
public double CutDistance;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes added by last-resort clamp removal (D9): once per physical sheet, for
|
||||
/// every clamp <see cref="Plate.RemovedClamps"/> lists. Zero for a plate with no
|
||||
/// removal, so existing timing is unaffected.
|
||||
/// </summary>
|
||||
public double ClampRemovalMinutes;
|
||||
|
||||
public static TimingInfo operator +(TimingInfo info1, TimingInfo info2)
|
||||
{
|
||||
return new TimingInfo
|
||||
@@ -18,6 +25,7 @@ namespace OpenNest
|
||||
IntersectionCount = info1.IntersectionCount + info2.IntersectionCount,
|
||||
TravelDistance = info1.TravelDistance + info2.TravelDistance,
|
||||
PierceCount = info1.PierceCount + info2.PierceCount,
|
||||
ClampRemovalMinutes = info1.ClampRemovalMinutes + info2.ClampRemovalMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +37,7 @@ namespace OpenNest
|
||||
IntersectionCount = info1.IntersectionCount - info2.IntersectionCount,
|
||||
TravelDistance = info1.TravelDistance - info2.TravelDistance,
|
||||
PierceCount = info1.PierceCount - info2.PierceCount,
|
||||
ClampRemovalMinutes = info1.ClampRemovalMinutes - info2.ClampRemovalMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +49,7 @@ namespace OpenNest
|
||||
IntersectionCount = info1.IntersectionCount * info2.IntersectionCount,
|
||||
TravelDistance = info1.TravelDistance * info2.TravelDistance,
|
||||
PierceCount = info1.PierceCount * info2.PierceCount,
|
||||
ClampRemovalMinutes = info1.ClampRemovalMinutes * info2.ClampRemovalMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +61,7 @@ namespace OpenNest
|
||||
IntersectionCount = info1.IntersectionCount * factor,
|
||||
TravelDistance = info1.TravelDistance * factor,
|
||||
PierceCount = info1.PierceCount * factor,
|
||||
ClampRemovalMinutes = info1.ClampRemovalMinutes * factor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Decision logic behind the desktop Clamps dialog: edits one nest's clamp rail in that
|
||||
/// nest's units. Three explicit boundaries: <see cref="LoadMachineDefaults"/> only fills
|
||||
/// the editor, <see cref="ApplyTo"/> changes the nest (the dialog's OK), and
|
||||
/// <see cref="ToMachineDefaults"/> is what "Save as Machine Defaults" writes immediately.
|
||||
/// </summary>
|
||||
public sealed class ClampRailEditor
|
||||
{
|
||||
private readonly List<RailClamp> _clamps = new();
|
||||
|
||||
/// <summary>Starts from the nest's rail, or the default clamp size with no clamps.</summary>
|
||||
public ClampRailEditor(ClampRail? rail, Units units, bool applyToNewNests)
|
||||
{
|
||||
Units = units;
|
||||
ApplyToNewNests = applyToNewNests;
|
||||
var initial = ClampSettings.FromRail(rail, units, applyToNewNests);
|
||||
Width = initial.Width;
|
||||
Depth = initial.Depth;
|
||||
RemovalMinutes = initial.RemovalMinutes;
|
||||
_clamps.AddRange(initial.Clamps.Order());
|
||||
}
|
||||
|
||||
/// <summary>The nest's units; every value is in these units.</summary>
|
||||
public Units Units { get; }
|
||||
|
||||
/// <summary>Clamp extent along its edge.</summary>
|
||||
public double Width { get; set; }
|
||||
|
||||
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
|
||||
public double Depth { get; set; }
|
||||
|
||||
/// <summary>Minutes to remove one clamp and put it back after cutting (D9); not a
|
||||
/// length, so it is never converted between units.</summary>
|
||||
public double RemovalMinutes { get; set; }
|
||||
|
||||
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
|
||||
public bool ApplyToNewNests { get; set; }
|
||||
|
||||
/// <summary>Clamps ordered by edge, then center from the plate origin; distinct.</summary>
|
||||
public IReadOnlyList<RailClamp> Clamps => _clamps;
|
||||
|
||||
/// <summary>Origin-side clamp centers along X from the plate origin, ascending.</summary>
|
||||
public IReadOnlyList<double> Centers =>
|
||||
_clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToList();
|
||||
|
||||
/// <summary>Adds an origin-side clamp; see <see cref="TryAddClamp"/>.</summary>
|
||||
public bool TryAddCenter(double center, out string? error) =>
|
||||
TryAddClamp(new RailClamp(ClampEdge.OriginSide, center), out error);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a clamp, keeping the list sorted. Refuses (with an operator-facing reason)
|
||||
/// centers that are not finite, negative, or already listed on that edge.
|
||||
/// </summary>
|
||||
public bool TryAddClamp(RailClamp clamp, out string? error)
|
||||
{
|
||||
if (!double.IsFinite(clamp.Center) || clamp.Center < 0)
|
||||
{
|
||||
error = "Enter a clamp center measured from the plate origin (zero or more).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var index = _clamps.BinarySearch(clamp);
|
||||
if (index >= 0)
|
||||
{
|
||||
error = $"A clamp is already centered at {clamp.Center:0.####} on that edge.";
|
||||
return false;
|
||||
}
|
||||
|
||||
_clamps.Insert(~index, clamp);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Removes the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
|
||||
public void RemoveCenterAt(int index)
|
||||
{
|
||||
if (index >= 0 && index < _clamps.Count)
|
||||
_clamps.RemoveAt(index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the editor's values with the machine defaults converted to this nest's
|
||||
/// units. Nothing is applied or saved.
|
||||
/// </summary>
|
||||
public void LoadMachineDefaults(ClampSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
var converted = settings.ConvertedTo(Units);
|
||||
Width = converted.Width;
|
||||
Depth = converted.Depth;
|
||||
RemovalMinutes = converted.RemovalMinutes;
|
||||
ApplyToNewNests = settings.Enabled;
|
||||
_clamps.Clear();
|
||||
_clamps.AddRange(converted.Clamps.Distinct().Order());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The edited rail, or null when no clamp is listed. Throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
|
||||
/// negative/non-finite removal time.
|
||||
/// </summary>
|
||||
public ClampRail? BuildRail() =>
|
||||
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes);
|
||||
|
||||
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
|
||||
public void ApplyTo(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
nest.SetClampRail(BuildRail());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The machine settings "Save as Machine Defaults" writes: these values in this nest's
|
||||
/// units, enabled for new nests only when requested and at least one clamp is listed.
|
||||
/// </summary>
|
||||
public ClampSettings ToMachineDefaults()
|
||||
{
|
||||
// Validate before anything is persisted.
|
||||
if (!double.IsFinite(Width) || Width <= 0 || !double.IsFinite(Depth) || Depth <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(Width),
|
||||
"Clamp width and depth must be positive."
|
||||
);
|
||||
if (!double.IsFinite(RemovalMinutes) || RemovalMinutes < 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(RemovalMinutes),
|
||||
"Clamp removal time must be finite and zero or more."
|
||||
);
|
||||
var settings = new ClampSettings
|
||||
{
|
||||
Enabled = ApplyToNewNests && _clamps.Count > 0,
|
||||
Units = Units,
|
||||
Width = Width,
|
||||
Depth = Depth,
|
||||
RemovalMinutes = RemovalMinutes,
|
||||
};
|
||||
settings.SetClamps(_clamps);
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Outcome of <see cref="ClampSettings.Load(string, out ClampSettingsStatus)"/>.</summary>
|
||||
public enum ClampSettingsStatus
|
||||
{
|
||||
/// <summary>Every value present in the file was valid.</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>No file exists at the path; clamps stay off.</summary>
|
||||
Missing,
|
||||
|
||||
/// <summary>
|
||||
/// The file loaded, but some values were invalid and were ignored (an invalid clamp
|
||||
/// center is dropped, other fields fall back individually). Callers should warn: the
|
||||
/// resulting rail may lack a physical clamp.
|
||||
/// </summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>The file exists but could not be read or parsed; clamps stay off.</summary>
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default
|
||||
/// %APPDATA%\OpenNest\clamps.json). Off by default: until <see cref="Enabled"/> is set
|
||||
/// with at least one center, new nests get no clamps and behave exactly as before.
|
||||
/// Loading never throws; values are validated field by field.
|
||||
/// </summary>
|
||||
public sealed class ClampSettings
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
/// <summary>Default clamp extent along the edge, in <see cref="Units"/> (inches).</summary>
|
||||
public const double DefaultWidth = 1.5;
|
||||
|
||||
/// <summary>Default clamp intrusion into the sheet, in <see cref="Units"/> (inches).</summary>
|
||||
public const double DefaultDepth = 0.625;
|
||||
|
||||
private const double MillimetersPerInch = 25.4;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>Whether new nests receive this rail. Default off.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Units of <see cref="Width"/>, <see cref="Depth"/> and <see cref="Centers"/>.</summary>
|
||||
public Units Units { get; set; } = Units.Inches;
|
||||
|
||||
public double Width { get; set; } = DefaultWidth;
|
||||
|
||||
public double Depth { get; set; } = DefaultDepth;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes to remove one clamp and put it back after cutting (D9), stored and edited in
|
||||
/// the machine clamp settings alongside the rail's physical size.
|
||||
/// </summary>
|
||||
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
|
||||
|
||||
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
|
||||
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
|
||||
|
||||
/// <summary>Far-side clamp centers along X, measured from the plate origin.</summary>
|
||||
public IReadOnlyList<double> FarSideCenters { get; set; } = Array.Empty<double>();
|
||||
|
||||
/// <summary>Origin-end clamp centers along Y, measured from the plate origin.</summary>
|
||||
public IReadOnlyList<double> OriginEndCenters { get; set; } = Array.Empty<double>();
|
||||
|
||||
/// <summary>Far-end clamp centers along Y, measured from the plate origin.</summary>
|
||||
public IReadOnlyList<double> FarEndCenters { get; set; } = Array.Empty<double>();
|
||||
|
||||
/// <summary>Every configured clamp, by edge then center as listed.</summary>
|
||||
public IEnumerable<RailClamp> Clamps =>
|
||||
Centers.Select(c => new RailClamp(ClampEdge.OriginSide, c))
|
||||
.Concat(FarSideCenters.Select(c => new RailClamp(ClampEdge.FarSide, c)))
|
||||
.Concat(OriginEndCenters.Select(c => new RailClamp(ClampEdge.OriginEnd, c)))
|
||||
.Concat(FarEndCenters.Select(c => new RailClamp(ClampEdge.FarEnd, c)));
|
||||
|
||||
/// <summary>The number of configured clamps on every edge.</summary>
|
||||
public int ClampCount =>
|
||||
Centers.Count + FarSideCenters.Count + OriginEndCenters.Count + FarEndCenters.Count;
|
||||
|
||||
/// <summary>The centers listed for <paramref name="edge"/>.</summary>
|
||||
public IReadOnlyList<double> CentersOn(ClampEdge edge) =>
|
||||
edge switch
|
||||
{
|
||||
ClampEdge.FarSide => FarSideCenters,
|
||||
ClampEdge.OriginEnd => OriginEndCenters,
|
||||
ClampEdge.FarEnd => FarEndCenters,
|
||||
_ => Centers,
|
||||
};
|
||||
|
||||
/// <summary>Lists <paramref name="clamps"/> under their edges, replacing every edge's centers.</summary>
|
||||
public void SetClamps(IEnumerable<RailClamp> clamps)
|
||||
{
|
||||
var list = clamps.ToList();
|
||||
double[] On(ClampEdge edge) => list.Where(c => c.Edge == edge).Select(c => c.Center).ToArray();
|
||||
Centers = On(ClampEdge.OriginSide);
|
||||
FarSideCenters = On(ClampEdge.FarSide);
|
||||
OriginEndCenters = On(ClampEdge.OriginEnd);
|
||||
FarEndCenters = On(ClampEdge.FarEnd);
|
||||
}
|
||||
|
||||
/// <summary>The built-in settings used for a missing or unreadable file: clamps off.</summary>
|
||||
public static ClampSettings Fallback => new();
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\clamps.json.</summary>
|
||||
public static string DefaultPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest",
|
||||
"clamps.json"
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Captures a nest's rail (or the default clamp size and no centers when it has none)
|
||||
/// in that nest's units, e.g. for "save as machine defaults".
|
||||
/// </summary>
|
||||
public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled)
|
||||
{
|
||||
var settings = new ClampSettings
|
||||
{
|
||||
Enabled = enabled,
|
||||
Units = units,
|
||||
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
|
||||
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
|
||||
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
|
||||
};
|
||||
if (rail != null)
|
||||
settings.SetClamps(rail.Clamps);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rail a new nest in <paramref name="units"/> receives, or null when clamps are
|
||||
/// disabled or no center is configured. Values are converted between inches and
|
||||
/// millimeters as needed; <see cref="RemovalMinutes"/> is a time, so it is never scaled.
|
||||
/// </summary>
|
||||
public ClampRail? RailFor(Units units)
|
||||
{
|
||||
if (!Enabled || ClampCount == 0)
|
||||
return null;
|
||||
|
||||
var converted = ConvertedTo(units);
|
||||
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy with width, depth and centers expressed in <paramref name="units"/>
|
||||
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
|
||||
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
|
||||
/// </summary>
|
||||
public ClampSettings ConvertedTo(Units units)
|
||||
{
|
||||
var scale = Scale(Units, units);
|
||||
return new ClampSettings
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Units = units,
|
||||
Width = Width * scale,
|
||||
Depth = Depth * scale,
|
||||
RemovalMinutes = RemovalMinutes,
|
||||
Centers = Centers.Select(c => c * scale).ToArray(),
|
||||
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
|
||||
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
|
||||
FarEndCenters = FarEndCenters.Select(c => c * scale).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives a newly created nest this machine rail when enabled (after the nest's units are
|
||||
/// set); otherwise leaves it untouched.
|
||||
/// </summary>
|
||||
public void ApplyToNewNest(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
var rail = RailFor(nest.Units);
|
||||
if (rail != null)
|
||||
nest.SetClampRail(rail);
|
||||
}
|
||||
|
||||
public static ClampSettings Load(string path) => Load(path, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the settings, never throwing. A missing or unreadable file yields
|
||||
/// <see cref="Fallback"/> (clamps off); a readable file merges valid values over it and
|
||||
/// reports <see cref="ClampSettingsStatus.Partial"/> if anything was ignored.
|
||||
/// </summary>
|
||||
public static ClampSettings Load(string path, out ClampSettingsStatus status)
|
||||
{
|
||||
var settings = Fallback;
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
status = ClampSettingsStatus.Missing;
|
||||
return settings;
|
||||
}
|
||||
|
||||
// Read as a raw document so a missing field (fallback, Ok) is distinguishable from a
|
||||
// present but unusable one, including an explicit null (fallback, Partial).
|
||||
Dictionary<string, JsonElement> values;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
status = ClampSettingsStatus.Invalid;
|
||||
return settings;
|
||||
}
|
||||
values = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
values[property.Name] = property.Value.Clone();
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||
{
|
||||
status = ClampSettingsStatus.Invalid;
|
||||
return settings;
|
||||
}
|
||||
|
||||
var ignored = false;
|
||||
|
||||
if (values.TryGetValue("enabled", out var enabled))
|
||||
{
|
||||
if (enabled.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
settings.Enabled = enabled.GetBoolean();
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (values.TryGetValue("units", out var units))
|
||||
{
|
||||
if (TryParseUnits(units, out var parsed))
|
||||
settings.Units = parsed;
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
// Fallback sizes are the physical default clamp, expressed in the file's units.
|
||||
settings.Width = ConvertDefault(DefaultWidth, settings.Units);
|
||||
settings.Depth = ConvertDefault(DefaultDepth, settings.Units);
|
||||
|
||||
if (values.TryGetValue("width", out var width))
|
||||
{
|
||||
if (TryLength(width, out var value) && value > 0)
|
||||
settings.Width = value;
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (values.TryGetValue("depth", out var depth))
|
||||
{
|
||||
if (TryLength(depth, out var value) && value > 0)
|
||||
settings.Depth = value;
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (values.TryGetValue("removalMinutes", out var removalMinutes))
|
||||
{
|
||||
if (TryLength(removalMinutes, out var value) && value >= 0)
|
||||
settings.RemovalMinutes = value;
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (values.TryGetValue("centers", out var centers))
|
||||
settings.Centers = ReadCenters(centers, ref ignored);
|
||||
if (values.TryGetValue("farSideCenters", out var farSide))
|
||||
settings.FarSideCenters = ReadCenters(farSide, ref ignored);
|
||||
if (values.TryGetValue("originEndCenters", out var originEnd))
|
||||
settings.OriginEndCenters = ReadCenters(originEnd, ref ignored);
|
||||
if (values.TryGetValue("farEndCenters", out var farEnd))
|
||||
settings.FarEndCenters = ReadCenters(farEnd, ref ignored);
|
||||
|
||||
status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok;
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>Valid centers of a JSON array, distinct and ascending; flags anything dropped.</summary>
|
||||
private static double[] ReadCenters(JsonElement centers, ref bool ignored)
|
||||
{
|
||||
if (centers.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
ignored = true;
|
||||
return Array.Empty<double>();
|
||||
}
|
||||
|
||||
var valid = new List<double>();
|
||||
foreach (var center in centers.EnumerateArray())
|
||||
{
|
||||
if (TryLength(center, out var x) && x >= 0)
|
||||
valid.Add(x);
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
return valid.Distinct().OrderBy(x => x).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the file (creating the parent directory), retrying briefly on IO collisions
|
||||
/// the same way <see cref="LocalJsonProvider"/> does.
|
||||
/// </summary>
|
||||
public void Save(string path, int maxRetries = 3)
|
||||
{
|
||||
var dto = new Dictionary<string, object>
|
||||
{
|
||||
["version"] = CurrentVersion,
|
||||
["enabled"] = Enabled,
|
||||
["units"] = Units.ToString().ToLowerInvariant(),
|
||||
["width"] = Width,
|
||||
["depth"] = Depth,
|
||||
["removalMinutes"] = RemovalMinutes,
|
||||
["centers"] = Centers,
|
||||
};
|
||||
// Other edges are written only when used, so origin-side-only files stay unchanged.
|
||||
if (FarSideCenters.Count > 0)
|
||||
dto["farSideCenters"] = FarSideCenters;
|
||||
if (OriginEndCenters.Count > 0)
|
||||
dto["originEndCenters"] = OriginEndCenters;
|
||||
if (FarEndCenters.Count > 0)
|
||||
dto["farEndCenters"] = FarEndCenters;
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static double Scale(Units from, Units to) =>
|
||||
from == to ? 1
|
||||
: from == Units.Inches ? MillimetersPerInch
|
||||
: 1 / MillimetersPerInch;
|
||||
|
||||
private static double ConvertDefault(double inches, Units units) =>
|
||||
inches * Scale(Units.Inches, units);
|
||||
|
||||
/// <summary>
|
||||
/// A finite JSON number that stays finite in either unit, so applying it to an inch or a
|
||||
/// millimeter nest can never overflow.
|
||||
/// </summary>
|
||||
private static bool TryLength(JsonElement element, out double value)
|
||||
{
|
||||
value = 0;
|
||||
return element.ValueKind == JsonValueKind.Number
|
||||
&& element.TryGetDouble(out value)
|
||||
&& double.IsFinite(value)
|
||||
&& double.IsFinite(value * MillimetersPerInch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
|
||||
/// numeric strings and comma-joined names.
|
||||
/// </summary>
|
||||
private static bool TryParseUnits(JsonElement element, out Units units)
|
||||
{
|
||||
units = default;
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
|
||||
var text = element.GetString();
|
||||
foreach (var candidate in Enum.GetValues<Units>())
|
||||
{
|
||||
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
units = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsUnreadableFile(Exception ex) =>
|
||||
ex
|
||||
is JsonException
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Globalization;
|
||||
using OpenNest;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Browse state for Add from Drawing Library: the requested search, sort, page and
|
||||
/// nest filter, and the last page applied. By default only drawings with the nest's
|
||||
/// material, grade and thickness are listed; <see cref="SetShowAllAsync"/> lifts that
|
||||
/// filter. Every change issues one bounded server query. A response that a later request
|
||||
/// superseded is discarded (and the earlier request is cancelled), so an older result can
|
||||
/// never replace a newer one. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class DrawingLibraryBrowseSession : IDisposable
|
||||
{
|
||||
private readonly IDrawingLibrary _library;
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(library);
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
if (pageSize < 1 || pageSize > DrawingLibraryQuery.MaxLimit)
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingLibraryQuery.MaxLimit}.");
|
||||
|
||||
_library = library;
|
||||
PageSize = pageSize;
|
||||
MaterialName = (nest.Material?.Name ?? "").Trim();
|
||||
MaterialGrade = (nest.Material?.Grade ?? "").Trim();
|
||||
// A nest without a thickness has nothing to match; it filters by material only.
|
||||
Thickness = double.IsFinite(nest.Thickness) && nest.Thickness > 0
|
||||
? DrawingLibraryRules.NormalizeThickness(nest.Thickness)
|
||||
: null;
|
||||
}
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
/// <summary>The nest's material name, applied unless <see cref="ShowAll"/>; blank means any.</summary>
|
||||
public string MaterialName { get; }
|
||||
|
||||
/// <summary>The nest's grade, applied unless <see cref="ShowAll"/>; blank means any.</summary>
|
||||
public string MaterialGrade { get; }
|
||||
|
||||
/// <summary>The nest's thickness, applied unless <see cref="ShowAll"/>; null means any.</summary>
|
||||
public double? Thickness { get; }
|
||||
|
||||
/// <summary>True when the nest filter is lifted and every active drawing is listed.</summary>
|
||||
public bool ShowAll { get; private set; }
|
||||
|
||||
/// <summary>True when the latest request narrows by the nest's material, grade or thickness.</summary>
|
||||
public bool IsFilteredToNest =>
|
||||
!ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
|
||||
|
||||
/// <summary>Trimmed search text of the latest request.</summary>
|
||||
public string Search { get; private set; } = "";
|
||||
|
||||
public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt;
|
||||
|
||||
public bool Descending { get; private set; } = true;
|
||||
|
||||
/// <summary>Offset of the latest request.</summary>
|
||||
public int Offset { get; private set; }
|
||||
|
||||
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
|
||||
public DrawingLibraryPage? Page { get; private set; }
|
||||
|
||||
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
|
||||
public bool IsLoading { get; private set; }
|
||||
|
||||
public bool CanGoPrevious => Offset > 0;
|
||||
|
||||
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
|
||||
|
||||
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 drawings".</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Page is not { } page)
|
||||
return "";
|
||||
if (page.Total == 0)
|
||||
{
|
||||
if (Search.Length > 0)
|
||||
return "No drawings match the search.";
|
||||
return IsFilteredToNest
|
||||
? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
|
||||
: "No drawings in the library.";
|
||||
}
|
||||
if (page.Items.Count == 0)
|
||||
return $"No drawings on this page ({Count(page.Total)} in total).";
|
||||
|
||||
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} drawings";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies new search text and returns to the first page.</summary>
|
||||
public Task<bool> SetSearchAsync(string? search)
|
||||
{
|
||||
Search = (search ?? "").Trim();
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Lifts or restores the nest filter and returns to the first page.</summary>
|
||||
public Task<bool> SetShowAllAsync(bool showAll)
|
||||
{
|
||||
ShowAll = showAll;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
|
||||
public Task<bool> SortByAsync(DrawingSortField field)
|
||||
{
|
||||
Descending = field == Sort && !Descending;
|
||||
Sort = field;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> NextPageAsync()
|
||||
{
|
||||
if (!CanGoNext)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = Page!.Offset + PageSize;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> PreviousPageAsync()
|
||||
{
|
||||
if (!CanGoPrevious)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = System.Math.Max(0, Offset - PageSize);
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
|
||||
public Task<bool> RefreshAsync() => RunAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Sends the current request. Returns true when its page was applied, false when a later
|
||||
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
|
||||
/// failures of superseded requests are ignored.
|
||||
/// </summary>
|
||||
private async Task<bool> RunAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a request that completes synchronously when cancelled
|
||||
// must already see itself as superseded. Each source is released by its own request.
|
||||
var previous = _pending;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_pending = cancellation;
|
||||
var generation = ++_generation;
|
||||
IsLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
previous?.Cancel();
|
||||
var page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
|
||||
// An archive or another PC can leave the requested page past the end.
|
||||
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
|
||||
{
|
||||
Offset = (page.Total - 1) / PageSize * PageSize;
|
||||
page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
}
|
||||
|
||||
Page = page;
|
||||
return true;
|
||||
}
|
||||
catch (Exception) when (generation != _generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Page = null;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_pending, cancellation))
|
||||
_pending = null;
|
||||
if (generation == _generation)
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
_library.QueryAsync(
|
||||
new DrawingLibraryQuery
|
||||
{
|
||||
Search = Search,
|
||||
MaterialName = ShowAll ? "" : MaterialName,
|
||||
MaterialGrade = ShowAll ? "" : MaterialGrade,
|
||||
Thickness = ShowAll ? null : Thickness,
|
||||
Sort = Sort,
|
||||
Descending = Descending,
|
||||
Offset = Offset,
|
||||
Limit = PageSize,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels any request in flight (which releases its own source when it ends);
|
||||
/// the library client is not owned and stays open.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_generation++;
|
||||
IsLoading = false;
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
pending?.Cancel();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 657 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user