mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-28 19:31:06 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5dc67346d | ||
|
|
f22e839850 | ||
|
|
b8e8cc387d | ||
|
|
c6b6678a2f | ||
|
|
4179b53c97 | ||
|
|
c8419761e0 | ||
|
|
16bd92567c | ||
|
|
cd51f7cfda | ||
|
|
6f4e872c03 | ||
|
|
588ae5ed09 | ||
|
|
277ca36f42 | ||
|
|
8dded7f177 | ||
|
|
b65a9485c7 | ||
|
|
63915d6fb6 | ||
|
|
379e3fd433 | ||
|
|
f1468e6f57 | ||
|
|
0be9e2201a | ||
|
|
fcb61f3b3e | ||
|
|
87e0a70878 | ||
|
|
8776eb4734 | ||
|
|
fd2e9c2f56 | ||
|
|
3618b335cb | ||
|
|
7d9c64487d | ||
|
|
cc066b457e | ||
|
|
d2f814f1f9 | ||
|
|
69d8187adc | ||
|
|
4670577110 | ||
|
|
d505a50934 | ||
|
|
bc15c362e1 | ||
|
|
9b4c1f51d0 | ||
|
|
eba15791e7 | ||
|
|
7fe4de4b6d | ||
|
|
f4317bc52f | ||
|
|
207f126b9e | ||
|
|
0f1e5fb568 | ||
|
|
0ede9443de | ||
|
|
58b8467c39 | ||
|
|
eedff85904 | ||
|
|
cc16590881 | ||
|
|
8ad32e4864 | ||
|
|
7198f7a4f7 | ||
|
|
ee842c822f | ||
|
|
512b861739 |
No files matched your search
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"vpk": {
|
||||
"version": "1.2.0",
|
||||
"commands": [
|
||||
"vpk"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,11 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- OpenNest.Tests
|
||||
- OpenNest.Desktop.Tests
|
||||
- OpenNest.Engine.Tests
|
||||
- OpenNest.IO.Tests
|
||||
- OpenNest.Server.Tests
|
||||
- OpenNest.FrontEnd.Tests
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
@@ -70,7 +72,7 @@ jobs:
|
||||
|
||||
windows-desktop:
|
||||
# WinForms tests need a Windows desktop runtime; the Linux job can only
|
||||
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
|
||||
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Development build
|
||||
|
||||
# Every master push whose cross-platform tests pass becomes the rolling
|
||||
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
|
||||
# newest release tag and N the commit count. Installed copies take it only
|
||||
# with Tools > Options > Include development builds.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Cross-platform tests']
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dev-build
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
package:
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
|
||||
DOTNET_NOLOGO: '1'
|
||||
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Resolve development version
|
||||
id: version
|
||||
run: |
|
||||
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
|
||||
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
|
||||
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
|
||||
$latest = @($releases)[-1]
|
||||
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
|
||||
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
|
||||
Write-Host "Development version $version (after v$latest)"
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
- name: Publish and verify Windows package
|
||||
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
|
||||
- name: Upload development build
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: OpenNest-dev-build
|
||||
path: |
|
||||
artifacts/*.zip
|
||||
artifacts/*.sha256
|
||||
artifacts/velopack/OpenNest-win-Setup.exe
|
||||
artifacts/velopack/OpenNest-win-Portable.zip
|
||||
artifacts/velopack/*-full.nupkg
|
||||
artifacts/velopack/releases.win.json
|
||||
artifacts/velopack/RELEASES
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
compression-level: 0
|
||||
|
||||
publish:
|
||||
needs: package
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
VERSION: ${{ needs.package.outputs.version }}
|
||||
steps:
|
||||
- name: Skip if master has moved on
|
||||
id: current
|
||||
run: |
|
||||
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
|
||||
if [ "$head" != "$SOURCE_SHA" ]; then
|
||||
echo "master is at $head; its own run publishes the newer build."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Download development build
|
||||
if: steps.current.outputs.skip != 'true'
|
||||
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
|
||||
- name: Verify checksums
|
||||
if: steps.current.outputs.skip != 'true'
|
||||
working-directory: dev
|
||||
run: |
|
||||
sha256sum -c ./*.zip.sha256
|
||||
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
|
||||
- name: Replace the dev-build pre-release
|
||||
if: steps.current.outputs.skip != 'true'
|
||||
working-directory: dev
|
||||
run: |
|
||||
if gh release view dev-build >/dev/null 2>&1; then
|
||||
gh release delete dev-build --cleanup-tag --yes
|
||||
fi
|
||||
cat > notes.md <<EOF
|
||||
Automatic development build of master at $SOURCE_SHA.
|
||||
|
||||
It may contain unfinished features. Installed copies receive it only with
|
||||
**Tools > Options > Include development builds**; full releases are unaffected.
|
||||
EOF
|
||||
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
|
||||
--target "$SOURCE_SHA" --prerelease --latest=false \
|
||||
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
|
||||
- name: Read back the pre-release
|
||||
if: steps.current.outputs.skip != 'true'
|
||||
run: |
|
||||
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
|
||||
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
|
||||
grep -qx 'prerelease=true draft=false assets=8' readback.txt
|
||||
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
# mini dump in TestResults, instead of holding the runner until the
|
||||
# job timeout.
|
||||
run: |
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||
}
|
||||
@@ -96,6 +96,11 @@ jobs:
|
||||
path: |
|
||||
artifacts/*.zip
|
||||
artifacts/*.sha256
|
||||
artifacts/velopack/OpenNest-win-Setup.exe
|
||||
artifacts/velopack/OpenNest-win-Portable.zip
|
||||
artifacts/velopack/*-full.nupkg
|
||||
artifacts/velopack/releases.win.json
|
||||
artifacts/velopack/RELEASES
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
compression-level: 0
|
||||
|
||||
@@ -22,13 +22,15 @@ dotnet build OpenNest.sln
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
|
||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
|
||||
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
|
||||
dotnet test OpenNest.Desktop.Tests/OpenNest.Desktop.Tests.csproj -c Release
|
||||
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
|
||||
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
|
||||
|
||||
# Windows runtime tests
|
||||
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.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`.
|
||||
Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); 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.
|
||||
|
||||
@@ -38,8 +40,9 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
||||
- `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; 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` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
||||
- `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.
|
||||
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
||||
- `OpenNest.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
|
||||
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
||||
|
||||
## Geometry and ownership safeguards
|
||||
|
||||
@@ -51,6 +54,7 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
||||
- 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.
|
||||
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
|
||||
- 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.
|
||||
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
|
||||
- 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.
|
||||
|
||||
@@ -70,6 +74,6 @@ Read the relevant contract before changing its behavior:
|
||||
- [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`.
|
||||
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
|
||||
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
|
||||
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
|
||||
@@ -11,6 +11,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
||||
|
||||
@@ -70,9 +70,11 @@ internal enum SupportEntries
|
||||
/// <summary>
|
||||
/// One automatic start candidate: the owned contour choice plus its preference kind and a
|
||||
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
|
||||
/// owner; nothing here exposes or mutates the underlying shape.
|
||||
/// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
|
||||
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
|
||||
/// ranking can count it on both of that corner's sides.
|
||||
/// </summary>
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
||||
|
||||
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
|
||||
if (target != null)
|
||||
{
|
||||
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
|
||||
// sides within tolerance); count how many of them are facing sides.
|
||||
// sides within tolerance, a fillet's joint within its reach of the corner it
|
||||
// rounds); count how many of them are facing sides.
|
||||
var left = p.X - minX;
|
||||
var right = maxX - p.X;
|
||||
var bottom = p.Y - minY;
|
||||
var top = maxY - p.Y;
|
||||
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
||||
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
|
||||
if (facingRight && right <= near) facing++;
|
||||
if (!facingRight && left <= near) facing++;
|
||||
if (facingTop && top <= near) facing++;
|
||||
if (!facingTop && bottom <= near) facing++;
|
||||
}
|
||||
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
||||
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
||||
|
||||
@@ -41,8 +41,10 @@ public static class ExecutionMotionReader
|
||||
var pos = frame;
|
||||
var first = true;
|
||||
var countBefore = moves.Count;
|
||||
foreach (var code in current.Codes)
|
||||
var raised = HeadRaise.RaisedMask(current.Codes);
|
||||
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
|
||||
{
|
||||
var code = current.Codes[codeIndex];
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||
@@ -106,7 +108,8 @@ public static class ExecutionMotionReader
|
||||
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
|
||||
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
||||
motion is ArcMove { Rotation: RotationType.CW });
|
||||
moves.Add(new(start, end, rapid, layer, curve));
|
||||
moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex],
|
||||
ReferenceEquals(current, program) ? codeIndex : null));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
|
||||
@@ -38,20 +38,31 @@ public sealed class OwnedExecution
|
||||
public sealed class ExecutionMotion
|
||||
{
|
||||
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
|
||||
PostVerificationGeometry.Curve curve)
|
||||
PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
Rapid = rapid;
|
||||
Layer = layer;
|
||||
Curve = curve;
|
||||
HeadRaised = headRaised;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public Vector? Start { get; }
|
||||
public Vector End { get; }
|
||||
public bool Rapid { get; }
|
||||
public LayerType Layer { get; }
|
||||
|
||||
/// <summary>A rapid made with the head raised over parts already cut (<see cref="HeadRaise"/>).</summary>
|
||||
public bool HeadRaised { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The index of the code this motion runs in the read program's own codes; null for a motion
|
||||
/// inside a sub-program and for the rapid a program that opens with a cut is given.
|
||||
/// </summary>
|
||||
public int? Code { get; }
|
||||
public double Length => Curve?.Length ?? 0;
|
||||
internal PostVerificationGeometry.Curve Curve { get; }
|
||||
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
|
||||
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code);
|
||||
}
|
||||
@@ -602,7 +602,7 @@ public sealed class PreparedContours
|
||||
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
|
||||
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
||||
=> CataloguePoints(shape, token)
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
@@ -763,12 +763,14 @@ public sealed class PreparedContours
|
||||
/// <summary>
|
||||
/// The preferred catalogue points for one contour: for each entity, its convex corner
|
||||
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
|
||||
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
|
||||
/// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
|
||||
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
|
||||
/// nothing; a single whole circle yields no points at all.
|
||||
/// </summary>
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CataloguePoints(
|
||||
Shape shape, CancellationToken token)
|
||||
{
|
||||
var found = new List<(int, Vector, AutomaticEntryKind)>();
|
||||
var found = new List<(int, Vector, AutomaticEntryKind, double)>();
|
||||
var count = shape.Entities.Count;
|
||||
if (count == 1 && shape.Entities[0] is Circle)
|
||||
{
|
||||
@@ -792,29 +794,65 @@ public sealed class PreparedContours
|
||||
switch (corner.Kind)
|
||||
{
|
||||
case ContourCuttingStrategy.CornerKind.Convex:
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
|
||||
break;
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Line && Next(i) is Arc:
|
||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||
// order classifies the same joint from the arc, matched below.
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
found.Add(Joint(i, vertex, (i + 1) % count));
|
||||
break;
|
||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||
// of a plain straight edge are not preferred automatic starts here.
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Arc && Next(i) is Line:
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
found.Add(Joint(i, vertex, i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Line line)
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
|
||||
}
|
||||
return found;
|
||||
|
||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
||||
|
||||
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
|
||||
FilletReach(shape, arcOrdinal) is { } reach
|
||||
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
|
||||
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
|
||||
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
|
||||
/// than a half circle the convex way, and reaching back along each edge no further than the
|
||||
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
|
||||
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
|
||||
/// </summary>
|
||||
private static double? FilletReach(Shape shape, int arcOrdinal)
|
||||
{
|
||||
var count = shape.Entities.Count;
|
||||
if (shape.Entities[arcOrdinal] is not Arc arc
|
||||
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|
||||
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
|
||||
return null;
|
||||
var sweep = arc.SweepAngle();
|
||||
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
|
||||
return null;
|
||||
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
|
||||
return null;
|
||||
var winding = ContourCuttingStrategy.DetermineWinding(shape);
|
||||
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
|
||||
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
|
||||
return null;
|
||||
var reach = arc.Radius * System.Math.Tan(sweep / 2);
|
||||
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
|
||||
|
||||
bool IsSmooth(Vector vertex, Entity entity) =>
|
||||
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
|
||||
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
|
||||
}
|
||||
|
||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||
|
||||
@@ -54,9 +54,11 @@ public sealed class ReleasedContourState
|
||||
/// 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.
|
||||
/// state advances exactly as it would without ownership. Crossings made by a rapid with the
|
||||
/// head raised are returned apart as well, as Raised: they are the raise's reason, not a fault.
|
||||
/// </summary>
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
|
||||
IReadOnlyList<PostVerificationFinding> Raised) Check(
|
||||
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
|
||||
CancellationToken token)
|
||||
{
|
||||
@@ -67,8 +69,10 @@ public sealed class ReleasedContourState
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
var fixedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
||||
var raisedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
|
||||
raisedFindings: raisedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,7 +101,7 @@ public sealed class ReleasedContourState
|
||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
||||
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
||||
bool proveOpenCuts = false, int contoursBefore = 0)
|
||||
bool proveOpenCuts = false, int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
|
||||
{
|
||||
var current = new CutGroup();
|
||||
var unfinished = new List<CutGroup>();
|
||||
@@ -166,16 +170,22 @@ public sealed class ReleasedContourState
|
||||
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
||||
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
||||
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
||||
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||
$"of part {obstacle.Part}.")
|
||||
{
|
||||
Location = (start + move.End) * 0.5,
|
||||
RapidStart = start,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||
}, owned);
|
||||
if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||
continue;
|
||||
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
|
||||
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
|
||||
+ $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}.")
|
||||
{
|
||||
Location = (start + move.End) * 0.5,
|
||||
RapidStart = start,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||
};
|
||||
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
|
||||
if (move.HeadRaised)
|
||||
raisedFindings?.Add(crossing);
|
||||
else
|
||||
Add(crossing, owned);
|
||||
}
|
||||
}
|
||||
else if (move.Layer == LayerType.Leadin)
|
||||
|
||||
@@ -867,10 +867,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
|
||||
/// not a corner.
|
||||
/// </summary>
|
||||
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
|
||||
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
|
||||
ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
|
||||
|
||||
/// <summary>
|
||||
/// The corner a contour of <paramref name="winding"/> would make turning from travel
|
||||
/// direction <paramref name="tangentIn"/> to <paramref name="tangentOut"/>, classified
|
||||
/// exactly as a shared vertex is (see <see cref="TryClassifyAutomaticStartCorner"/>).
|
||||
/// </summary>
|
||||
internal static CornerKind ClassifyTurn(Vector tangentIn, Vector tangentOut, RotationType winding)
|
||||
{
|
||||
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
|
||||
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
|
||||
var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
|
||||
var dot = tangentIn.DotProduct(tangentOut);
|
||||
var turn = winding == RotationType.CCW ? cross : -cross;
|
||||
|
||||
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// A rapid the head must make raised because it passes over parts already cut, which may have
|
||||
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
|
||||
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>. Rapid checks accept
|
||||
/// a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
|
||||
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
|
||||
/// See docs/cutting-planner.md.
|
||||
/// </summary>
|
||||
public static class HeadRaise
|
||||
{
|
||||
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
|
||||
public const string Marker = "HEAD RAISE";
|
||||
|
||||
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
|
||||
|
||||
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
|
||||
public static bool IsMarked(Program program)
|
||||
{
|
||||
if (program?.Codes == null)
|
||||
return false;
|
||||
foreach (var code in program.Codes)
|
||||
if (IsMarker(code))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised. A
|
||||
/// marker must be followed directly by a rapid; anything else is a malformed program
|
||||
/// (<see cref="ArgumentException"/>), since a post could raise before the wrong move.
|
||||
/// </summary>
|
||||
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(codes);
|
||||
var mask = new bool[codes.Count];
|
||||
for (var i = 0; i < codes.Count; i++)
|
||||
{
|
||||
if (!IsMarker(codes[i]))
|
||||
continue;
|
||||
if (i + 1 >= codes.Count || codes[i + 1] is not RapidMove)
|
||||
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
|
||||
mask[i + 1] = true;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
|
||||
/// <paramref name="rapids"/>, which must be rapids not marked already.
|
||||
/// </summary>
|
||||
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(program);
|
||||
ArgumentNullException.ThrowIfNull(rapids);
|
||||
var marked = RaisedMask(program.Codes);
|
||||
var copy = (Program)program.Clone();
|
||||
for (var i = copy.Codes.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!rapids.Contains(i))
|
||||
continue;
|
||||
if (copy.Codes[i] is not RapidMove || marked[i])
|
||||
throw new ArgumentException("Only an unmarked rapid can be marked for a head raise.");
|
||||
copy.Codes.Insert(i, new Comment(Marker));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
|
||||
/// the post's head-raise code, and its markers must be well formed.
|
||||
/// </summary>
|
||||
public static void EnsurePostable(Nest nest, bool hasCode, string postName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
foreach (var plate in nest.Plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part?.Program == null || !IsMarked(part.Program))
|
||||
continue;
|
||||
var name = DisplayPerimeterTail.PartName(part);
|
||||
if (!hasCode)
|
||||
throw new InvalidOperationException(
|
||||
$"{name} has rapids that must raise the head over parts already cut, but the {postName} "
|
||||
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
|
||||
try
|
||||
{
|
||||
RaisedMask(part.Program.Codes);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ namespace OpenNest.Geometry
|
||||
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
|
||||
internal const double JointTolerance = 1e-6;
|
||||
|
||||
// Loops are recovered with ShapeBuilder's chaining tolerance: a source contour that
|
||||
// closes within it (PEP rounding leaves 1e-6 gaps) is a loop everywhere else, and an
|
||||
// unrecovered loop leaves every contact with the whole part unresolved and blocking.
|
||||
internal const double ChainTolerance = Tolerance.ChainTolerance;
|
||||
|
||||
private readonly List<Entity> entities;
|
||||
private readonly int[] loopOf;
|
||||
private readonly int[] previous;
|
||||
@@ -373,7 +378,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
|
||||
|
||||
private static double SignedArea(List<Entity> entities, int first, int last)
|
||||
{
|
||||
|
||||
@@ -79,6 +79,14 @@ public interface INestRepository
|
||||
Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("This repository does not allocate nest names.");
|
||||
|
||||
/// <summary>
|
||||
/// The ledger item codes on the plates of every open (Draft, Quote or To Be Cut) nest,
|
||||
/// except <paramref name="excludeNestId"/> when it is not empty.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("This repository does not list stock item codes.");
|
||||
|
||||
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
|
||||
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
|
||||
@@ -44,7 +44,8 @@ public enum LibraryInsertKind
|
||||
/// Decides how one library drawing revision enters a nest, before anything changes:
|
||||
/// a new exact copy carrying its library link, more quantity on the copy already there,
|
||||
/// or a refusal. Material, grade or thickness that differ from the nest still allow the
|
||||
/// add, but the drawing's cut pins are dropped (D3). Different units, a different revision
|
||||
/// add, but the drawing's cut pins are dropped (D3); a blank material or grade, or a zero
|
||||
/// thickness, on the record suits any nest and is never a difference. Different units, a different revision
|
||||
/// of the same drawing, or another drawing with the same name refuse it.
|
||||
/// </summary>
|
||||
public sealed class LibraryDrawingInsert
|
||||
@@ -75,7 +76,7 @@ public sealed class LibraryDrawingInsert
|
||||
|
||||
/// <summary>
|
||||
/// For a new copy, true when the library drawing's cut pins come with it: the library
|
||||
/// material, grade and thickness match the nest's. Otherwise the copy has no pins. An
|
||||
/// material, grade and thickness match the nest's or are unset. Otherwise the copy has no pins. An
|
||||
/// added quantity leaves the existing copy as it is.
|
||||
/// </summary>
|
||||
public bool KeepPins { get; }
|
||||
@@ -140,7 +141,12 @@ public sealed class LibraryDrawingInsert
|
||||
var drawing = package.Drawing;
|
||||
drawing.Name = record.Name;
|
||||
drawing.Customer = record.Customer;
|
||||
drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
|
||||
// A field the record leaves unset means any material, so the copy takes the nest's.
|
||||
var nestMaterial = nest.Material ?? new Material();
|
||||
drawing.Material = new Material(
|
||||
IsSet(record.MaterialName) ? record.MaterialName : nestMaterial.Name,
|
||||
IsSet(record.MaterialGrade) ? record.MaterialGrade : nestMaterial.Grade,
|
||||
drawing.Material?.Density ?? 0);
|
||||
(record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
|
||||
drawing.Quantity.Required = quantity;
|
||||
drawing.Library = link;
|
||||
@@ -177,21 +183,24 @@ public sealed class LibraryDrawingInsert
|
||||
private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
|
||||
new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty<string>(), reason);
|
||||
|
||||
// A blank material or grade, or a zero thickness, on the record suits any nest.
|
||||
private static List<string> Differences(Nest nest, DrawingLibraryRecord record)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
var material = nest.Material ?? new Material();
|
||||
if (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
|
||||
if (IsSet(record.MaterialName) && SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
|
||||
differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})");
|
||||
if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
|
||||
if (IsSet(record.MaterialGrade) && SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
|
||||
differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})");
|
||||
var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
|
||||
var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
|
||||
if (thickness != nestThickness)
|
||||
if (thickness != 0 && thickness != nestThickness)
|
||||
differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
|
||||
return differences;
|
||||
}
|
||||
|
||||
private static bool IsSet(string? value) => SharedListNames.Key(value).Length > 0;
|
||||
|
||||
private static string Describe(DrawingLibraryRecord record) =>
|
||||
string.IsNullOrWhiteSpace(record.Customer)
|
||||
? $"library drawing '{record.Name}' revision {record.Revision}"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace OpenNest.Data.MaterialLedger;
|
||||
|
||||
/// <summary>A plate's item code that is no longer the nest's to use, and why.</summary>
|
||||
/// <param name="PlateNumber">The plate's 1-based position in the nest.</param>
|
||||
public sealed record LedgerCodeConflict(int PlateNumber, string ItemCode, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// The save and post re-check of a nest's <see cref="Plate.StockItemCodes"/>: finds codes the
|
||||
/// ledger no longer has free for this nest or that other open nests are coded with, and takes
|
||||
/// them off the plates when the programmer chooses to. Pure; the caller fetches the inputs.
|
||||
/// </summary>
|
||||
public static class LedgerCodeCheck
|
||||
{
|
||||
/// <summary>True when any plate has a coded sheet.</summary>
|
||||
public static bool HasCodes(IEnumerable<Plate> plates) =>
|
||||
(plates ?? throw new ArgumentNullException(nameof(plates))).Any(plate => plate.StockItemCodes.Count > 0);
|
||||
|
||||
/// <param name="plates">The nest's plates in order.</param>
|
||||
/// <param name="freeItems">The ledger's free stock for the nest's material, after every hold.</param>
|
||||
/// <param name="ownHolds">
|
||||
/// The ledger's holds for this nest: an open one keeps its sheet for this nest, so the
|
||||
/// sheet counts as available here although the ledger no longer lists it as free.
|
||||
/// </param>
|
||||
/// <param name="inUse">Other open nests' codes; an unknown answer checks the ledger alone.</param>
|
||||
/// <returns>One conflict per plate and code, in plate order then the plate's code order.</returns>
|
||||
public static IReadOnlyList<LedgerCodeConflict> Find(IList<Plate> plates, IEnumerable<LedgerStockItem> freeItems,
|
||||
IEnumerable<LedgerHold>? ownHolds, LedgerCodesInUse inUse)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
ArgumentNullException.ThrowIfNull(freeItems);
|
||||
ArgumentNullException.ThrowIfNull(inUse);
|
||||
|
||||
var available = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var item in freeItems.Where(item => item != null && item.Quantity > 0))
|
||||
available[item.ItemCode] = available.GetValueOrDefault(item.ItemCode) + item.Quantity;
|
||||
foreach (var hold in (ownHolds ?? Array.Empty<LedgerHold>()).Where(hold => hold != null && LedgerHoldStatuses.IsOpen(hold.Status)))
|
||||
available[hold.ItemCode] = available.GetValueOrDefault(hold.ItemCode) + 1;
|
||||
|
||||
// A bundle's code repeats once per sheet across every plate it is used on.
|
||||
var needed = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var code in plates.SelectMany(plate => plate.StockItemCodes))
|
||||
needed[code] = needed.GetValueOrDefault(code) + 1;
|
||||
|
||||
var reasons = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (code, count) in needed)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var free = available.GetValueOrDefault(code);
|
||||
if (free == 0)
|
||||
parts.Add("not free in MaterialLedger");
|
||||
else if (free < count)
|
||||
parts.Add($"only {free} of the {count} sheets needed free in MaterialLedger");
|
||||
var nests = inUse.NestsUsing(code);
|
||||
if (nests.Count > 0)
|
||||
parts.Add((nests.Count == 1 ? "on nest " : "on nests ") + string.Join(", ", nests));
|
||||
if (parts.Count > 0)
|
||||
reasons[code] = Capitalize(string.Join("; ", parts));
|
||||
}
|
||||
|
||||
var conflicts = new List<LedgerCodeConflict>();
|
||||
for (var i = 0; i < plates.Count; i++)
|
||||
{
|
||||
foreach (var code in plates[i].StockItemCodes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (reasons.TryGetValue(code, out var reason))
|
||||
conflicts.Add(new LedgerCodeConflict(i + 1, code, reason));
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes every copy of <paramref name="codes"/> off the plates, keeping the layout and the
|
||||
/// other codes in order; the sheets left uncoded are matched by size in ML. Returns how
|
||||
/// many sheets lost their code.
|
||||
/// </summary>
|
||||
public static int Remove(IEnumerable<Plate> plates, IEnumerable<string> codes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
ArgumentNullException.ThrowIfNull(codes);
|
||||
var remove = codes.ToHashSet(StringComparer.Ordinal);
|
||||
var removed = 0;
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
var kept = plate.StockItemCodes.Where(code => !remove.Contains(code)).ToList();
|
||||
if (kept.Count == plate.StockItemCodes.Count)
|
||||
continue;
|
||||
removed += plate.StockItemCodes.Count - kept.Count;
|
||||
plate.StockItemCodes = kept;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private static string Capitalize(string text) => text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text[1..];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace OpenNest.Data.MaterialLedger;
|
||||
|
||||
/// <summary>
|
||||
/// Takes item codes already on other open nests out of ledger stock rows, so two nests are
|
||||
/// not planned on the same sheet before ML holds exist for either. Pure: rows are rebuilt,
|
||||
/// never changed.
|
||||
/// </summary>
|
||||
public static class LedgerCodeFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// <paramref name="rows"/> without <paramref name="codesInUse"/>: every copy of a code
|
||||
/// leaves the row's <see cref="LedgerStockRow.ItemCodes"/> and
|
||||
/// <see cref="LedgerStockRow.ItemLocations"/>, its quantity drops by the copies removed, and
|
||||
/// a row left with none is dropped. Row ids and order are kept. <paramref name="removed"/>
|
||||
/// counts the sheets and drops taken out.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerStockRow> Exclude(IEnumerable<LedgerStockRow> rows,
|
||||
IEnumerable<string> codesInUse, out int removed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
ArgumentNullException.ThrowIfNull(codesInUse);
|
||||
var exclude = codesInUse as IReadOnlySet<string> ?? codesInUse.ToHashSet(StringComparer.Ordinal);
|
||||
removed = 0;
|
||||
var kept = new List<LedgerStockRow>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row == null)
|
||||
continue;
|
||||
var codes = row.ItemCodes.Where(code => !exclude.Contains(code)).ToList();
|
||||
var dropped = row.ItemCodes.Count - codes.Count;
|
||||
if (dropped == 0)
|
||||
{
|
||||
kept.Add(row);
|
||||
continue;
|
||||
}
|
||||
removed += dropped;
|
||||
if (codes.Count == 0)
|
||||
continue;
|
||||
|
||||
var itemLocations = row.ItemLocations
|
||||
.Where(pair => !exclude.Contains(pair.Key))
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
// A location stays listed while an item left in the row is there, or when the row
|
||||
// never said which item it held.
|
||||
var locations = row.Locations
|
||||
.Where(location => itemLocations.ContainsValue(location)
|
||||
|| !row.ItemLocations.Values.Contains(location, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
kept.Add(row with
|
||||
{
|
||||
Quantity = codes.Count,
|
||||
ItemCodes = codes,
|
||||
Locations = locations,
|
||||
ItemLocations = itemLocations,
|
||||
});
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="mapping"/> with <paramref name="codesInUse"/> taken out of its rows and
|
||||
/// the sheets removed counted in <see cref="LedgerStockMapping.OnOtherNests"/>.
|
||||
/// </summary>
|
||||
public static LedgerStockMapping Exclude(LedgerStockMapping mapping, IEnumerable<string> codesInUse)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mapping);
|
||||
var rows = Exclude(mapping.Rows, codesInUse, out var removed);
|
||||
return mapping with { Rows = rows, OnOtherNests = mapping.OnOtherNests + removed };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net.Http;
|
||||
|
||||
namespace OpenNest.Data.MaterialLedger;
|
||||
|
||||
/// <summary>
|
||||
/// The ledger item codes other open server nests are coded with, or why they could not be
|
||||
/// read. ML's free counts drop only once a hold exists, so these codes are what another
|
||||
/// Auto Nest run must leave out until then. Reading them never blocks: a failure is a result.
|
||||
/// </summary>
|
||||
public sealed record LedgerCodesInUse
|
||||
{
|
||||
/// <summary>The nest server gets this long to answer, so a stock load or save never waits on it.</summary>
|
||||
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private LedgerCodesInUse(IReadOnlyList<NestStockCodeUse> uses, string? unavailable)
|
||||
{
|
||||
Uses = uses;
|
||||
Unavailable = unavailable;
|
||||
Codes = uses.Select(use => use.ItemCode).ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>One entry per code and nest; empty when <see cref="Unavailable"/>.</summary>
|
||||
public IReadOnlyList<NestStockCodeUse> Uses { get; }
|
||||
|
||||
/// <summary>The distinct codes of <see cref="Uses"/>.</summary>
|
||||
public IReadOnlySet<string> Codes { get; }
|
||||
|
||||
/// <summary>Why the codes could not be read; null when they were.</summary>
|
||||
public string? Unavailable { get; }
|
||||
|
||||
public bool IsKnown => Unavailable == null;
|
||||
|
||||
public static LedgerCodesInUse Known(IEnumerable<NestStockCodeUse> uses) =>
|
||||
new((uses ?? throw new ArgumentNullException(nameof(uses))).Where(use => use != null).ToList(), null);
|
||||
|
||||
public static LedgerCodesInUse Unknown(string reason) =>
|
||||
new(Array.Empty<NestStockCodeUse>(), string.IsNullOrWhiteSpace(reason) ? "not available" : reason);
|
||||
|
||||
/// <summary>The names of the other nests using <paramref name="code"/>, in name order.</summary>
|
||||
public IReadOnlyList<string> NestsUsing(string code) =>
|
||||
Uses.Where(use => string.Equals(use.ItemCode, code, StringComparison.Ordinal))
|
||||
.Select(use => use.NestName)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Reads the codes from <paramref name="repository"/>, leaving out the nest
|
||||
/// <paramref name="excludeNestId"/> (the one being edited). A null repository is File
|
||||
/// mode. Only the caller's own cancellation throws.
|
||||
/// </summary>
|
||||
public static async Task<LedgerCodesInUse> ReadAsync(INestRepository? repository, Guid excludeNestId,
|
||||
CancellationToken cancellationToken, TimeSpan? timeout = null)
|
||||
{
|
||||
if (repository == null)
|
||||
return Unknown("nests are saved as files");
|
||||
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout ?? RequestTimeout);
|
||||
try
|
||||
{
|
||||
return Known(await repository.GetStockCodesInUseAsync(excludeNestId, timeoutSource.Token));
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Unknown("the nest server did not answer");
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return Unknown("the nest server needs an update");
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or System.Text.Json.JsonException
|
||||
or ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return Unknown("the nest server could not be reached");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,12 @@ public sealed record LedgerStockMapping(
|
||||
public int Drops => Rows.Where(r => r.IsDrop).Sum(r => r.Quantity);
|
||||
public int SubstituteRows => Rows.Count(r => r.IsSubstitute);
|
||||
|
||||
/// <summary>E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)".</summary>
|
||||
/// <summary>Sheets and drops left out because other open nests are coded with them (<see cref="LedgerCodeFilter"/>).</summary>
|
||||
public int OnOtherNests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (3 on other nests) (2 skipped: 2 unknown shape)".
|
||||
/// </summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
@@ -64,6 +69,8 @@ public sealed record LedgerStockMapping(
|
||||
var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}";
|
||||
if (SubstituteRows > 0)
|
||||
text += $", {Plural(SubstituteRows, "substitute row")}";
|
||||
if (OnOtherNests > 0)
|
||||
text += $" ({OnOtherNests} on other nests)";
|
||||
return skipped.Count == 0
|
||||
? text
|
||||
: $"{text} ({SkippedUnknownShape + SkippedTooSmall} skipped: {string.Join(", ", skipped)})";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// A ledger item code an open server nest's plates are coded with, returned by
|
||||
/// <c>GET /api/nests/stock-item-codes</c>. One entry per code and nest; a bundle code used
|
||||
/// for several sheets appears once.
|
||||
/// </summary>
|
||||
public sealed class NestStockCodeUse
|
||||
{
|
||||
public string ItemCode { get; init; } = "";
|
||||
|
||||
public Guid NestId { get; init; }
|
||||
|
||||
public string NestName { get; init; } = "";
|
||||
|
||||
/// <summary>Draft, Quote or To Be Cut: the statuses whose sheets are still to be cut.</summary>
|
||||
public NestStatus Status { get; init; }
|
||||
}
|
||||
@@ -151,6 +151,19 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
return allocation.Number;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = "api/nests/stock-item-codes" + (excludeNestId == Guid.Empty ? "" : "?exclude=" + excludeNestId.ToString("D"));
|
||||
using var response = await _httpClient.GetAsync(Url(query), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
throw new NotSupportedException("This nest server does not list stock item codes (HTTP 404). Update the nest server.");
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "list stock item codes", cancellationToken);
|
||||
var uses = await response.Content.ReadFromJsonAsync<List<NestStockCodeUse>>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
return uses?.Where(use => use != null && !string.IsNullOrWhiteSpace(use.ItemCode)).ToList()
|
||||
?? new List<NestStockCodeUse>();
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace OpenNest.Desktop.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// OpenNest.Desktop is shared by the WinForms and Avalonia front ends, so it must stay free of
|
||||
/// any UI toolkit: no WinForms/WindowsDesktop framework, System.Drawing.Common or Avalonia.
|
||||
/// </summary>
|
||||
public class DesktopDependencyTests
|
||||
{
|
||||
private static readonly string[] ForbiddenPrefixes =
|
||||
[
|
||||
"System.Windows.Forms",
|
||||
"Microsoft.WindowsDesktop",
|
||||
"WindowsBase",
|
||||
"PresentationCore",
|
||||
"PresentationFramework",
|
||||
"System.Drawing.Common",
|
||||
"Avalonia",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void DesktopAssembly_ReferencesNoUiToolkit()
|
||||
{
|
||||
var assembly = Assembly.Load(new AssemblyName("OpenNest.Desktop"));
|
||||
var references = assembly.GetReferencedAssemblies().Select(a => a.Name!);
|
||||
|
||||
Assert.Empty(references.Where(IsForbidden));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesktopProject_TargetsPlainNet8WithoutUiFrameworks()
|
||||
{
|
||||
var project = XDocument.Load(Path.Combine(DesktopProjectDirectory(), "OpenNest.Desktop.csproj"));
|
||||
var elements = project.Descendants().ToList();
|
||||
|
||||
var targets = elements
|
||||
.Where(e => e.Name.LocalName is "TargetFramework" or "TargetFrameworks")
|
||||
.SelectMany(e => e.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.ToList();
|
||||
Assert.Equal(["net8.0"], targets);
|
||||
|
||||
Assert.DoesNotContain(elements, e =>
|
||||
e.Name.LocalName is "UseWindowsForms" or "UseWPF" or "UseWindowsDesktop"
|
||||
&& e.Value.Trim().Equals("true", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(elements, e => e.Name.LocalName == "FrameworkReference");
|
||||
|
||||
var packages = elements
|
||||
.Where(e => e.Name.LocalName == "PackageReference")
|
||||
.Select(e => (string?)e.Attribute("Include") ?? string.Empty);
|
||||
Assert.Empty(packages.Where(IsForbidden));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesktopProject_RestoresNoUiToolkitPackagesTransitively()
|
||||
{
|
||||
var assetsPath = Path.Combine(DesktopProjectDirectory(), "obj", "project.assets.json");
|
||||
Assert.True(File.Exists(assetsPath), $"Restore output not found: {assetsPath}");
|
||||
|
||||
using var assets = JsonDocument.Parse(File.ReadAllText(assetsPath));
|
||||
var libraries = assets.RootElement.GetProperty("libraries").EnumerateObject()
|
||||
.Select(library => library.Name.Split('/')[0]);
|
||||
|
||||
Assert.Empty(libraries.Where(IsForbidden));
|
||||
}
|
||||
|
||||
private static bool IsForbidden(string name) =>
|
||||
ForbiddenPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static string DesktopProjectDirectory()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "OpenNest.sln")))
|
||||
directory = directory.Parent;
|
||||
|
||||
Assert.NotNull(directory);
|
||||
return Path.Combine(directory.FullName, "OpenNest.Desktop");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../OpenNest.Desktop/OpenNest.Desktop.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Toolkit-neutral desktop logic shared by the WinForms and Avalonia front ends.
|
||||
No UI toolkit: OpenNest.Desktop.Tests guards against WinForms, WindowsDesktop,
|
||||
System.Drawing.Common and Avalonia references. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Desktop</RootNamespace>
|
||||
<AssemblyName>OpenNest.Desktop</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,334 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Cutouts;
|
||||
using OpenNest.Engine.Tests.NestingEngines;
|
||||
using OpenNest.Geometry;
|
||||
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||
|
||||
/// <summary>The frontier packer as a cutout proposal: inserts settle against the cutout wall
|
||||
/// and each other instead of landing at the first sampled point that clears.</summary>
|
||||
[Collection("Cutout pipeline registry")]
|
||||
public class CutoutPackerProposalTests
|
||||
{
|
||||
private const double Spacing = 0.3;
|
||||
|
||||
/// <summary>A 13.148 x 1.25 bar with a shallow notch in one long edge and eight bolt holes.</summary>
|
||||
private const string BarCode = """
|
||||
G91
|
||||
G00X0Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X0.5Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X1.875Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X0.5Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X4.625Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X0.5Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X1.875Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X0.5Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X-11.8865Y-1.0625
|
||||
G01X0Y0.75
|
||||
G02X0.5Y0.5I0.5J0
|
||||
G01X3.824Y0
|
||||
G02X0.25Y-0.25I0J-0.25
|
||||
G01X0Y-0.125
|
||||
G03X0.25Y-0.25I0.25J-0
|
||||
G01X3.5Y0
|
||||
G03X0.25Y0.25I0J0.25
|
||||
G01X0Y0.125
|
||||
G02X0.25Y0.25I0.25J0
|
||||
G01X3.824Y0
|
||||
G02X0.5Y-0.5I0J-0.5
|
||||
G01X0Y-0.75
|
||||
G01X-13.148Y0
|
||||
""";
|
||||
|
||||
/// <summary>One 12.25-wide window whose near end carries a notched tab, open to the given
|
||||
/// wall height above the tab; the frame below stacks four such windows.</summary>
|
||||
private static string Window(double wallHeight) => $"""
|
||||
G01X0.125Y0
|
||||
G01X0Y0.841458
|
||||
G02X0.5Y0.5I0.5J0
|
||||
G01X0Y-0.3125
|
||||
G01X1.125Y0
|
||||
G01X0Y0.3125
|
||||
G01X1.25Y0
|
||||
G01X0Y-0.3125
|
||||
G01X1.125Y0
|
||||
G01X0Y0.3125
|
||||
G01X4Y0
|
||||
G01X0Y-0.3125
|
||||
G01X1.125Y0
|
||||
G01X0Y0.3125
|
||||
G01X1.25Y0
|
||||
G01X0Y-0.3125
|
||||
G01X1.125Y0
|
||||
G01X0Y0.3125
|
||||
G02X0.5Y-0.5I0J-0.5
|
||||
G01X0Y-0.841458
|
||||
G01X0.125Y0
|
||||
G01X0Y{wallHeight.ToString(CultureInfo.InvariantCulture)}
|
||||
G03X-0.125Y0.125I-0.125J0
|
||||
G01X-12Y0
|
||||
G03X-0.125Y-0.125I0J-0.125
|
||||
G01X0Y-{wallHeight.ToString(CultureInfo.InvariantCulture)}
|
||||
""";
|
||||
|
||||
/// <summary>A 14.82 x 69.72 frame with a plain 12.25 x 13.4 window and three tabbed windows
|
||||
/// of rising height; only the last is tall enough to stand the bar in.</summary>
|
||||
private static readonly string FrameCode = $"""
|
||||
G91
|
||||
G00X0Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X0.5Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X1.875Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X0.5Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X-3.9375Y2.033896
|
||||
G01X12.25Y0
|
||||
G01X0Y13.297
|
||||
G03X-0.125Y0.125I-0.125J0
|
||||
G01X-12Y0
|
||||
G03X-0.125Y-0.125I0J-0.125
|
||||
G01X0Y-13.297
|
||||
G00X8.5625Y-2.033896
|
||||
G03X0Y0I-0.125J0
|
||||
G00X0.5Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X1.875Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X0.5Y0
|
||||
G03X0Y0I-0.125J0
|
||||
G00X-11.4375Y16.955896
|
||||
{Window(14.375)}
|
||||
G00X0Y16
|
||||
{Window(14.375)}
|
||||
G00X0Y16
|
||||
{Window(19.453)}
|
||||
G00X-1.283896Y-47.797
|
||||
G01X0Y68.25
|
||||
G02X0.125Y0.125I0.125J0
|
||||
G01X14.567791Y0
|
||||
G02X0.125Y-0.125I0J-0.125
|
||||
G01X0Y-68.25
|
||||
G02X-0.125Y-0.125I-0.125J0
|
||||
G01X-0.2615Y0
|
||||
G02X-0.125Y0.125I0J0.125
|
||||
G01X0Y0.051
|
||||
G01X-0.323396Y0
|
||||
G01X0Y-0.897396
|
||||
G02X-0.5Y-0.5I-0.5J-0
|
||||
G01X-12.148Y0
|
||||
G02X-0.5Y0.5I0J0.5
|
||||
G01X0Y0.897396
|
||||
G01X-0.323396Y0
|
||||
G01X0Y-0.051
|
||||
G02X-0.125Y-0.125I-0.125J0
|
||||
G01X-0.2615Y0
|
||||
G02X-0.125Y0.125I0J0.125
|
||||
""";
|
||||
|
||||
/// <summary>Height of the tab on a tabbed window's near end.</summary>
|
||||
private const double TabHeight = 0.841458 + 0.5;
|
||||
|
||||
private static Program Bar() => Incremental(BarCode);
|
||||
private static Program Frame() => Incremental(FrameCode);
|
||||
|
||||
[Fact]
|
||||
public void FrameHasFourWindowsAndTheBarIsSolid()
|
||||
{
|
||||
var frame = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
|
||||
var bar = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
|
||||
Assert.Equal(4, frame.Cutouts.Count(c => System.Math.Abs(c.Area()) > 100));
|
||||
Assert.Equal(14.818, frame.Bounds.Length, 3);
|
||||
Assert.Equal(69.721, frame.Bounds.Width, 3);
|
||||
Assert.Equal(13.148, bar.Bounds.Length, 3);
|
||||
Assert.Equal(1.25, bar.Bounds.Width, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoBarsStackAgainstAWindowWallInsteadOfScatteringAcrossIt()
|
||||
{
|
||||
var frame = JobBuilder.Part("PT10", Frame(), 1);
|
||||
var bar = JobBuilder.Part("PT11", Bar(), 2);
|
||||
var geometry = JobPartGeometry.Read(frame.Geometry);
|
||||
var seated = new List<(int Hole, IReadOnlyList<NestJobPlacement> Poses)>();
|
||||
for (var hole = 0; hole < geometry.Cutouts.Count; hole++)
|
||||
{
|
||||
if (System.Math.Abs(geometry.Cutouts[hole].Area()) < 100)
|
||||
continue;
|
||||
var poses = CutoutRouter.Fill(frame, hole, new[] { bar }, Spacing);
|
||||
if (poses.Count == 2)
|
||||
seated.Add((hole, poses));
|
||||
}
|
||||
var (window, stacked) = Assert.Single(seated);
|
||||
AssertClear(frame, bar, stacked, Spacing);
|
||||
AssertStacked(geometry.Cutouts[window], JobPartGeometry.Read(bar.Geometry), stacked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackerAloneSeatsBothBarsAgainstTheWall()
|
||||
{
|
||||
var frame = JobBuilder.Part("PT10", Frame(), 1);
|
||||
var bar = JobBuilder.Part("PT11", Bar(), 2);
|
||||
var geometry = JobPartGeometry.Read(frame.Geometry);
|
||||
var tallest = Enumerable.Range(0, geometry.Cutouts.Count)
|
||||
.OrderByDescending(i => System.Math.Abs(geometry.Cutouts[i].Area())).First();
|
||||
var poses = CutoutPackerProposal.Fill(frame, tallest, new[] { bar }, Spacing);
|
||||
Assert.Equal(2, poses.Count);
|
||||
Assert.Equal(new[] { 0, 1 }, poses.Select(p => p.InstanceIndex));
|
||||
AssertClear(frame, bar, poses, Spacing);
|
||||
AssertStacked(geometry.Cutouts[tallest], JobPartGeometry.Read(bar.Geometry), poses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackerKeepsClearOfInsertsAlreadySeated()
|
||||
{
|
||||
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||
var insert = JobBuilder.Rectangle("insert", 3, 2, 6);
|
||||
var geometry = JobPartGeometry.Read(insert.Geometry);
|
||||
var occupant = new NestJobPlacement("occupant", 0, -1.5, -1, 0);
|
||||
var poses = CutoutPackerProposal.Fill(frame, 0, new[] { insert }, 0.25,
|
||||
new[] { (geometry, occupant) }, null, default);
|
||||
Assert.NotEmpty(poses);
|
||||
foreach (var pose in poses)
|
||||
Assert.True(NestLayoutCheck.Clears(geometry, occupant, geometry, pose, 0.25), $"{pose} meets the occupant");
|
||||
AssertClear(frame, insert, poses, 0.25);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Irregular")]
|
||||
[InlineData("Default")]
|
||||
public void AutoNestSeatsBothBarsStackedInOneWindow(string engine)
|
||||
{
|
||||
var frameDrawing = new Drawing("PT10", Frame());
|
||||
var barDrawing = new Drawing("PT11", Bar());
|
||||
var request = new NestPipelineRequest(engine,
|
||||
new[] { new NestItem { Drawing = barDrawing, Quantity = 2 }, new NestItem { Drawing = frameDrawing, Quantity = 2 } },
|
||||
new[] { new NestPlateStock("sheet", new Size(60, 120), 1, Spacing) },
|
||||
new NestJobOptions(maxPlates: 1));
|
||||
var result = NestPipeline.Run(request);
|
||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
var plate = Assert.Single(result.Plates);
|
||||
var bars = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, barDrawing)).ToArray();
|
||||
var frames = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, frameDrawing)).ToArray();
|
||||
Assert.Equal(2, bars.Length);
|
||||
Assert.Equal(2, frames.Length);
|
||||
var frameGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
|
||||
var barGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
|
||||
// Both bars lie in one window of one frame; express them in that frame's coordinates.
|
||||
var seated = new List<(Part Frame, Shape Window, IReadOnlyList<NestJobPlacement> Poses)>();
|
||||
foreach (var host in frames)
|
||||
{
|
||||
var local = bars.Select(bar => Local(host, bar)).ToArray();
|
||||
foreach (var window in frameGeometry.Cutouts.Where(c => System.Math.Abs(c.Area()) > 100))
|
||||
if (local.All(pose => Inside(window, Extent(barGeometry, pose))))
|
||||
seated.Add((host, window, local));
|
||||
}
|
||||
var (_, hole, poses) = Assert.Single(seated);
|
||||
AssertStacked(hole, barGeometry, poses);
|
||||
}
|
||||
|
||||
private static NestJobPlacement Local(Part frame, Part part)
|
||||
{
|
||||
var dx = part.Location.X - frame.Location.X;
|
||||
var dy = part.Location.Y - frame.Location.Y;
|
||||
var cosine = System.Math.Cos(-frame.Rotation);
|
||||
var sine = System.Math.Sin(-frame.Rotation);
|
||||
return new NestJobPlacement("PT11", 0, dx * cosine - dy * sine, dx * sine + dy * cosine,
|
||||
part.Rotation - frame.Rotation);
|
||||
}
|
||||
|
||||
private static bool Inside(Shape window, Box box)
|
||||
{
|
||||
var bounds = window.BoundingBox;
|
||||
return box.Left > bounds.Left && box.Right < bounds.Right && box.Bottom > bounds.Bottom && box.Top < bounds.Top;
|
||||
}
|
||||
|
||||
/// <summary>Both bars stand in the window against one wall and against each other, not in
|
||||
/// the middle: each touches the wall or the other bar across the window, and the near tab
|
||||
/// or the far wall along it, within the spacing plus a small tolerance.</summary>
|
||||
private static void AssertStacked(Shape window, JobPartGeometry bar, IReadOnlyList<NestJobPlacement> poses)
|
||||
{
|
||||
const double slack = 0.05;
|
||||
var hole = window.BoundingBox;
|
||||
var boxes = poses.Select(p => Extent(bar, p)).ToArray();
|
||||
Assert.Equal(2, boxes.Length);
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
Assert.True(Inside(window, box), $"{box} is not inside the window {hole}");
|
||||
var other = boxes.Single(b => !ReferenceEquals(b, box));
|
||||
var toWall = System.Math.Min(box.Left - hole.Left, hole.Right - box.Right);
|
||||
var toOther = System.Math.Max(other.Left - box.Right, box.Left - other.Right);
|
||||
Assert.True(toWall <= Spacing + slack || toOther <= Spacing + slack,
|
||||
$"{box} floats {toWall:F3} from the walls and {toOther:F3} from the other bar");
|
||||
var toEnd = System.Math.Min(box.Bottom - hole.Bottom - TabHeight, hole.Top - box.Top);
|
||||
Assert.True(toEnd <= Spacing + slack, $"{box} floats {toEnd:F3} from the window ends");
|
||||
}
|
||||
var gap = System.Math.Max(boxes[1].Left - boxes[0].Right, boxes[0].Left - boxes[1].Right);
|
||||
Assert.InRange(gap, Spacing - 1e-6, Spacing + slack);
|
||||
var center = (boxes.Min(b => b.Left) + boxes.Max(b => b.Right)) / 2;
|
||||
Assert.True(System.Math.Abs(center - (hole.Left + hole.Right) / 2) > 2,
|
||||
"the pair sits in the middle of the window");
|
||||
}
|
||||
|
||||
private static Box Extent(JobPartGeometry geometry, NestJobPlacement pose)
|
||||
{
|
||||
var shape = (Shape)geometry.Perimeter.Clone();
|
||||
shape.Rotate(pose.Rotation);
|
||||
var box = shape.BoundingBox;
|
||||
return new Box(box.Left + pose.X, box.Bottom + pose.Y, box.Length, box.Width);
|
||||
}
|
||||
|
||||
private static void AssertClear(NestJobPart frame, NestJobPart insert, IReadOnlyList<NestJobPlacement> poses,
|
||||
double spacing)
|
||||
{
|
||||
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
|
||||
var geometry = JobPartGeometry.Read(insert.Geometry);
|
||||
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||
foreach (var pose in poses)
|
||||
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, spacing), $"{pose} meets the frame");
|
||||
for (var i = 0; i < poses.Count; i++)
|
||||
for (var j = i + 1; j < poses.Count; j++)
|
||||
Assert.True(NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing),
|
||||
$"{poses[i]} meets {poses[j]}");
|
||||
}
|
||||
|
||||
/// <summary>Incremental (G91) G-code: rapids, lines and arcs with centers relative to the start.</summary>
|
||||
private static Program Incremental(string code)
|
||||
{
|
||||
var program = new Program();
|
||||
double x = 0, y = 0;
|
||||
foreach (var line in code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var words = Regex.Matches(line, @"([A-Z])(-?\d*\.?\d+)")
|
||||
.ToDictionary(m => m.Groups[1].Value[0], m => double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture));
|
||||
var g = (int)words['G'];
|
||||
if (g == 91)
|
||||
continue;
|
||||
var end = new Vector(x + words.GetValueOrDefault('X'), y + words.GetValueOrDefault('Y'));
|
||||
var center = new Vector(x + words.GetValueOrDefault('I'), y + words.GetValueOrDefault('J'));
|
||||
program.Codes.Add(g switch
|
||||
{
|
||||
0 => new RapidMove(end.X, end.Y),
|
||||
1 => new LinearMove(end.X, end.Y),
|
||||
2 => new ArcMove(end, center, RotationType.CW),
|
||||
_ => new ArcMove(end, center, RotationType.CCW),
|
||||
});
|
||||
(x, y) = (end.X, end.Y);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||
|
||||
/// <summary>An insert with holes of its own goes into a frame's cutout when none of its holes
|
||||
/// fits any part of the job (bolt holes in a strap); otherwise it stays independent, since a
|
||||
/// later insert could land in its hole.</summary>
|
||||
[Collection("Cutout pipeline registry")]
|
||||
public class CutoutPipelineHoledInsertTests
|
||||
{
|
||||
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
|
||||
{
|
||||
public List<NestJob> Jobs { get; } = new();
|
||||
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
Jobs.Add(job);
|
||||
return solve(job);
|
||||
}
|
||||
}
|
||||
|
||||
// A 20 x 20 frame with a 12 x 14 window.
|
||||
private static Program Frame() => WithSquareHoles(TestShapes.Rectangle(20, 20), (4, 3, 12, 14));
|
||||
|
||||
// A 13 x 1.25 strap with two 0.25 bolt holes: it fits the window only upright.
|
||||
private static Program Strap()
|
||||
{
|
||||
var program = TestShapes.Rectangle(13, 1.25);
|
||||
foreach (var x in new[] { 1.0, 12.0 })
|
||||
{
|
||||
program.Codes.Add(new RapidMove(x + 0.125, 0.625));
|
||||
program.Codes.Add(new ArcMove(x - 0.125, 0.625, x, 0.625, RotationType.CCW));
|
||||
program.Codes.Add(new ArcMove(x + 0.125, 0.625, x, 0.625, RotationType.CCW));
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
private static Program WithSquareHoles(Program program, params (double X, double Y, double W, double H)[] holes)
|
||||
{
|
||||
foreach (var (x, y, w, h) in holes)
|
||||
{
|
||||
program.Codes.Add(new RapidMove(x, y));
|
||||
program.Codes.Add(new LinearMove(x + w, y));
|
||||
program.Codes.Add(new LinearMove(x + w, y + h));
|
||||
program.Codes.Add(new LinearMove(x, y + h));
|
||||
program.Codes.Add(new LinearMove(x, y));
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
private static NestPipelineRequest Request(string engine, params Program[] programs) =>
|
||||
new(engine,
|
||||
programs.Select((p, i) => new NestItem { Drawing = new Drawing($"d{i}", p), Quantity = 1 }).ToArray(),
|
||||
// Only the frame fits beside nothing else: the strap must go in its window.
|
||||
new[] { new NestPlateStock("sheet", new Size(20.5, 20.5), 1, 0.25) });
|
||||
|
||||
[Theory]
|
||||
[InlineData("Irregular")]
|
||||
[InlineData("Default")]
|
||||
public void StrapWithBoltHolesGoesInTheFrameWindow(string engine)
|
||||
{
|
||||
var request = Request(engine, Frame(), Strap());
|
||||
Assert.Equal(NestJobStatus.Incomplete, NestPipeline.RunWithoutCutouts(request).Status);
|
||||
|
||||
var result = NestPipeline.Run(request);
|
||||
|
||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(new[] { request.Items[1].Drawing, request.Items[0].Drawing },
|
||||
Assert.Single(result.Plates).Parts.Select(p => p.BaseDrawing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertWhoseHoleFitsAnotherPartStaysIndependent()
|
||||
{
|
||||
// The 10 x 10 part's 4 x 4 hole fits the 2 x 2 part, so it is never reserved in the
|
||||
// frame's window; the 2 x 2 part goes into that hole, the smaller frame, instead.
|
||||
var holed = WithSquareHoles(TestShapes.Rectangle(10, 10), (3, 3, 4, 4));
|
||||
var engine = new Stub(job => new NestJobResultBuilder(job).Build(NestJobStopReason.NoPlacementFound));
|
||||
NestPipeline.RunCutoutTrial(engine, "stub", Request("stub", Frame(), holed, TestShapes.Rectangle(2, 2)));
|
||||
|
||||
Assert.Equal(new[] { "part-0", "__cutout-proxy-part-1-0" },
|
||||
Assert.Single(engine.Jobs).Parts.Select(p => p.Id));
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,10 @@ public sealed class CutoutPipelineRegistryCollection { }
|
||||
[Collection("Cutout pipeline registry")]
|
||||
public class CutoutPipelinePreviewTests
|
||||
{
|
||||
private static NestPipelineRequest Request(string engine = "Irregular")
|
||||
/// <param name="hole">Hole diameter: ten holds two 3 x 3 inserts, six holds exactly one.</param>
|
||||
private static NestPipelineRequest Request(string engine = "Irregular", double hole = 10)
|
||||
{
|
||||
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
|
||||
var frame = new Drawing("frame", TestShapes.Ring(20, hole));
|
||||
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
|
||||
return new NestPipelineRequest(engine,
|
||||
new[] { new NestItem { Drawing = frame, Quantity = 1 },
|
||||
@@ -112,7 +113,8 @@ public class CutoutPipelinePreviewTests
|
||||
[Fact]
|
||||
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
|
||||
{
|
||||
var request = Request("stub");
|
||||
// One insert is reserved in the hole; the other must stay an ordinary requirement.
|
||||
var request = Request("stub", hole: 6);
|
||||
request.Items[1].Quantity = 2;
|
||||
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
|
||||
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
|
||||
@@ -171,7 +173,8 @@ public class CutoutPipelinePreviewTests
|
||||
[Fact]
|
||||
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
|
||||
{
|
||||
var request = Request("stub");
|
||||
// One insert is reserved in the hole; the other must stay an ordinary requirement.
|
||||
var request = Request("stub", hole: 6);
|
||||
request.Items[1].Quantity = 2;
|
||||
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
|
||||
{
|
||||
@@ -273,7 +276,8 @@ public class CutoutPipelinePreviewTests
|
||||
[Fact]
|
||||
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
|
||||
{
|
||||
var initial = Request("stub");
|
||||
// One hole seat: the first requirement row fills it, the second row stays separate.
|
||||
var initial = Request("stub", hole: 6);
|
||||
var request = initial with
|
||||
{
|
||||
Items = new[] { initial.Items[0], initial.Items[1],
|
||||
|
||||
@@ -212,9 +212,10 @@ public sealed class CuttingPlanBatch
|
||||
|
||||
/// <summary>
|
||||
/// Safest first, then fastest: the replayed safest route from the free order and from the
|
||||
/// current order, whichever breaks fewer checks (most severe first), then has less rapid
|
||||
/// travel. A route whose planner-chosen motions pass every check is taken at once, even
|
||||
/// when fixed lead-ins still warn. Null when neither walk finds a route.
|
||||
/// current order, whichever breaks fewer checks (most severe first), then raises the head
|
||||
/// fewer times, then has less rapid travel. A route whose planner-chosen motions pass every
|
||||
/// check head-down is taken at once, even when fixed lead-ins still warn. Null when neither
|
||||
/// walk finds a route.
|
||||
/// </summary>
|
||||
private static (CuttingPlanResult Result, bool FreeOrder)? SafestRoute(Entry entry, CancellationToken token)
|
||||
{
|
||||
@@ -225,10 +226,9 @@ public sealed class CuttingPlanBatch
|
||||
{
|
||||
if (CuttingPlanService.PlanSafestRoute(snapshot, token) is not { } candidate)
|
||||
continue;
|
||||
if (!candidate.IsSafestRouteFallback)
|
||||
if (!candidate.IsSafestRouteFallback && candidate.HeadRaises == 0)
|
||||
return (candidate, freeOrder);
|
||||
if (best is not { } known || candidate.SafestRoute < known.Result.SafestRoute
|
||||
|| candidate.SafestRoute == known.Result.SafestRoute && candidate.RapidDistance < known.Result.RapidDistance)
|
||||
if (best is not { } known || Safer(candidate, known.Result))
|
||||
best = (candidate, freeOrder);
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,17 @@ public sealed class CuttingPlanBatch
|
||||
return best;
|
||||
}
|
||||
|
||||
// Fewer broken checks (a route that breaks none first), then fewer head raises, then less travel.
|
||||
private static bool Safer(CuttingPlanResult candidate, CuttingPlanResult known)
|
||||
{
|
||||
var severity = (candidate.SafestRoute ?? default).CompareTo(known.SafestRoute ?? default);
|
||||
if (severity != 0)
|
||||
return severity < 0;
|
||||
if (candidate.HeadRaises != known.HeadRaises)
|
||||
return candidate.HeadRaises < known.HeadRaises;
|
||||
return candidate.RapidDistance < known.RapidDistance;
|
||||
}
|
||||
|
||||
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
|
||||
int firstPassBudget = FirstPassBudget)
|
||||
{
|
||||
@@ -447,6 +458,8 @@ public sealed class CuttingPlanProposal
|
||||
Quadrant = source.Quadrant,
|
||||
PartSpacing = source.PartSpacing,
|
||||
EdgeSpacing = source.EdgeSpacing,
|
||||
// View > Draw Support shows the proposal on the same bed.
|
||||
SupportBed = source.SupportBed,
|
||||
};
|
||||
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
|
||||
foreach (var zone in source.ClampZones)
|
||||
@@ -512,8 +525,11 @@ public sealed class CuttingPlanProposal
|
||||
+ $"regenerated, {kept} kept as is; rapid travel "
|
||||
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}"
|
||||
+ (plate.ReversedCutOffCount == 0 ? ""
|
||||
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.");
|
||||
// A ready plan's findings are advisory notes (support tabs that may not hold a part level).
|
||||
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.")
|
||||
+ (result.HeadRaises == 0 ? ""
|
||||
: $" No route found keeps the head down over every cut part, so {Count(result.HeadRaises, "rapid")} "
|
||||
+ $"{(result.HeadRaises == 1 ? "raises" : "raise")} the head; each is listed below.");
|
||||
// A ready plan's findings are advisory notes (head raises, support tabs that may not hold a part level).
|
||||
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
|
||||
yield return line;
|
||||
yield break;
|
||||
|
||||
@@ -367,6 +367,12 @@ public sealed class CuttingPlanResult
|
||||
/// <summary>Whether this is a safest-route fallback rather than a route that passed every check.</summary>
|
||||
public bool IsSafestRouteFallback => SafestRoute != null;
|
||||
|
||||
/// <summary>
|
||||
/// The rapids that raise the head over parts already cut, each listed in <see cref="Findings"/>.
|
||||
/// Only a safest-route fallback marks them, when no search kept every rapid head-down.
|
||||
/// </summary>
|
||||
public int HeadRaises { get; init; }
|
||||
|
||||
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
|
||||
internal CuttingPlanSnapshot Snapshot { get; set; }
|
||||
}
|
||||
@@ -350,10 +350,13 @@ public static class CuttingPlanService
|
||||
|
||||
/// <summary>
|
||||
/// The safest route the greedy walk finds, independently replayed; null when there is none.
|
||||
/// A route that passes every check is Ready. One that does not is an UNVERIFIED BestEffort
|
||||
/// carrying every violation, applicable only with explicit acceptance. A route the replay
|
||||
/// cannot check completely, or with any motion into material (a lead that does not fit,
|
||||
/// a cut-order or nested-lead conflict), is never offered: the strict refusal stands.
|
||||
/// Its planner-chosen rapids that still cross parts already cut raise the head
|
||||
/// (<see cref="RaisedRapids"/>): only here, after the strict search found no route avoiding
|
||||
/// them. A route that then passes every check is Ready, listing its raises. One that does not
|
||||
/// is an UNVERIFIED BestEffort carrying every violation, applicable only with explicit
|
||||
/// acceptance. A route the replay cannot check completely, or with any motion into material
|
||||
/// (a lead that does not fit, a cut-order or nested-lead conflict), is never offered: the
|
||||
/// strict refusal stands.
|
||||
/// </summary>
|
||||
internal static CuttingPlanResult PlanSafestRoute(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
@@ -366,6 +369,14 @@ public static class CuttingPlanService
|
||||
var order = JointCuttingPlanSearch.SafestRoute(snapshot.WithSearchLimits(budget, snapshot.MaxEntries), token);
|
||||
if (order == null)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
order = RaisedRapids.Mark(snapshot, order, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
// A route that cannot be read for marking keeps its crossings as findings.
|
||||
}
|
||||
var result = ReplayPrograms(snapshot, order, 0, token, safestRoute: true);
|
||||
if (result.Status is not (CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort)
|
||||
|| result.SafestRoute is { Severe: > 0 })
|
||||
@@ -397,6 +408,9 @@ public static class CuttingPlanService
|
||||
// Violations inside fixed emissions: accepted as given, reported, never refused (D1a).
|
||||
// Capture adds the drawing pins it could not honour; their contours were planned normally.
|
||||
var warnings = new List<CuttingPlanFinding>(snapshot.Findings);
|
||||
// Rapids that raise the head over cut parts are listed, never refused.
|
||||
var raises = new List<CuttingPlanFinding>();
|
||||
var raisedRapids = 0;
|
||||
var materialOwners = snapshot.Placements.Where(p => !p.IsCutOff).ToArray();
|
||||
var materials = materialOwners.Select(p => p.Material).ToArray();
|
||||
foreach (var proposal in order)
|
||||
@@ -433,8 +447,10 @@ public static class CuttingPlanService
|
||||
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
|
||||
}
|
||||
var isFixed = proposal.FixedMotions(execution);
|
||||
var (strict, fixedFindings) = checker.Check(execution, position, source.SourceOrdinal + 1,
|
||||
var (strict, fixedFindings, raised) = checker.Check(execution, position, source.SourceOrdinal + 1,
|
||||
source.IsCutOff, isFixed, token);
|
||||
raises.AddRange(RaisedRapids.Notes(snapshot, source, raised));
|
||||
raisedRapids += RaisedRapids.Count(raised);
|
||||
findings.AddRange(JointCuttingPlanSearch.Map(snapshot, strict));
|
||||
warnings.AddRange(fixedFindings.Select(f => FixedLeadWarnings.FromChecker(snapshot, source, f)));
|
||||
var fixedLeads = new List<FixedLeadFailure>();
|
||||
@@ -464,12 +480,14 @@ public static class CuttingPlanService
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Support tabs that cannot hold their part level are noted, never refused.
|
||||
var notes = order.SelectMany(SupportTabCapture.Notes).Concat(SupportTabCapture.Skipped(snapshot)).ToList();
|
||||
var notes = raises.Concat(order.SelectMany(SupportTabCapture.Notes))
|
||||
.Concat(SupportTabCapture.Skipped(snapshot)).ToList();
|
||||
if (safestRoute && findings.Count != 0 && findings.All(f => f.Kind != PostVerificationKind.Incomplete))
|
||||
return new(CuttingPlanStatus.BestEffort, order, findings.Concat(warnings).Concat(notes).Distinct(),
|
||||
expansions, distance)
|
||||
{
|
||||
SafestRoute = RouteSeverity.Of(findings.Distinct().Select(f => f.Kind)),
|
||||
HeadRaises = raisedRapids,
|
||||
};
|
||||
if (findings.Count != 0)
|
||||
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
@@ -479,9 +497,11 @@ public static class CuttingPlanService
|
||||
// UNVERIFIED: never Ready, applicable only through explicit warning acceptance.
|
||||
if (warnings.Count != 0)
|
||||
return new(CuttingPlanStatus.BestEffort, order, warnings.Concat(notes).Distinct(), expansions,
|
||||
distance, independentlyReplayed: true);
|
||||
distance, independentlyReplayed: true)
|
||||
{ HeadRaises = raisedRapids };
|
||||
return new(CuttingPlanStatus.Ready, order, notes, expansions: expansions,
|
||||
rapidDistance: distance, independentlyReplayed: true);
|
||||
rapidDistance: distance, independentlyReplayed: true)
|
||||
{ HeadRaises = raisedRapids };
|
||||
}
|
||||
|
||||
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
|
||||
|
||||
@@ -52,7 +52,7 @@ internal static class JointCuttingPlanSearch
|
||||
{
|
||||
var inserted = CutOffInsertion.Insert(snapshot, outcome.Order, attached, walk.CountExpansion, token);
|
||||
if (inserted != null)
|
||||
return walk.Ready(inserted);
|
||||
return walk.Ready(walk.FollowSegments(inserted));
|
||||
}
|
||||
catch (BudgetExceededException)
|
||||
{
|
||||
@@ -195,27 +195,43 @@ internal static class JointCuttingPlanSearch
|
||||
}, token);
|
||||
}
|
||||
|
||||
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
|
||||
// would contradict an order already required. False when nothing new was learned.
|
||||
// When the approach crossed the part cut just before the one it left from, the tool went on
|
||||
// past that part and came back across it: "cut those two the other way round", so the tool
|
||||
// leaves from the nearer one (two stacked strips, cut far one first). Otherwise "cut the
|
||||
// blocked part before every part whose cut contour its approach crossed", unless that
|
||||
// would contradict an order already required. When every such rule would (a sheet separator
|
||||
// is cut after everything), the blocked part cannot come earlier, so the tool must arrive
|
||||
// from beyond those parts instead: "cut the part it left from before them". False when
|
||||
// nothing new was learned.
|
||||
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
|
||||
{
|
||||
if (attempt.Blocked is not int blocked)
|
||||
return false;
|
||||
var learned = false;
|
||||
foreach (var crossed in attempt.Crossed.Order())
|
||||
{
|
||||
if (crossed == blocked || prerequisites[crossed].Contains(blocked) || Precedes(crossed, blocked, prerequisites))
|
||||
continue;
|
||||
prerequisites[crossed].Add(blocked);
|
||||
learned = true;
|
||||
}
|
||||
return learned;
|
||||
var rules = Rules(attempt, prerequisites).ToArray();
|
||||
foreach (var (first, then) in rules)
|
||||
prerequisites[then].Add(first);
|
||||
return rules.Length != 0;
|
||||
}
|
||||
|
||||
// True when a crossing of the blocked part's approach would teach a new order rule.
|
||||
private static bool CanLearn(Attempt attempt, HashSet<int>[] prerequisites) =>
|
||||
attempt.Blocked is int blocked && attempt.Crossed.Any(crossed => crossed != blocked
|
||||
&& !prerequisites[crossed].Contains(blocked) && !Precedes(crossed, blocked, prerequisites));
|
||||
Rules(attempt, prerequisites).Any();
|
||||
|
||||
// The rules Learn would add, as (cut first, cut then) pairs.
|
||||
private static IEnumerable<(int First, int Then)> Rules(Attempt attempt, HashSet<int>[] prerequisites)
|
||||
{
|
||||
if (attempt.Blocked is not int blocked)
|
||||
return [];
|
||||
if (attempt.LastCut?.SourceOrdinal is int last && attempt.CutBeforeLast?.SourceOrdinal is int previous
|
||||
&& attempt.Crossed.Contains(previous) && !prerequisites[previous].Contains(last)
|
||||
&& !Precedes(previous, last, prerequisites))
|
||||
return [(last, previous)];
|
||||
var before = New(blocked);
|
||||
return before.Length != 0 || attempt.LastCut?.SourceOrdinal is not int departed ? before : New(departed);
|
||||
|
||||
(int, int)[] New(int first) => attempt.Crossed.Order()
|
||||
.Where(crossed => crossed != blocked && crossed != first && !prerequisites[crossed].Contains(first)
|
||||
&& !Precedes(crossed, first, prerequisites))
|
||||
.Select(crossed => (first, crossed)).ToArray();
|
||||
}
|
||||
|
||||
// True when 'first' must already come before 'second' through the prerequisite chain.
|
||||
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
|
||||
@@ -334,10 +350,10 @@ internal static class JointCuttingPlanSearch
|
||||
}
|
||||
// A cutoff is a fixed program cut the same way wherever it goes, so replanning
|
||||
// the parts before it can only change where its approach starts. Cutting it
|
||||
// earlier never makes its own cut harder; learn that rather than stall here.
|
||||
// earlier never makes its own cut harder, and a sheet separator that cannot come
|
||||
// earlier needs the approach from elsewhere; learn that rather than stall here.
|
||||
if (prerequisites != null && sequence != null && attempt.Blocked is int blocked
|
||||
&& snapshot.Placements[blocked] is { IsCutOff: true, SeparatesSheet: false }
|
||||
&& CanLearn(attempt, prerequisites))
|
||||
&& snapshot.Placements[blocked].IsCutOff && CanLearn(attempt, prerequisites))
|
||||
return attempt;
|
||||
if (attempt.Advance(node))
|
||||
progressExpansions = Expansions;
|
||||
@@ -523,8 +539,9 @@ internal static class JointCuttingPlanSearch
|
||||
CountExpansion(source);
|
||||
var checker = node.Checker.Copy();
|
||||
if (Check(cut, cut.Execution, node.Position, checker, attempt))
|
||||
// A segment is transparent: the part after it ranks its entries and hole route
|
||||
// from where the tool was before the segment, as planned without it.
|
||||
// A segment is transparent to the part after it: that part ranks its outside entry
|
||||
// from where the tool was before the segment, as planned without it. Its hole
|
||||
// route starts where the tool is, the segment's end, as FollowSegments plans it.
|
||||
yield return new(new([.. node.Order, cut], cut.Execution.DeparturePoint, checker, null, node,
|
||||
IsSegment(cut) ? node.RoutePosition : null),
|
||||
cut.Execution.RapidDistanceFrom(node.Position), cut.SourceOrdinal, -1, -1);
|
||||
@@ -559,7 +576,7 @@ internal static class JointCuttingPlanSearch
|
||||
}
|
||||
var preference = node.Active?.Preference;
|
||||
if (node.Active?.Perimeter is { } outside && preference == null)
|
||||
preference = PlanHoles(node.Active, outside);
|
||||
preference = PlanHoles(node.Active.Source, node.Active.Boundary.Position, outside);
|
||||
var choices = node.Active?.Choices ?? [];
|
||||
var arrival = node.Active?.Arrival ?? node.Position;
|
||||
var before = node.Active?.Before ?? node.Checker;
|
||||
@@ -1216,12 +1233,13 @@ internal static class JointCuttingPlanSearch
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter)
|
||||
// The hole route and preferred hole entries from the tool's global arrival onto the part,
|
||||
// ending at the outside entry.
|
||||
private HolePreference PlanHoles(FixedProgramPlacement source, Vector arrival, ContourChoice perimeter)
|
||||
{
|
||||
var source = active.Source;
|
||||
var prepared = source.Prepared;
|
||||
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
|
||||
var localArrival = active.Boundary.RoutePosition - source.Location;
|
||||
var localArrival = arrival - source.Location;
|
||||
var centres = CuttingHoleOrder.Centres(prepared, localArrival, token);
|
||||
var endpoint = perimeter.Point;
|
||||
var entries = new Dictionary<int, ContourChoice>();
|
||||
@@ -1255,6 +1273,72 @@ internal static class JointCuttingPlanSearch
|
||||
return new(route, entries, pierces, endpoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The order with segments placed, where each holed part cut straight after a segment
|
||||
/// takes the hole route and entries planned from the segment's end, where the tool
|
||||
/// really is. The free search planned the part as if the segment were not there, from
|
||||
/// where the tool was before it. The part's outside entry and the order stay, so the
|
||||
/// route is still the one planned without the lines; a kept order replanned later plans
|
||||
/// the same holes from the same point. A part keeps its planned holes when the new
|
||||
/// route has no preferred entry for every hole or does not pass the checks.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<FixedProgramPlacement> FollowSegments(IReadOnlyList<FixedProgramPlacement> order)
|
||||
{
|
||||
var followed = order.ToArray();
|
||||
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||
var position = snapshot.StartPoint;
|
||||
try
|
||||
{
|
||||
for (var index = 0; index < followed.Length; index++)
|
||||
{
|
||||
if (index > 0 && IsSegment(followed[index - 1])
|
||||
&& FromSegment(followed[index], position, checker) is { } replanned)
|
||||
followed[index] = replanned;
|
||||
var placement = followed[index];
|
||||
checker.Check(placement.Execution, position, placement.SourceOrdinal + 1, placement.IsCutOff,
|
||||
placement.FixedMotions(placement.Execution), token);
|
||||
position = placement.Execution.DeparturePoint;
|
||||
}
|
||||
}
|
||||
catch (BudgetExceededException)
|
||||
{
|
||||
// The plan is complete without this refinement; keep it as placed.
|
||||
return order;
|
||||
}
|
||||
return followed;
|
||||
}
|
||||
|
||||
// The part re-planned from arrival with its outside entry kept, or null to keep it.
|
||||
private FixedProgramPlacement FromSegment(FixedProgramPlacement placement, Vector arrival,
|
||||
ReleasedContourState checker)
|
||||
{
|
||||
var source = snapshot.Placements[placement.SourceOrdinal];
|
||||
var prepared = source.Prepared;
|
||||
if (prepared is not { HasCutPerimeter: true } || prepared.PerimeterOrdinal == 0
|
||||
|| placement.ContourChoices.Count != prepared.Count
|
||||
|| placement.ContourChoices[^1].ContourOrdinal != prepared.PerimeterOrdinal)
|
||||
return null;
|
||||
var perimeter = placement.ContourChoices[^1];
|
||||
var preference = PlanHoles(source, arrival, perimeter);
|
||||
if (preference.Entries.Count != prepared.PerimeterOrdinal)
|
||||
return null;
|
||||
var choices = preference.Route.Select(hole => preference.Entries[hole]).Append(perimeter).ToArray();
|
||||
Program program;
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
CountExpansion(source);
|
||||
program = prepared.Emit(choices);
|
||||
execution = ExecutionMotionReader.Read(program, source.Location, arrival, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return Check(source, execution, arrival, checker.Copy(), new Attempt(null), choices)
|
||||
? source.Propose(program, execution, choices, token) : null;
|
||||
}
|
||||
|
||||
// The placements cut at a node, and the excluded ones, which count as cut throughout.
|
||||
private HashSet<int> Finished(Node node)
|
||||
{
|
||||
@@ -1504,6 +1588,9 @@ internal static class JointCuttingPlanSearch
|
||||
/// <summary>The part cut last before the deepest boundary, or null at the start point.</summary>
|
||||
internal FixedProgramPlacement LastCut => Deepest?.Order is { Length: > 0 } order ? order[^1] : null;
|
||||
|
||||
/// <summary>The part cut just before <see cref="LastCut"/>, or null.</summary>
|
||||
internal FixedProgramPlacement CutBeforeLast => Deepest?.Order is { Length: > 1 } order ? order[^2] : null;
|
||||
|
||||
/// <summary>The ordinal of the part the attempt could not get past, or null.</summary>
|
||||
internal int? Blocked => sequence == null || Deepest == null || Deepest.Order.Length >= sequence.Length ? null
|
||||
: sequence[Deepest.Order.Length];
|
||||
@@ -1568,8 +1655,8 @@ internal static class JointCuttingPlanSearch
|
||||
ContourChoice Perimeter = null, HolePreference Preference = null);
|
||||
/// <summary>
|
||||
/// A search state; Previous links a whole-part boundary to the boundary before it. Anchor,
|
||||
/// when set, is where the tool was before the cut-off segments cut last: entries and hole
|
||||
/// routes rank from <see cref="RoutePosition"/>, every rapid is checked from Position.
|
||||
/// when set, is where the tool was before the cut-off segments cut last: outside entries
|
||||
/// rank from <see cref="RoutePosition"/>; hole routes and every rapid start from Position.
|
||||
/// </summary>
|
||||
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
|
||||
ActivePart Active, Node Previous, Vector? Anchor = null)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// The last resort for a route no search could keep head-down: each planner-chosen rapid that
|
||||
/// crosses parts already cut is marked to raise the head (<see cref="HeadRaise"/>). Only the
|
||||
/// safest-route fallback uses it, after the strict search found no route avoiding every crossing,
|
||||
/// so a plate that can be cut head-down never gets a raise. Rapids into or inside a fixed emission,
|
||||
/// and rapids inside sub-programs, are never marked: their crossings stay findings.
|
||||
/// </summary>
|
||||
internal static class RaisedRapids
|
||||
{
|
||||
/// <summary>
|
||||
/// <paramref name="order"/> with the crossing rapids of regenerated parts marked, walked as
|
||||
/// replay walks it. Parts with nothing to mark keep their proposal.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<FixedProgramPlacement> Mark(CuttingPlanSnapshot snapshot,
|
||||
IReadOnlyList<FixedProgramPlacement> order, CancellationToken token)
|
||||
{
|
||||
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||
var position = snapshot.StartPoint;
|
||||
var marked = new List<FixedProgramPlacement>(order.Count);
|
||||
foreach (var proposal in order)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var program = proposal.CopyProgram();
|
||||
var execution = ExecutionMotionReader.Read(program, proposal.Location, position, token);
|
||||
var placement = proposal;
|
||||
var rapids = proposal.Prepared == null || proposal.IsCutOff ? []
|
||||
: Crossing(checker.Copy(), proposal, execution, position, token);
|
||||
if (rapids.Count != 0)
|
||||
{
|
||||
var raised = HeadRaise.Mark(program, rapids);
|
||||
execution = ExecutionMotionReader.Read(raised, proposal.Location, position, token);
|
||||
placement = proposal.Propose(raised, execution, proposal.ContourChoices, token);
|
||||
}
|
||||
checker.Check(execution, position, proposal.SourceOrdinal + 1, proposal.IsCutOff,
|
||||
proposal.FixedMotions(execution), token);
|
||||
marked.Add(placement);
|
||||
position = execution.DeparturePoint;
|
||||
}
|
||||
return marked;
|
||||
}
|
||||
|
||||
// The own-code indices of the planner's rapids that cross parts already cut.
|
||||
private static HashSet<int> Crossing(ReleasedContourState checker, FixedProgramPlacement proposal,
|
||||
OwnedExecution execution, Vector arrival, CancellationToken token)
|
||||
{
|
||||
var isFixed = proposal.FixedMotions(execution);
|
||||
var strict = checker.Check(execution, arrival, proposal.SourceOrdinal + 1, false, isFixed, token).Strict;
|
||||
var codes = new HashSet<int>();
|
||||
foreach (var finding in strict)
|
||||
{
|
||||
if (finding.Kind != PostVerificationKind.RapidCrossing || finding.RapidEnd is not { } end)
|
||||
continue;
|
||||
for (var index = 0; index < execution.Motions.Count; index++)
|
||||
{
|
||||
var motion = execution.Motions[index];
|
||||
if (motion.Rapid && motion.Code is int code && isFixed?.Invoke(index) != true
|
||||
&& motion.End == end && (index == 0 ? arrival : motion.Start) == finding.RapidStart)
|
||||
codes.Add(code);
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One advisory note per raised rapid of <paramref name="source"/>, naming the cut parts it
|
||||
/// passes over, from the replay's raised crossings (check positions are source ordinals + 1).
|
||||
/// </summary>
|
||||
internal static IEnumerable<CuttingPlanFinding> Notes(CuttingPlanSnapshot snapshot,
|
||||
FixedProgramPlacement source, IReadOnlyList<PostVerificationFinding> raised) =>
|
||||
raised.GroupBy(f => (f.RapidStart, f.RapidEnd)).Select(rapid =>
|
||||
{
|
||||
var parts = rapid.Select(f => f.OtherPartNumber).OfType<int>().Distinct().Order().ToArray();
|
||||
var other = parts.Length == 1 ? snapshot.Placements[parts[0] - 1] : null;
|
||||
var over = other != null ? "a part already cut" : $"parts {string.Join(", ", parts)}, already cut,";
|
||||
return new CuttingPlanFinding(source.SourceOrdinal, source.SourcePart, other?.SourceOrdinal,
|
||||
other?.SourcePart, null, $"A rapid raises the head over {over} because no route found keeps the head down.")
|
||||
{ Witness = rapid.First() };
|
||||
});
|
||||
|
||||
/// <summary>The raised rapids in <paramref name="raised"/>: crossings of one rapid count once.</summary>
|
||||
internal static int Count(IEnumerable<PostVerificationFinding> raised) =>
|
||||
raised.Select(f => (f.RapidStart, f.RapidEnd)).Distinct().Count();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Clipper2Lib;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -189,13 +190,7 @@ internal static class CutoutLatticeFill
|
||||
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
|
||||
if (grown == null || grown.Count < 3)
|
||||
return new PathsD();
|
||||
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
|
||||
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
|
||||
var forbidden = Clipper.Union(Minkowski.Sum(reflected, hole, true, Precision),
|
||||
new PathsD { Clipper.TranslatePath(reflected, hole[0].x, hole[0].y) }, FillRule.NonZero, Precision);
|
||||
var anchor = grown[0];
|
||||
return Clipper.Difference(new PathsD { Clipper.TranslatePath(hole, -anchor.x, -anchor.y) }, forbidden,
|
||||
FillRule.NonZero, Precision);
|
||||
return PackBoundary.InnerFit(grown, new PathsD { hole });
|
||||
}
|
||||
|
||||
/// <summary>Drops copies that fail the production clearance check against the frame; any
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>
|
||||
/// Frame-local cutout proposal that packs one closed cutout with the Irregular engine's
|
||||
/// <see cref="FrontierPacker"/>, taking the cutout as the packing boundary and every insert
|
||||
/// requirement at once: parts settle against the cutout wall and each other instead of at the
|
||||
/// first sampled point that clears. Each of the engine's strategy variants packs the hole; the
|
||||
/// variant seating the most copies wins, ties going to the tighter layout. Every pose is then
|
||||
/// certified with <see cref="NestLayoutCheck.Clears"/> against the frame, the occupants and the
|
||||
/// other accepted poses, exactly as the lattice and NFP proposals are. Never changes a job.
|
||||
/// </summary>
|
||||
internal static class CutoutPackerProposal
|
||||
{
|
||||
private const double MaxCoordinate = 1e8;
|
||||
private const double MaxSpacing = 100;
|
||||
private const double Tie = 1e-6;
|
||||
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
|
||||
Fill(frame, cutoutIndex, inserts, spacing,
|
||||
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), null, token);
|
||||
|
||||
/// <summary>Poses in the frame's own coordinates, in packing order, clearing the frame,
|
||||
/// the <paramref name="occupied"/> inserts and each other; instance indices count per
|
||||
/// requirement.</summary>
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||
IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CutoutMemo? memo,
|
||||
CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(inserts);
|
||||
ArgumentNullException.ThrowIfNull(occupied);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (inserts.Count == 0 || !double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
var frameGeometry = memo?.Read(frame.Geometry) ?? JobPartGeometry.TryRead(frame.Geometry);
|
||||
if (frameGeometry == null || !WithinRange(frameGeometry.Bounds))
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||
var cutout = frameGeometry.Cutouts[cutoutIndex];
|
||||
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|
||||
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
|
||||
foreach (var insert in inserts)
|
||||
{
|
||||
var geometry = memo?.Read(insert.Geometry) ?? JobPartGeometry.TryRead(insert.Geometry);
|
||||
if (geometry == null || !WithinRange(geometry.Bounds))
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
geometries[insert.Id] = geometry;
|
||||
}
|
||||
|
||||
// Inscribed: the flattened cutout never extends past the real one.
|
||||
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
|
||||
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false), new Vector()));
|
||||
if (hole.Count < 3)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
var box = cutout.BoundingBox;
|
||||
var stock = new NestPlateStock("cutout", new Size(box.Width, box.Length), 1, spacing);
|
||||
var types = PartCatalog.Build(new NestJob(inserts, new[] { stock }));
|
||||
if (types.All(t => t.Orientations.Count == 0))
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var nfps = new NoFitCache(spacing);
|
||||
var obstacles = occupied.Select(o => Outline(o.Geometry, o.Pose)).ToList();
|
||||
var boundary = new PackBoundary(hole, obstacles, nfps);
|
||||
var pairs = new Dictionary<int, IReadOnlyList<PairPose>>();
|
||||
var counter = new WorkCounter();
|
||||
var demand = types.Select(t => t.Part.Quantity).ToArray();
|
||||
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||
var preparedFrame = NestLayoutCheck.Prepare(frameGeometry, framePose, spacing);
|
||||
var preparedOccupied = new NestLayoutCheck.PreparedPlacement[occupied.Count];
|
||||
for (var i = 0; i < occupied.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
preparedOccupied[i] = NestLayoutCheck.Prepare(occupied[i].Geometry, occupied[i].Pose, spacing);
|
||||
}
|
||||
|
||||
List<NestJobPlacement>? best = null;
|
||||
foreach (var (axis, beta) in IrregularNestingEngine.Variants)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var packer = new FrontierPacker(types, nfps, pairs, stock, axis, beta, counter, boundary: boundary);
|
||||
var fill = packer.Fill(demand, token);
|
||||
var poses = new List<NestJobPlacement>(fill.Parts.Count);
|
||||
foreach (var placed in fill.Parts)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = types[placed.Orientation.TypeIndex].Part;
|
||||
var pose = new NestJobPlacement(part.Id, 0, System.Math.Round(placed.X, 8),
|
||||
System.Math.Round(placed.Y, 8), placed.Orientation.Rotation);
|
||||
if (Clears(pose, geometries[part.Id], preparedFrame, preparedOccupied, poses, geometries, spacing, memo, token))
|
||||
poses.Add(pose);
|
||||
}
|
||||
if (poses.Count == 0)
|
||||
continue;
|
||||
if (best == null || poses.Count > best.Count
|
||||
|| (poses.Count == best.Count && Tighter(poses, best, id => geometries[id])))
|
||||
best = poses;
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (best == null)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
return best.Select(p =>
|
||||
{
|
||||
var index = counts.GetValueOrDefault(p.PartId);
|
||||
counts[p.PartId] = index + 1;
|
||||
return p with { InstanceIndex = index };
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="candidate"/> is the more compact layout: a smaller
|
||||
/// bounding box of the placed inserts, then one lower, then one further left.</summary>
|
||||
internal static bool Tighter(IReadOnlyList<NestJobPlacement> candidate,
|
||||
IReadOnlyList<NestJobPlacement> incumbent, Func<string, JobPartGeometry> geometry)
|
||||
{
|
||||
if (candidate.Count == 0)
|
||||
return false;
|
||||
if (incumbent.Count == 0)
|
||||
return true;
|
||||
var a = Envelope(candidate, geometry);
|
||||
var b = Envelope(incumbent, geometry);
|
||||
var areaA = a.Length * a.Width;
|
||||
var areaB = b.Length * b.Width;
|
||||
if (System.Math.Abs(areaA - areaB) > Tie * System.Math.Max(1, System.Math.Max(areaA, areaB)))
|
||||
return areaA < areaB;
|
||||
if (System.Math.Abs(a.Bottom - b.Bottom) > Tie)
|
||||
return a.Bottom < b.Bottom;
|
||||
return a.Left < b.Left - Tie;
|
||||
}
|
||||
|
||||
private static Box Envelope(IReadOnlyList<NestJobPlacement> poses, Func<string, JobPartGeometry> geometry)
|
||||
{
|
||||
var left = double.PositiveInfinity;
|
||||
var bottom = double.PositiveInfinity;
|
||||
var right = double.NegativeInfinity;
|
||||
var top = double.NegativeInfinity;
|
||||
foreach (var pose in poses)
|
||||
{
|
||||
var shape = (Shape)geometry(pose.PartId).Perimeter.Clone();
|
||||
shape.Rotate(pose.Rotation);
|
||||
var bounds = shape.BoundingBox;
|
||||
left = System.Math.Min(left, bounds.Left + pose.X);
|
||||
bottom = System.Math.Min(bottom, bounds.Bottom + pose.Y);
|
||||
right = System.Math.Max(right, bounds.Right + pose.X);
|
||||
top = System.Math.Max(top, bounds.Top + pose.Y);
|
||||
}
|
||||
return new Box(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
|
||||
private static bool Clears(NestJobPlacement pose, JobPartGeometry geometry,
|
||||
NestLayoutCheck.PreparedPlacement frame, NestLayoutCheck.PreparedPlacement[] occupied,
|
||||
List<NestJobPlacement> accepted, IReadOnlyDictionary<string, JobPartGeometry> geometries,
|
||||
double spacing, CutoutMemo? memo, CancellationToken token)
|
||||
{
|
||||
foreach (var other in occupied)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!NestLayoutCheck.Clears(other, geometry, pose))
|
||||
return false;
|
||||
}
|
||||
foreach (var other in accepted)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var otherGeometry = geometries[other.PartId];
|
||||
var clears = memo?.Clears(otherGeometry, other, geometry, pose, spacing)
|
||||
?? NestLayoutCheck.Clears(otherGeometry, other, geometry, pose, spacing);
|
||||
if (!clears)
|
||||
return false;
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return NestLayoutCheck.Clears(frame, geometry, pose);
|
||||
}
|
||||
|
||||
/// <summary>The occupant's perimeter at its pose, circumscribed so it never loses material.</summary>
|
||||
private static (PathD Outline, double Tolerance) Outline(JobPartGeometry geometry, NestJobPlacement pose)
|
||||
{
|
||||
var shape = (Shape)geometry.Perimeter.Clone();
|
||||
shape.Rotate(pose.Rotation);
|
||||
var path = ClipperBridge.ToPath(ClipperBridge.Flatten(shape, CutoutLatticeFill.FlattenTolerance, circumscribe: true),
|
||||
new Vector(pose.X, pose.Y));
|
||||
return (CutoutLatticeFill.Positive(path), CutoutLatticeFill.FlattenTolerance);
|
||||
}
|
||||
|
||||
private static bool WithinRange(Box box) =>
|
||||
double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
|
||||
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
|
||||
}
|
||||
@@ -57,6 +57,12 @@ internal sealed class CutoutPipelinePrepass
|
||||
// An insert's outline cannot fit a hole of smaller area; bolt holes skip the searches.
|
||||
var outline = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
|
||||
? System.Math.Abs(g.Perimeter.Area()) : double.PositiveInfinity);
|
||||
// Lattice and NFP placement read a part's holes as free space, so they take as inserts
|
||||
// only parts whose holes no part of the job fits by area: no later insert can land in
|
||||
// one. Proxies are never solid; their frames' holes hold reserved inserts.
|
||||
var smallestOutline = outline.Values.DefaultIfEmpty(double.PositiveInfinity).Min();
|
||||
var solid = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
|
||||
&& g.Cutouts.All(c => System.Math.Abs(c.Area()) < smallestOutline));
|
||||
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
|
||||
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
|
||||
// A proposal depends on the frame, the hole and the offered inserts with their local
|
||||
@@ -104,10 +110,10 @@ internal sealed class CutoutPipelinePrepass
|
||||
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
|
||||
p.Priority, LocalRotation(p.Rotation, angle)))
|
||||
.ToArray();
|
||||
var flat = eligible.Where(p => geometry[p.Id] is { Cutouts.Count: 0 }).ToArray();
|
||||
var flat = eligible.Where(p => solid.GetValueOrDefault(p.Id)).ToArray();
|
||||
var nested = CutoutCirclePacker.RoundHole(profile.Cutouts[hole]) == null
|
||||
? Array.Empty<NestJobPart>()
|
||||
: eligible.Where(p => round[p.Id] && geometry[p.Id]!.Cutouts.Count > 0).ToArray();
|
||||
: eligible.Where(p => round[p.Id] && !solid.GetValueOrDefault(p.Id)).ToArray();
|
||||
if (flat.Length == 0 && nested.Length == 0)
|
||||
continue;
|
||||
foreach (var pose in Propose(frame, hole, flat, nested))
|
||||
|
||||
@@ -10,7 +10,8 @@ namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
|
||||
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
|
||||
/// the large-insert path tries NFP first. A round hole whose inserts are all round gets only the
|
||||
/// circle-in-circle proposal for the whole list.
|
||||
/// circle-in-circle proposal for the whole list. Every other hole is also packed by
|
||||
/// <see cref="CutoutPackerProposal"/>, which wins with more copies or an equal count packed tighter.
|
||||
/// All accepted poses are clearance-checked.
|
||||
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
|
||||
internal static class CutoutRouter
|
||||
@@ -32,7 +33,8 @@ internal static class CutoutRouter
|
||||
Fill(frame, cutoutIndex, inserts, spacing, null, memo, token);
|
||||
|
||||
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
|
||||
/// after indexing; true marks a retained lattice seed, false a residual NFP or circle-packer pose.</summary>
|
||||
/// after indexing; true marks a retained lattice seed, false a residual NFP, circle-packer or
|
||||
/// frontier-packer pose.</summary>
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default) =>
|
||||
@@ -66,9 +68,10 @@ internal static class CutoutRouter
|
||||
// NFP searches never placed more there, and they are slow on round inserts, most of all
|
||||
// when demand exceeds what fits (every surplus copy is searched for and not found).
|
||||
if (CutoutCirclePacker.Applies(hole, inserts))
|
||||
return Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
|
||||
return Observe(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
|
||||
|
||||
var accepted = new List<NestJobPlacement>();
|
||||
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
|
||||
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
|
||||
var holeArea = System.Math.Abs(hole.Area());
|
||||
@@ -77,6 +80,7 @@ internal static class CutoutRouter
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var geometry = JobPartGeometry.Read(part.Geometry);
|
||||
geometries[part.Id] = geometry;
|
||||
var ratio = geometry.MaterialArea / holeArea;
|
||||
// Unsupported or degenerate material cannot establish a useful routing ratio;
|
||||
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
|
||||
@@ -148,11 +152,20 @@ internal static class CutoutRouter
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
// The frontier packer sees every requirement at once and settles parts against the
|
||||
// walls and each other; it replaces the sampled proposal when it seats more copies, or
|
||||
// the same number more compactly.
|
||||
var packed = CutoutPackerProposal.Fill(frame, cutoutIndex, inserts, spacing,
|
||||
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), memo, token);
|
||||
if (packed.Count > accepted.Count
|
||||
|| (packed.Count == accepted.Count
|
||||
&& CutoutPackerProposal.Tighter(packed, accepted, id => geometries[id])))
|
||||
return Observe(packed);
|
||||
foreach (var (pose, lattice) in observed)
|
||||
observeAccepted?.Invoke(pose, lattice);
|
||||
return accepted.ToArray();
|
||||
|
||||
IReadOnlyList<NestJobPlacement> Circles(IReadOnlyList<NestJobPlacement> poses)
|
||||
IReadOnlyList<NestJobPlacement> Observe(IReadOnlyList<NestJobPlacement> poses)
|
||||
{
|
||||
foreach (var pose in poses)
|
||||
observeAccepted?.Invoke(pose, false);
|
||||
|
||||
@@ -44,6 +44,10 @@ internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> Par
|
||||
/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest,
|
||||
/// i.e. the one that buys the most material coverage for the sheet length it consumes.
|
||||
/// Parts are never placed in a sequence given up front; the sheet state decides what comes next.
|
||||
///
|
||||
/// Given a <see cref="PackBoundary"/>, the packer fills that outline instead of the stock's
|
||||
/// rectangular work area: each region starts from the orientation's inner-fit polygon of the
|
||||
/// boundary less the boundary's obstacles, and everything else is unchanged.
|
||||
/// </summary>
|
||||
internal sealed class FrontierPacker
|
||||
{
|
||||
@@ -63,7 +67,10 @@ internal sealed class FrontierPacker
|
||||
private readonly WorkCounter counter;
|
||||
private readonly BlockCatalog? blocks;
|
||||
private readonly ClampObstacles? clamps;
|
||||
private readonly PackBoundary? boundary;
|
||||
|
||||
/// <param name="boundary">Outline to fill instead of the stock's work area; it must be
|
||||
/// fitted to <paramref name="nfps"/>.</param>
|
||||
public FrontierPacker(
|
||||
IReadOnlyList<PartType> types,
|
||||
NoFitCache nfps,
|
||||
@@ -73,9 +80,12 @@ internal sealed class FrontierPacker
|
||||
double beta,
|
||||
WorkCounter counter,
|
||||
BlockCatalog? blocks = null,
|
||||
ClampObstacles? clamps = null
|
||||
ClampObstacles? clamps = null,
|
||||
PackBoundary? boundary = null
|
||||
)
|
||||
{
|
||||
if (boundary != null && !ReferenceEquals(boundary.Nfps, nfps))
|
||||
throw new ArgumentException("The boundary is fitted to another no-fit cache.", nameof(boundary));
|
||||
this.blocks = blocks;
|
||||
this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock);
|
||||
this.counter = counter;
|
||||
@@ -85,9 +95,13 @@ internal sealed class FrontierPacker
|
||||
this.stock = stock;
|
||||
this.axis = axis;
|
||||
this.beta = beta;
|
||||
work = stock.WorkArea;
|
||||
this.boundary = boundary;
|
||||
work = boundary?.Bounds ?? stock.WorkArea;
|
||||
}
|
||||
|
||||
private bool Fits(double width, double height) =>
|
||||
boundary?.Fits(width, height) ?? stock.Fits(width, height);
|
||||
|
||||
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
|
||||
{
|
||||
var left = remaining.ToArray();
|
||||
@@ -97,9 +111,11 @@ internal sealed class FrontierPacker
|
||||
{
|
||||
if (byOrientation.ContainsKey(o))
|
||||
return true;
|
||||
if (!stock.Fits(o.Width, o.Height))
|
||||
if (!Fits(o.Width, o.Height))
|
||||
return false;
|
||||
var region = boundary == null ? new Region(o, work, single) : new Region(o, boundary, single);
|
||||
if (region.IsEmpty)
|
||||
return false;
|
||||
var region = new Region(o, work, single);
|
||||
if (clamps != null)
|
||||
for (var i = 0; i < clamps.Count; i++)
|
||||
{
|
||||
@@ -110,6 +126,13 @@ internal sealed class FrontierPacker
|
||||
counter.Add(1);
|
||||
token.ThrowIfCancellationRequested();
|
||||
}
|
||||
if (boundary != null)
|
||||
for (var i = 0; i < boundary.ObstacleCount; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
region.Subtract(nfps.Get(boundary.Obstacle(i), o), 0, 0);
|
||||
counter.Add(1);
|
||||
}
|
||||
states.Add(region);
|
||||
byOrientation[o] = region;
|
||||
return true;
|
||||
@@ -126,7 +149,7 @@ internal sealed class FrontierPacker
|
||||
foreach (var pair in typePairs)
|
||||
{
|
||||
// Members missing from the catalog get regions too, but only for pair placement.
|
||||
if (stock.Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
|
||||
if (Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
|
||||
offered.Add(new PairState(pair, byOrientation[pair.A], byOrientation[pair.B]));
|
||||
}
|
||||
}
|
||||
@@ -148,7 +171,7 @@ internal sealed class FrontierPacker
|
||||
continue;
|
||||
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
|
||||
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
|
||||
if (!stock.Fits(width, height))
|
||||
if (!Fits(width, height))
|
||||
continue;
|
||||
foreach (var member in members)
|
||||
{
|
||||
@@ -341,6 +364,15 @@ internal sealed class FrontierPacker
|
||||
private RectD bounds;
|
||||
|
||||
public Region(Orientation orientation, Box work, bool single)
|
||||
: this(orientation, work, single, null) { }
|
||||
|
||||
/// <summary>Starts from the orientation's inner fit of the boundary. No fit slack: the
|
||||
/// inner fit already lies within the bounds box, and an exact fit against a cutout wall
|
||||
/// would fail the clearance check anyway.</summary>
|
||||
public Region(Orientation orientation, PackBoundary boundary, bool single)
|
||||
: this(orientation, boundary.Bounds, single, boundary.InnerFit(orientation)) { }
|
||||
|
||||
private Region(Orientation orientation, Box work, bool single, PathsD? seed)
|
||||
{
|
||||
Orientation = orientation;
|
||||
Single = single;
|
||||
@@ -353,7 +385,7 @@ internal sealed class FrontierPacker
|
||||
maxX = minX;
|
||||
if (maxY < minY)
|
||||
maxY = minY;
|
||||
free = new PathsD
|
||||
free = seed ?? new PathsD
|
||||
{
|
||||
new PathD
|
||||
{
|
||||
@@ -363,7 +395,8 @@ internal sealed class FrontierPacker
|
||||
new(minX - FitSlack, maxY + FitSlack),
|
||||
},
|
||||
};
|
||||
bounds = Clipper.GetBounds(free);
|
||||
free.RemoveAll(p => p.Count < 3);
|
||||
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
|
||||
}
|
||||
|
||||
public Orientation Orientation { get; }
|
||||
|
||||
@@ -24,8 +24,9 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
/// </summary>
|
||||
public sealed class IrregularNestingEngine : INestingEngine
|
||||
{
|
||||
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
||||
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
||||
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).
|
||||
/// The cutout packer proposal tries the same variants on a cutout.</summary>
|
||||
internal static readonly (PackAxis Axis, double Beta)[] Variants =
|
||||
{
|
||||
(PackAxis.X, 1.0),
|
||||
(PackAxis.Y, 1.0),
|
||||
|
||||
@@ -30,9 +30,13 @@ internal sealed class NoFitCache
|
||||
|
||||
public NoFitCache(double clearance)
|
||||
{
|
||||
Clearance = clearance;
|
||||
halfClearance = clearance / 2;
|
||||
}
|
||||
|
||||
/// <summary>The clearance two footprints together keep.</summary>
|
||||
public double Clearance { get; }
|
||||
|
||||
public PathD Footprint(Orientation o) =>
|
||||
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o));
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#nullable enable
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// A closed outline the packer fills in place of a rectangular work area: a frame's cutout in
|
||||
/// frame coordinates, with the parts already seated in it as obstacles. Each orientation's
|
||||
/// starting free region is its inner-fit polygon: the reference points at which its clearance
|
||||
/// footprint lies inside the outline eroded by the other half of the clearance, so a part
|
||||
/// keeps the full clearance from the frame exactly as it does from another part. The inner
|
||||
/// fit is computed once per orientation and shared by every packing of this boundary.
|
||||
/// </summary>
|
||||
/// <remarks>Bound to one <see cref="NoFitCache"/>: its footprints define the inner fits and the
|
||||
/// obstacle no-fit polygons, so a packer filling this boundary must use that cache.</remarks>
|
||||
internal sealed class PackBoundary
|
||||
{
|
||||
/// <summary>Extra erosion beyond the half clearance, covering the outline's flattening and
|
||||
/// the round-offset chords at its reflex corners.</summary>
|
||||
private const double Margin = 0.002;
|
||||
|
||||
private const double ArcTolerance = 0.001;
|
||||
|
||||
private readonly PathsD eroded;
|
||||
private readonly Orientation[] obstacles;
|
||||
private readonly ConcurrentDictionary<(int, int), PathsD> innerFits = new();
|
||||
|
||||
/// <param name="outline">Closed polygon inscribed in the true outline, so flattening never
|
||||
/// adds room.</param>
|
||||
/// <param name="obstacles">Polygons circumscribing material already inside the outline, with
|
||||
/// the chord deviation each was flattened with; every obstacle gets the full clearance.</param>
|
||||
/// <param name="nfps">The cache whose footprints this boundary is fitted to.</param>
|
||||
public PackBoundary(PathD outline, IReadOnlyList<(PathD Outline, double Tolerance)> obstacles, NoFitCache nfps)
|
||||
{
|
||||
Nfps = nfps;
|
||||
var box = Clipper.GetBounds(outline);
|
||||
Bounds = new Box(box.left, box.top, box.Width, box.Height); // Clipper RectD: top is the minimum Y.
|
||||
eroded = Clipper.InflatePaths(new PathsD { Positive(outline) }, -(nfps.Clearance / 2 + Margin),
|
||||
JoinType.Round, EndType.Polygon, 2.0, NoFitCache.Precision, ArcTolerance);
|
||||
eroded.RemoveAll(p => p.Count < 3);
|
||||
this.obstacles = new Orientation[obstacles.Count];
|
||||
for (var i = 0; i < obstacles.Count; i++)
|
||||
{
|
||||
var path = Positive(obstacles[i].Outline);
|
||||
var tolerance = obstacles[i].Tolerance;
|
||||
var bounds = Clipper.GetBounds(path);
|
||||
this.obstacles[i] = new Orientation
|
||||
{
|
||||
// Real part types are nonnegative, so obstacle footprints never alias them.
|
||||
TypeIndex = -1,
|
||||
Index = i,
|
||||
Rotation = 0,
|
||||
Outline = path,
|
||||
Tolerance = tolerance,
|
||||
MinX = bounds.left - tolerance,
|
||||
MinY = bounds.top - tolerance,
|
||||
MaxX = bounds.right + tolerance,
|
||||
MaxY = bounds.bottom + tolerance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public NoFitCache Nfps { get; }
|
||||
|
||||
/// <summary>Bounds of the outline: the packer's work area and front origin.</summary>
|
||||
public Box Bounds { get; }
|
||||
|
||||
public int ObstacleCount => obstacles.Length;
|
||||
|
||||
public Orientation Obstacle(int index) => obstacles[index];
|
||||
|
||||
/// <summary>Coarse filter against the outline's bounds; the inner fit decides.</summary>
|
||||
public bool Fits(double width, double height) =>
|
||||
width <= Bounds.Length + 1e-9 && height <= Bounds.Width + 1e-9;
|
||||
|
||||
/// <summary>A fresh copy of the orientation's starting free region.</summary>
|
||||
public PathsD InnerFit(Orientation orientation) =>
|
||||
new(innerFits.GetOrAdd((orientation.TypeIndex, orientation.Index),
|
||||
_ => InnerFit(Nfps.Footprint(orientation), eroded)));
|
||||
|
||||
/// <summary>
|
||||
/// Reference points t at which <paramref name="grown"/> lies inside one component of
|
||||
/// <paramref name="hole"/>: its anchor vertex is inside the component (t in component - anchor)
|
||||
/// and its outline never meets the component's boundary (t outside boundary + reflected
|
||||
/// outline, including the pose that contains the whole component).
|
||||
/// </summary>
|
||||
internal static PathsD InnerFit(PathD grown, PathsD hole)
|
||||
{
|
||||
if (grown.Count < 3)
|
||||
return new PathsD();
|
||||
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
|
||||
var anchor = grown[0];
|
||||
var inside = new PathsD();
|
||||
PathsD? forbidden = null;
|
||||
foreach (var component in hole)
|
||||
{
|
||||
if (component.Count < 3)
|
||||
continue;
|
||||
inside.Add(Clipper.TranslatePath(component, -anchor.x, -anchor.y));
|
||||
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
|
||||
var sweep = Clipper.Union(Minkowski.Sum(reflected, component, true, NoFitCache.Precision),
|
||||
new PathsD { Clipper.TranslatePath(reflected, component[0].x, component[0].y) },
|
||||
FillRule.NonZero, NoFitCache.Precision);
|
||||
forbidden = forbidden == null ? sweep : Clipper.Union(forbidden, sweep, FillRule.NonZero, NoFitCache.Precision);
|
||||
}
|
||||
return forbidden == null
|
||||
? new PathsD()
|
||||
: Clipper.Difference(inside, forbidden, FillRule.NonZero, NoFitCache.Precision);
|
||||
}
|
||||
|
||||
private static PathD Positive(PathD path)
|
||||
{
|
||||
if (path.Count >= 3 && !Clipper.IsPositive(path))
|
||||
{
|
||||
path = new PathD(path);
|
||||
path.Reverse();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Gpu</RootNamespace>
|
||||
<AssemblyName>OpenNest.Gpu</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests
|
||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
|
||||
Assert.Equal(before, NestDigest(factory.DatabasePath));
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
@@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests
|
||||
|
||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||
{
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(before, NestDigest(factory.DatabasePath));
|
||||
// Two distinct blobs across four revisions: the bracket's notes edit and the
|
||||
// plate both reuse the bracket's first package.
|
||||
@@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
|
||||
Execute(factory.DatabasePath, "PRAGMA user_version=14");
|
||||
Execute(factory.DatabasePath, "PRAGMA user_version=15");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,36 @@ public sealed class DrawingLibraryQueryTests
|
||||
Assert.Equal(page.Items.Count, page.Total);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("materialName=Carbon%20Steel", "Any|Any thickness|Steel")]
|
||||
[InlineData("materialName=Carbon%20Steel&materialGrade=A36&thickness=0.25", "Any|Any thickness|Steel")]
|
||||
[InlineData("materialName=Stainless&thickness=0.25", "Any")]
|
||||
[InlineData("materialName=Carbon%20Steel&thickness=0.1875", "Any|Any thickness")]
|
||||
[InlineData("thickness=0", "Any|Any thickness")]
|
||||
public async Task UnsetMaterialGradeOrThickness_MatchesAnyFilter(string query, string expected)
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
foreach (var (name, material, grade, thickness) in new[]
|
||||
{
|
||||
("Any", "", "", 0.0),
|
||||
("Any thickness", "Carbon Steel", "", 0.0),
|
||||
("Steel", "Carbon Steel", "A36", 0.25),
|
||||
})
|
||||
{
|
||||
var record = Record(name);
|
||||
record.MaterialName = material;
|
||||
record.MaterialGrade = grade;
|
||||
record.Thickness = thickness;
|
||||
await Upload(client, record, Package(name));
|
||||
}
|
||||
|
||||
var page = await DrawingLibraryApiTests.Query(client, query + "&sort=name&order=asc");
|
||||
|
||||
Assert.Equal(expected, string.Join("|", page.Items.Select(r => r.Name)));
|
||||
Assert.Equal(page.Items.Count, page.Total);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("name", false)]
|
||||
[InlineData("name", true)]
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
|
||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
|
||||
Assert.Equal(before, Digest(factory.DatabasePath));
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
|
||||
Assert.Equal(before, Digest(factory.DatabasePath));
|
||||
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
|
||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class MaterialListTests
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Empty(upgraded.ListMaterials());
|
||||
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
|
||||
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests
|
||||
Assert.Equal("Stainless", record.Material);
|
||||
Assert.Equal("", record.MaterialGrade);
|
||||
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade);
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
private static NestRecord Record(string name, string material, string grade) => new()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Net;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET /api/nests/stock-item-codes</c> lists the ledger item codes of open nests from codes
|
||||
/// stored at each archive write, so Auto Nest can leave them out before ML holds exist.
|
||||
/// </summary>
|
||||
public sealed class NestStockCodesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task OpenNests_ListTheirCodesOncePerNest()
|
||||
{
|
||||
using var session = new Session();
|
||||
var first = await session.SaveAsync("Nest A", NestStatus.Quote, ["SHEET-1", "SHEET-1"], ["DROP-7"]);
|
||||
var second = await session.SaveAsync("Nest B", NestStatus.ToBeCut, ["SHEET-1"]);
|
||||
await session.SaveAsync("Uncoded", NestStatus.Quote);
|
||||
|
||||
var uses = await session.Repository.GetStockCodesInUseAsync(Guid.Empty);
|
||||
|
||||
Assert.Equal(
|
||||
[("DROP-7", first.Id, "Nest A"), ("SHEET-1", first.Id, "Nest A"), ("SHEET-1", second.Id, "Nest B")],
|
||||
uses.Select(u => (u.ItemCode, u.NestId, u.NestName)).ToArray());
|
||||
Assert.Equal(NestStatus.ToBeCut, uses[2].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exclude_LeavesOutTheNestBeingEdited()
|
||||
{
|
||||
using var session = new Session();
|
||||
var editing = await session.SaveAsync("Editing", NestStatus.Quote, ["SHEET-1"]);
|
||||
await session.SaveAsync("Other", NestStatus.Quote, ["SHEET-2"]);
|
||||
|
||||
var uses = await session.Repository.GetStockCodesInUseAsync(editing.Id);
|
||||
|
||||
Assert.Equal(["SHEET-2"], uses.Select(u => u.ItemCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CutAndDeletedNests_AreNotOpen()
|
||||
{
|
||||
using var session = new Session();
|
||||
var draft = await session.SaveAsync("Draft", NestStatus.Draft, ["D-1"]);
|
||||
var cut = await session.SaveAsync("Cut", NestStatus.Quote, ["C-1"]);
|
||||
var deleted = await session.SaveAsync("Deleted", NestStatus.Quote, ["X-1"]);
|
||||
|
||||
cut.Status = NestStatus.HasBeenCut;
|
||||
await session.Repository.UpdateMetadataAsync(cut.Id, cut, cut.Revision);
|
||||
await session.Repository.DeleteAsync(deleted.Id, deleted.Revision);
|
||||
|
||||
Assert.Equal(["D-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
|
||||
Assert.Equal(draft.Id, Assert.Single(await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).NestId);
|
||||
|
||||
// Recovering a nest brings its codes back without rereading the archive.
|
||||
var gone = (await session.Repository.GetDeletedMetadataAsync(deleted.Id))!;
|
||||
await session.Repository.RecoverAsync(deleted.Id, gone.Revision);
|
||||
Assert.Equal(["D-1", "X-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavingAndRestoring_ReplaceTheStoredCodes()
|
||||
{
|
||||
using var session = new Session();
|
||||
var record = await session.SaveAsync("Nest", NestStatus.Quote, ["OLD-1"]);
|
||||
|
||||
var nest = Nest("Nest", NestStatus.Quote, ["NEW-1"]);
|
||||
var updated = await session.Repository.UpdateFileAsync(record.Id, NestApiTests.Archive(nest),
|
||||
Record(record.Id, nest), record.Revision);
|
||||
Assert.Equal(["NEW-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
|
||||
|
||||
await session.Repository.RestoreAsync(record.Id, 1, updated.Revision);
|
||||
Assert.Equal(["OLD-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidExclude_IsABadRequest()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/api/nests/stock-item-codes?exclude=nope");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OlderServer_IsNotSupported()
|
||||
{
|
||||
using var http = new HttpClient(new StatusHandler(HttpStatusCode.NotFound)) { BaseAddress = new Uri("http://old/") };
|
||||
using var repository = new RemoteNestRepository(http, "http://old/");
|
||||
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() => repository.GetStockCodesInUseAsync(Guid.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void V13Upgrade_ReadsTheCodesOfEveryStoredNest()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
var open = Guid.NewGuid();
|
||||
var deleted = Guid.NewGuid();
|
||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||
{
|
||||
var nest = Nest("Open", NestStatus.Quote, ["SHEET-1"]);
|
||||
database.Insert(open, Record(open, nest), NestApiTests.Archive(nest));
|
||||
var gone = Nest("Gone", NestStatus.Quote, ["SHEET-2"]);
|
||||
var record = database.Insert(deleted, Record(deleted, gone), NestApiTests.Archive(gone));
|
||||
database.SetDeleted(deleted, true, record.Revision);
|
||||
}
|
||||
Execute(factory.DatabasePath, "DROP TABLE nest_stock_codes; PRAGMA user_version=13;");
|
||||
|
||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||
{
|
||||
Assert.Equal(["SHEET-1"], database.StockCodesInUse(null).Select(u => u.ItemCode));
|
||||
var record = database.Get(deleted, deleted: true)!;
|
||||
database.SetDeleted(deleted, false, record.Revision);
|
||||
Assert.Equal(["SHEET-1", "SHEET-2"], database.StockCodesInUse(null).Select(u => u.ItemCode));
|
||||
}
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
private static Nest Nest(string name, NestStatus status, params string[][] plateCodes)
|
||||
{
|
||||
var nest = NestApiTests.SyntheticNest();
|
||||
nest.Name = name;
|
||||
nest.Status = status;
|
||||
var drawing = nest.Drawings.Single();
|
||||
nest.Plates.Clear();
|
||||
foreach (var codes in plateCodes.DefaultIfEmpty(Array.Empty<string>()))
|
||||
{
|
||||
var plate = new Plate(new Size(48, 96)) { Quantity = System.Math.Max(codes.Length, 1) };
|
||||
plate.Parts.Add(new Part(drawing, new Vector(2, 3)));
|
||||
plate.StockItemCodes = codes;
|
||||
nest.Plates.Add(plate);
|
||||
}
|
||||
return nest;
|
||||
}
|
||||
|
||||
private static NestRecord Record(Guid id, Nest nest) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = nest.Name,
|
||||
Status = nest.Status,
|
||||
DateCreated = nest.DateCreated,
|
||||
DateModified = nest.DateCreated,
|
||||
};
|
||||
|
||||
private static void Execute(string path, string sql)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static object? Scalar(string path, string sql)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return command.ExecuteScalar();
|
||||
}
|
||||
|
||||
private sealed class Session : IDisposable
|
||||
{
|
||||
private readonly ServerFactory factory = new();
|
||||
private readonly HttpClient client;
|
||||
|
||||
public Session()
|
||||
{
|
||||
client = factory.CreateClient();
|
||||
Repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
|
||||
}
|
||||
|
||||
public RemoteNestRepository Repository { get; }
|
||||
|
||||
public Task<NestRecord> SaveAsync(string name, NestStatus status, params string[][] plateCodes)
|
||||
{
|
||||
var nest = Nest(name, status, plateCodes);
|
||||
return Repository.UploadAsync(NestApiTests.Archive(nest), Record(Guid.NewGuid(), nest));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Repository.Dispose();
|
||||
client.Dispose();
|
||||
factory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StatusHandler(HttpStatusCode status) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HttpResponseMessage(status));
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public sealed class NestUnitsTests
|
||||
Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units));
|
||||
Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units);
|
||||
Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units);
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
}
|
||||
|
||||
private static NestRecord Record(string name, Units units) => new()
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace OpenNest.Server.Tests;
|
||||
|
||||
@@ -10,15 +11,19 @@ namespace OpenNest.Server.Tests;
|
||||
/// Hosts the production routes in memory against a private, disposable SQLite file.
|
||||
/// The production <see cref="NestDatabase"/> registration is replaced before anything
|
||||
/// resolves it, so tests never open the default data/nests.db or an OPENNEST_DB path.
|
||||
/// The update mirror never refreshes on its own and reaches GitHub only through
|
||||
/// <c>updateHandler</c> (by default a handler that fails every request); tests refresh it explicitly.
|
||||
/// Disposing the factory disposes the host (and its database) before deleting the directory.
|
||||
/// </summary>
|
||||
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||
{
|
||||
private bool _cleanedUp;
|
||||
private NestDatabase? _database;
|
||||
private readonly HttpMessageHandler? _updateHandler;
|
||||
|
||||
public ServerFactory(string? databasePath = null)
|
||||
public ServerFactory(string? databasePath = null, HttpMessageHandler? updateHandler = null)
|
||||
{
|
||||
_updateHandler = updateHandler;
|
||||
DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(DirectoryPath);
|
||||
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
|
||||
@@ -36,6 +41,13 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||
services.RemoveAll<NestDatabase>();
|
||||
// Register an externally owned instance; this factory closes it before deleting its file.
|
||||
services.AddSingleton(_database = new NestDatabase(DatabasePath, pooling: false));
|
||||
|
||||
services.RemoveAll<UpdateMirror>();
|
||||
var hosted = services.Where(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(UpdateMirrorService)).ToList();
|
||||
foreach (var descriptor in hosted)
|
||||
services.Remove(descriptor);
|
||||
services.AddSingleton(_ => new UpdateMirror(
|
||||
Path.Combine(DirectoryPath, "updates"), new HttpClient(_updateHandler ?? new OfflineHandler())));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,4 +62,10 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||
if (Directory.Exists(DirectoryPath))
|
||||
Directory.Delete(DirectoryPath, recursive: true);
|
||||
}
|
||||
|
||||
private sealed class OfflineHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
throw new HttpRequestException("Tests never reach GitHub.");
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ public sealed class SetReleaseDrawingTests
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
var kept = upgraded.GetRelease(releaseId)!;
|
||||
Assert.Empty(kept.Drawings);
|
||||
Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity));
|
||||
|
||||
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'"));
|
||||
var release = upgraded.GetRelease(releaseId)!;
|
||||
Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone));
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class SharedSettingsTests
|
||||
|
||||
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
|
||||
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Empty(upgraded.ListSharedSettings());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace OpenNest.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The desktop update feed the server mirrors from GitHub (<c>/updates/{channel}/</c>): which
|
||||
/// release each channel serves, verified downloads, cleanup, restarts and the HTTP contract.
|
||||
/// GitHub is a synthetic handler; nothing here reaches the network.
|
||||
/// </summary>
|
||||
public sealed class UpdateMirrorTests
|
||||
{
|
||||
private const string ReleasesUrl = "https://api.github.com/repos/ajisaacs/OpenNest/releases?per_page=10";
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_MirrorsNothingUntilAClientAsksForAChannel()
|
||||
{
|
||||
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
using var directory = new TempDirectory();
|
||||
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
|
||||
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
Assert.Empty(github.Requests);
|
||||
Assert.NotNull(mirror.GetStatus().LastChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_ServesTheStableFeedAndPackageAfterTheFirstRequestWakesARefresh()
|
||||
{
|
||||
var github = new FakeGitHub()
|
||||
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true)
|
||||
.WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
using var factory = new ServerFactory(updateHandler: github);
|
||||
using var client = factory.CreateClient();
|
||||
var mirror = factory.Services.GetRequiredService<UpdateMirror>();
|
||||
var woken = 0;
|
||||
mirror.RefreshRequested += () => woken++;
|
||||
|
||||
// Velopack appends its own query; the route ignores it.
|
||||
var before = await client.GetAsync("/updates/stable/releases.win.json?arch=x64&localVersion=0.6.0");
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, before.StatusCode);
|
||||
Assert.Equal(1, woken);
|
||||
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
var feed = await client.GetFromJsonAsync<JsonElement>("/updates/stable/releases.win.json");
|
||||
var asset = Assert.Single(feed.GetProperty("Assets").EnumerateArray().ToList());
|
||||
Assert.Equal("0.6.1", asset.GetProperty("Version").GetString());
|
||||
Assert.Equal("OpenNest-0.6.1-full.nupkg", asset.GetProperty("FileName").GetString());
|
||||
Assert.Equal(FakeGitHub.Hash("0.6.1"), asset.GetProperty("SHA256").GetString());
|
||||
Assert.Equal(FakeGitHub.Package("0.6.1"), await client.GetByteArrayAsync("/updates/stable/OpenNest-0.6.1-full.nupkg"));
|
||||
|
||||
// The development build is never fetched for a stable-only shop.
|
||||
Assert.DoesNotContain(github.Requests, r => r.Contains("dev-build"));
|
||||
Assert.Equal(1, github.Requests.Count(r => r.StartsWith("https://api.github.com/", StringComparison.Ordinal)));
|
||||
|
||||
var status = await client.GetFromJsonAsync<JsonElement>("/api/updates");
|
||||
Assert.Equal("0.6.1", status.GetProperty("stable").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, status.GetProperty("dev").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, status.GetProperty("lastError").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_RefusesUnknownChannelsAndFilesTheFeedDoesNotName()
|
||||
{
|
||||
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
using var factory = new ServerFactory(updateHandler: github);
|
||||
using var client = factory.CreateClient();
|
||||
var mirror = factory.Services.GetRequiredService<UpdateMirror>();
|
||||
mirror.GetFeed(UpdateMirror.Stable);
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/beta/releases.win.json")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/dev/OpenNest-0.6.1-full.nupkg")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/stable/OpenNest-0.6.0-full.nupkg")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/stable/..%2F..%2Fnests.db")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dev_ServesTheHigherOfTheDevelopmentBuildAndTheNewestRelease()
|
||||
{
|
||||
var github = new FakeGitHub()
|
||||
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true)
|
||||
.WithRelease("v0.6.1", "0.6.1", prerelease: false)
|
||||
.WithRelease("v0.5.1", null, prerelease: true); // Older pre-release with no feed.
|
||||
using var directory = new TempDirectory();
|
||||
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
|
||||
mirror.GetFeed(UpdateMirror.Dev);
|
||||
|
||||
await mirror.RefreshAsync();
|
||||
Assert.Equal("0.7.0-dev.2020", mirror.GetStatus().Dev);
|
||||
|
||||
// A full release published before the next development build outranks it.
|
||||
github.Clear()
|
||||
.WithRelease("v0.7.0", "0.7.0", prerelease: false)
|
||||
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true);
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
Assert.Equal("0.7.0", mirror.GetStatus().Dev);
|
||||
Assert.Null(mirror.GetStatus().Stable);
|
||||
Assert.Equal(new[] { "OpenNest-0.7.0-full.nupkg" }, PackageFiles(directory.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_DownloadsEachPackageOnceAndDeletesSupersededOnes()
|
||||
{
|
||||
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
using var directory = new TempDirectory();
|
||||
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
|
||||
mirror.GetFeed(UpdateMirror.Stable);
|
||||
|
||||
await mirror.RefreshAsync();
|
||||
await mirror.RefreshAsync();
|
||||
Assert.Equal(1, github.Requests.Count(r => r.EndsWith(".nupkg", StringComparison.Ordinal)));
|
||||
|
||||
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false).WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
Assert.Equal("0.6.2", mirror.GetStatus().Stable);
|
||||
Assert.Equal(new[] { "OpenNest-0.6.2-full.nupkg" }, PackageFiles(directory.Path));
|
||||
Assert.Null(mirror.GetPackagePath(UpdateMirror.Stable, "OpenNest-0.6.1-full.nupkg"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_RejectsAPackageThatDoesNotMatchItsFeedAndKeepsTheCurrentOne()
|
||||
{
|
||||
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
using var directory = new TempDirectory();
|
||||
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
|
||||
mirror.GetFeed(UpdateMirror.Stable);
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false, corrupt: true);
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
var status = mirror.GetStatus();
|
||||
Assert.Equal("0.6.1", status.Stable);
|
||||
Assert.Contains("SHA-256", status.LastError);
|
||||
Assert.Equal(new[] { "OpenNest-0.6.1-full.nupkg" }, PackageFiles(directory.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_KeepsServingWhenGitHubIsUnreachable()
|
||||
{
|
||||
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
using var directory = new TempDirectory();
|
||||
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
|
||||
mirror.GetFeed(UpdateMirror.Stable);
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
github.Clear();
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
Assert.NotNull(mirror.GetStatus().LastError);
|
||||
Assert.NotNull(mirror.GetFeed(UpdateMirror.Stable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restart_ServesTheSavedFeedAndKeepsRefreshingIt()
|
||||
{
|
||||
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
using var directory = new TempDirectory();
|
||||
var first = new UpdateMirror(directory.Path, new HttpClient(github));
|
||||
first.GetFeed(UpdateMirror.Stable);
|
||||
await first.RefreshAsync();
|
||||
|
||||
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false);
|
||||
var restarted = new UpdateMirror(directory.Path, new HttpClient(github));
|
||||
Assert.Equal("0.6.1", restarted.GetStatus().Stable);
|
||||
Assert.NotNull(restarted.GetPackagePath(UpdateMirror.Stable, "OpenNest-0.6.1-full.nupkg"));
|
||||
|
||||
await restarted.RefreshAsync();
|
||||
Assert.Equal("0.6.2", restarted.GetStatus().Stable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_StopsMirroringAChannelNobodyHasAskedForInAWeek()
|
||||
{
|
||||
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
|
||||
using var directory = new TempDirectory();
|
||||
var now = DateTimeOffset.Parse("2026-10-28T12:00:00Z");
|
||||
var mirror = new UpdateMirror(directory.Path, new HttpClient(github), clock: () => now);
|
||||
mirror.GetFeed(UpdateMirror.Stable);
|
||||
await mirror.RefreshAsync();
|
||||
var requests = github.Requests.Count;
|
||||
|
||||
now += UpdateMirror.WantedFor + TimeSpan.FromMinutes(1);
|
||||
await mirror.RefreshAsync();
|
||||
|
||||
Assert.Equal(requests, github.Requests.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0.7.0", "0.6.1", 1)]
|
||||
[InlineData("0.7.0-dev.2020", "0.6.1", 1)]
|
||||
[InlineData("0.7.0", "0.7.0-dev.2020", 1)]
|
||||
[InlineData("0.7.0-dev.10", "0.7.0-dev.9", 1)]
|
||||
[InlineData("0.7.0-dev.9", "0.7.0-dev", 1)]
|
||||
[InlineData("0.7.0-beta", "0.7.0-alpha", 1)]
|
||||
[InlineData("0.7.0-alpha", "0.7.0-1", 1)]
|
||||
[InlineData("0.7.0+abc", "0.7.0", 0)]
|
||||
[InlineData("0.10.0", "0.9.0", 1)]
|
||||
[InlineData("0.6.1", "not-a-version", 1)]
|
||||
public void SemanticVersion_FollowsSemVerPrecedence(string higher, string lower, int expected)
|
||||
{
|
||||
Assert.Equal(expected, SemanticVersion.Compare(higher, lower));
|
||||
Assert.Equal(-expected, SemanticVersion.Compare(lower, higher));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData("on", true)]
|
||||
[InlineData("off", false)]
|
||||
[InlineData(" OFF ", false)]
|
||||
public void Options_UpdatesAreOnUnlessTurnedOff(string? value, bool expected) =>
|
||||
Assert.Equal(expected, ServerOptions.UpdatesEnabled(value));
|
||||
|
||||
private static string[] PackageFiles(string directory) =>
|
||||
Directory.GetFiles(Path.Combine(directory, "packages")).Select(Path.GetFileName).OfType<string>().ToArray();
|
||||
|
||||
private sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "opennest-updates-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The GitHub release list, feeds and packages for synthetic versions, newest release first.</summary>
|
||||
private sealed class FakeGitHub : HttpMessageHandler
|
||||
{
|
||||
private readonly List<object> _releases = new();
|
||||
private readonly Dictionary<string, byte[]> _files = new(StringComparer.Ordinal);
|
||||
|
||||
public List<string> Requests { get; } = new();
|
||||
|
||||
public static byte[] Package(string version) => Encoding.UTF8.GetBytes($"package {version}");
|
||||
|
||||
public static string Hash(string version) => Convert.ToHexString(SHA256.HashData(Package(version)));
|
||||
|
||||
public FakeGitHub Clear()
|
||||
{
|
||||
_releases.Clear();
|
||||
_files.Clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <param name="version">The feed's version, or null for a release without a Velopack feed.</param>
|
||||
public FakeGitHub WithRelease(string tag, string? version, bool prerelease, bool corrupt = false)
|
||||
{
|
||||
var assets = new List<object>();
|
||||
if (version is not null)
|
||||
{
|
||||
var package = $"OpenNest-{version}-full.nupkg";
|
||||
var feed = JsonSerializer.Serialize(new
|
||||
{
|
||||
Assets = new[]
|
||||
{
|
||||
new { PackageId = "OpenNest", Version = version, Type = "Full", FileName = package, SHA1 = "00", SHA256 = Hash(version), Size = (long)Package(version).Length },
|
||||
},
|
||||
});
|
||||
assets.Add(Asset(tag, "releases.win.json", Encoding.UTF8.GetBytes(feed)));
|
||||
var bytes = Package(version);
|
||||
assets.Add(Asset(tag, package, corrupt ? bytes.Select(b => (byte)(b ^ 1)).ToArray() : bytes));
|
||||
}
|
||||
|
||||
assets.Add(Asset(tag, $"OpenNest.{tag}.win-x64.zip", new byte[] { 1 }));
|
||||
_releases.Add(new { tag_name = tag, draft = false, prerelease, assets });
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
Requests.Add(url);
|
||||
if (url == ReleasesUrl)
|
||||
{
|
||||
Assert.Contains("OpenNest-Server", request.Headers.UserAgent.ToString());
|
||||
if (_releases.Count == 0)
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden));
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(_releases) });
|
||||
}
|
||||
|
||||
return Task.FromResult(_files.TryGetValue(url, out var bytes)
|
||||
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }
|
||||
: new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
private object Asset(string tag, string name, byte[] contents)
|
||||
{
|
||||
var url = $"https://github.com/ajisaacs/OpenNest/releases/download/{tag}/{name}";
|
||||
_files[url] = contents;
|
||||
return new { name, browser_download_url = url };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,6 @@ internal static class NestArchive
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// NestReader: codes end at the first blank entry and at the plate's quantity.
|
||||
var platesOut = plates
|
||||
.Select((p, index) =>
|
||||
{
|
||||
@@ -98,10 +97,7 @@ internal static class NestArchive
|
||||
Width = System.Math.Min(size.Width, size.Length),
|
||||
Length = System.Math.Max(size.Width, size.Length),
|
||||
Quantity = p.Quantity,
|
||||
StockItemCodes = (p.StockItemCodes ?? new())
|
||||
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Take(System.Math.Max(p.Quantity, 0))
|
||||
.ToList(),
|
||||
StockItemCodes = PlateCodes(p).ToList(),
|
||||
ExpectedRemnant = ReadRemnant(p.ExpectedRemnant),
|
||||
};
|
||||
})
|
||||
@@ -121,6 +117,27 @@ internal static class NestArchive
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The distinct ledger item codes the archive's plates are coded with, in plate order, by
|
||||
/// the same rules as <see cref="ReadSheets"/>; empty when the bytes are unreadable.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ReadStockItemCodes(byte[] file)
|
||||
{
|
||||
if (Read(file, out var nest) is not null)
|
||||
return Array.Empty<string>();
|
||||
var drawingIds = (nest.Drawings ?? new()).Select(d => d.Id).ToHashSet();
|
||||
return NestPlates(nest, drawingIds)
|
||||
.SelectMany(p => PlateCodes(p))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// NestReader: codes end at the first blank entry and at the plate's quantity.
|
||||
private static IEnumerable<string> PlateCodes(PlateJson plate) =>
|
||||
(plate.StockItemCodes ?? new())
|
||||
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Take(System.Math.Max(plate.Quantity, 0));
|
||||
|
||||
/// <summary>The archive's units, for backfilling records stored before they were kept; inches when unreadable.</summary>
|
||||
public static Units ReadUnits(byte[] file) => Read(file, out var nest) is null ? UnitsOf(nest) : Units.Inches;
|
||||
|
||||
|
||||
@@ -138,9 +138,10 @@ public sealed partial class NestDatabase
|
||||
if (!query.IncludeArchived) conditions.Add("archived=0");
|
||||
if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")");
|
||||
if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer");
|
||||
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("materialKey=$material");
|
||||
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade");
|
||||
if (query.Thickness is not null) conditions.Add("thickness=$thickness");
|
||||
// A drawing saved without a material, grade or thickness (0) suits any, so it matches every filter value.
|
||||
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("(materialKey=$material OR materialKey='')");
|
||||
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("(gradeKey=$grade OR gradeKey='')");
|
||||
if (query.Thickness is not null) conditions.Add("(thickness=$thickness OR thickness=0)");
|
||||
if (query.CutType is not null) conditions.Add("cutType=$cutType");
|
||||
if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst");
|
||||
var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Server;
|
||||
|
||||
// The ledger item codes each nest's current archive is coded with, kept beside the record at
|
||||
// every archive write so listing the codes in use never reopens an archive.
|
||||
public sealed partial class NestDatabase
|
||||
{
|
||||
// v14: one row per distinct code of a nest's current archive.
|
||||
private const string StockCodeSchema = """
|
||||
CREATE TABLE IF NOT EXISTS nest_stock_codes (
|
||||
id TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
PRIMARY KEY (id, code)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_nest_stock_codes_code ON nest_stock_codes(code);
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// The item codes on the current archives of every non-deleted Draft, Quote or To Be Cut
|
||||
/// nest except <paramref name="excludeId"/>, ordered by code then nest name.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NestStockCodeUse> StockCodesInUse(Guid? excludeId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT c.code, n.id, n.name, n.status FROM nest_stock_codes c
|
||||
JOIN nests n ON n.id = c.id
|
||||
WHERE n.deleted = 0 AND n.status IN ('Draft', 'Quote', 'ToBeCut') AND n.id <> $exclude
|
||||
ORDER BY c.code, n.name COLLATE NOCASE, n.id
|
||||
""";
|
||||
command.Parameters.AddWithValue("$exclude", (excludeId ?? Guid.Empty).ToString());
|
||||
var uses = new List<NestStockCodeUse>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
uses.Add(new NestStockCodeUse
|
||||
{
|
||||
ItemCode = reader.GetString(0),
|
||||
NestId = Guid.Parse(reader.GetString(1)),
|
||||
NestName = reader.GetString(2),
|
||||
Status = Enum.TryParse<NestStatus>(reader.GetString(3), true, out var status) ? status : NestStatus.Quote,
|
||||
});
|
||||
}
|
||||
return uses;
|
||||
}
|
||||
}
|
||||
|
||||
// Called only under _sync with the caller's transaction, whenever a nest's current archive changes.
|
||||
private void WriteStockCodes(Guid id, byte[] file, SqliteTransaction transaction)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = "DELETE FROM nest_stock_codes WHERE id = $id";
|
||||
command.Parameters.AddWithValue("$id", id.ToString());
|
||||
command.ExecuteNonQuery();
|
||||
InsertStockCodes(command, id, NestArchive.ReadStockItemCodes(file));
|
||||
}
|
||||
|
||||
private static void InsertStockCodes(SqliteCommand command, Guid id, IEnumerable<string> codes)
|
||||
{
|
||||
command.CommandText = "INSERT OR IGNORE INTO nest_stock_codes (id, code) VALUES ($id, $code)";
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("$id", id.ToString());
|
||||
var code = command.Parameters.Add("$code", SqliteType.Text);
|
||||
foreach (var value in codes)
|
||||
{
|
||||
code.Value = value;
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
command.Parameters.Clear();
|
||||
}
|
||||
|
||||
// v14: reads each current archive once, deleted ones too so a recovered nest keeps its codes.
|
||||
private static void AddStockCodes(SqliteCommand command)
|
||||
{
|
||||
command.CommandText = StockCodeSchema;
|
||||
command.ExecuteNonQuery();
|
||||
var codes = new List<(Guid Id, IReadOnlyList<string> Codes)>();
|
||||
command.CommandText = "SELECT id, file FROM nests";
|
||||
using (var reader = command.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
codes.Add((Guid.Parse(reader.GetString(0)), NestArchive.ReadStockItemCodes((byte[])reader.GetValue(1))));
|
||||
}
|
||||
foreach (var (id, nestCodes) in codes.Where(c => c.Codes.Count > 0))
|
||||
InsertStockCodes(command, id, nestCodes);
|
||||
command.CommandText = "PRAGMA user_version=14";
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ public sealed partial class NestDatabase : IDisposable
|
||||
""";
|
||||
AddRecordParameters(command, id, record, file, updateFile: true);
|
||||
command.ExecuteNonQuery();
|
||||
WriteStockCodes(id, file, transaction);
|
||||
Snapshot(id, transaction);
|
||||
transaction.Commit();
|
||||
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
|
||||
@@ -220,6 +221,7 @@ public sealed partial class NestDatabase : IDisposable
|
||||
command.Parameters.AddWithValue("$revision", checked(currentRevision + 1));
|
||||
command.Parameters.AddWithValue("$source", (object?)sourceRevision ?? DBNull.Value);
|
||||
command.ExecuteNonQuery();
|
||||
WriteStockCodes(id, file, transaction);
|
||||
Snapshot(id, transaction);
|
||||
}
|
||||
|
||||
@@ -342,7 +344,7 @@ public sealed partial class NestDatabase : IDisposable
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = "PRAGMA user_version";
|
||||
var version = (long)command.ExecuteScalar()!;
|
||||
if (version > 13) throw new InvalidOperationException("Database schema is newer than this server.");
|
||||
if (version > 14) throw new InvalidOperationException("Database schema is newer than this server.");
|
||||
if (version == 0)
|
||||
{
|
||||
command.CommandText = """
|
||||
@@ -435,6 +437,8 @@ public sealed partial class NestDatabase : IDisposable
|
||||
command.CommandText = SharedSettingSchema;
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
if (version < 14)
|
||||
AddStockCodes(command);
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
|
||||
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
|
||||
|
||||
// Desktop update feed mirrored from GitHub beside the database; OPENNEST_UPDATES=off turns it off.
|
||||
var updatesEnabled = ServerOptions.UpdatesEnabled(Environment.GetEnvironmentVariable(ServerOptions.UpdatesVariable));
|
||||
if (updatesEnabled)
|
||||
{
|
||||
builder.Services.AddSingleton(_ => new UpdateMirror(ServerOptions.UpdatesPath(databasePath), new HttpClient()));
|
||||
builder.Services.AddHostedService<UpdateMirrorService>();
|
||||
}
|
||||
|
||||
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -59,6 +67,11 @@ app.MapGet("/api/nests/{id:guid}/sheets", (Guid id, NestDatabase db) =>
|
||||
: Results.Ok(sheets);
|
||||
});
|
||||
|
||||
// Ledger item codes on open nests' plates, so Auto Nest leaves them out until ML holds them.
|
||||
// The nest being edited passes its own id as exclude.
|
||||
app.MapGet("/api/nests/stock-item-codes", (Guid? exclude, NestDatabase db) =>
|
||||
Results.Ok(db.StockCodesInUse(exclude)));
|
||||
|
||||
// Next generated nest name for a year; one counter per year shared by every client.
|
||||
app.MapPost("/api/nest-names/next", (int year, NestDatabase db) =>
|
||||
{
|
||||
@@ -154,6 +167,8 @@ app.MapDrawingSets();
|
||||
app.MapReleases();
|
||||
app.MapMaterials();
|
||||
app.MapSettings();
|
||||
if (updatesEnabled)
|
||||
app.MapUpdates();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -4,6 +4,18 @@ namespace OpenNest.Server;
|
||||
public static class ServerOptions
|
||||
{
|
||||
public const string DatabaseVariable = "OPENNEST_DB";
|
||||
public const string UpdatesVariable = "OPENNEST_UPDATES";
|
||||
|
||||
/// <summary>
|
||||
/// True unless <c>OPENNEST_UPDATES</c> is <c>off</c>: the server mirrors the desktop
|
||||
/// app's update feed from GitHub (see <see cref="UpdateMirror"/>).
|
||||
/// </summary>
|
||||
public static bool UpdatesEnabled(string? value) =>
|
||||
!string.Equals(value?.Trim(), "off", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>The mirrored update feed lives in an <c>updates</c> directory beside the database.</summary>
|
||||
public static string UpdatesPath(string databasePath) =>
|
||||
Path.Combine(Path.GetDirectoryName(Path.GetFullPath(databasePath))!, "updates");
|
||||
|
||||
/// <summary>
|
||||
/// The database path: <c>--database=<path></c> or <c>--database <path></c>, then
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>
|
||||
/// A caching mirror of the desktop app's Velopack feed, so the workstations of one shop check
|
||||
/// for updates against their nest server instead of each calling GitHub. One refresh makes a
|
||||
/// single GitHub API request (the release list), reads the feeds attached to the chosen releases
|
||||
/// and downloads each channel's newest full package once, verified against the feed's size and
|
||||
/// SHA-256, into <see cref="DirectoryPath"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Channels: <c>stable</c> serves the newest full release; <c>dev</c> serves whichever is higher
|
||||
/// of that and the newest pre-release with a feed (the rolling development build). A channel is
|
||||
/// mirrored only while clients ask for it (within <see cref="WantedFor"/>), so a server whose
|
||||
/// workstations never take development builds never downloads one. Feeds are served with the
|
||||
/// package file names Velopack wrote, unchanged: clients download <c>{feed URL}/{FileName}</c>.
|
||||
/// </remarks>
|
||||
public sealed class UpdateMirror
|
||||
{
|
||||
public const string Stable = "stable";
|
||||
public const string Dev = "dev";
|
||||
public const string FeedFileName = "releases.win.json";
|
||||
public const string DefaultRepository = "ajisaacs/OpenNest";
|
||||
|
||||
/// <summary>How long a channel stays mirrored after the last client asked for it.</summary>
|
||||
public static readonly TimeSpan WantedFor = TimeSpan.FromDays(7);
|
||||
|
||||
private static readonly string[] Channels = { Stable, Dev };
|
||||
private static readonly JsonSerializerOptions FeedOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _repository;
|
||||
private readonly Func<DateTimeOffset> _clock;
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
private readonly object _stateLock = new();
|
||||
private readonly Dictionary<string, ChannelFeed> _feeds = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, DateTimeOffset> _wanted = new(StringComparer.Ordinal);
|
||||
private DateTimeOffset? _lastChecked;
|
||||
private string? _lastError;
|
||||
|
||||
public UpdateMirror(string directoryPath, HttpClient http, string repository = DefaultRepository, Func<DateTimeOffset>? clock = null)
|
||||
{
|
||||
DirectoryPath = directoryPath;
|
||||
_http = http;
|
||||
_repository = repository;
|
||||
_clock = clock ?? (() => DateTimeOffset.UtcNow);
|
||||
Directory.CreateDirectory(PackagesPath);
|
||||
LoadSavedFeeds();
|
||||
}
|
||||
|
||||
/// <summary>Raised when a client asks for a channel that holds no feed yet, so the next refresh need not wait.</summary>
|
||||
public event Action? RefreshRequested;
|
||||
|
||||
public string DirectoryPath { get; }
|
||||
|
||||
private string PackagesPath => Path.Combine(DirectoryPath, "packages");
|
||||
|
||||
public static bool IsChannel(string channel) => Array.IndexOf(Channels, channel) >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// The channel's feed for a client, or null while nothing is mirrored for it (the route answers
|
||||
/// 503 and the client falls back to GitHub). Every call marks the channel as wanted.
|
||||
/// </summary>
|
||||
public byte[]? GetFeed(string channel)
|
||||
{
|
||||
bool missing;
|
||||
byte[]? feed;
|
||||
lock (_stateLock)
|
||||
{
|
||||
_wanted[channel] = _clock();
|
||||
feed = _feeds.TryGetValue(channel, out var current) ? current.Json : null;
|
||||
missing = feed is null;
|
||||
}
|
||||
|
||||
if (missing)
|
||||
RefreshRequested?.Invoke();
|
||||
return feed;
|
||||
}
|
||||
|
||||
/// <summary>The path of a package a channel's feed currently names, or null for any other file name.</summary>
|
||||
public string? GetPackagePath(string channel, string fileName)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!_feeds.TryGetValue(channel, out var feed) || feed.FileName != fileName)
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = Path.Combine(PackagesPath, fileName);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
public UpdateMirrorStatus GetStatus()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return new UpdateMirrorStatus
|
||||
{
|
||||
Stable = _feeds.TryGetValue(Stable, out var stable) ? stable.Version : null,
|
||||
Dev = _feeds.TryGetValue(Dev, out var dev) ? dev.Version : null,
|
||||
LastChecked = _lastChecked,
|
||||
LastError = _lastError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings every wanted channel up to date with GitHub. Failures are recorded in
|
||||
/// <see cref="GetStatus"/> and leave the feeds already mirrored in place.
|
||||
/// </summary>
|
||||
public async Task RefreshAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _refreshLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var wanted = WantedChannels();
|
||||
if (wanted.Count > 0)
|
||||
await RefreshChannelsAsync(wanted, cancellationToken);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_lastChecked = _clock();
|
||||
_lastError = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_lastChecked = _clock();
|
||||
_lastError = ex.Message;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> WantedChannels()
|
||||
{
|
||||
var now = _clock();
|
||||
lock (_stateLock)
|
||||
return Channels.Where(c => _wanted.TryGetValue(c, out var at) && now - at <= WantedFor).ToList();
|
||||
}
|
||||
|
||||
private async Task RefreshChannelsAsync(List<string> channels, CancellationToken cancellationToken)
|
||||
{
|
||||
var releases = await GetJsonAsync<List<GitHubRelease>>(
|
||||
$"https://api.github.com/repos/{_repository}/releases?per_page=10", cancellationToken) ?? new();
|
||||
var published = releases.Where(r => !r.Draft && FeedAsset(r) is not null).ToList();
|
||||
|
||||
// The API lists the newest releases first.
|
||||
var stable = published.FirstOrDefault(r => !r.Prerelease);
|
||||
var preRelease = published.FirstOrDefault(r => r.Prerelease);
|
||||
var feeds = new Dictionary<GitHubRelease, Candidate?>();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
var sources = channel == Stable ? new[] { stable } : new[] { stable, preRelease };
|
||||
Candidate? best = null;
|
||||
foreach (var release in sources.OfType<GitHubRelease>())
|
||||
{
|
||||
if (!feeds.TryGetValue(release, out var candidate))
|
||||
feeds[release] = candidate = await ReadCandidateAsync(release, cancellationToken);
|
||||
if (candidate is not null && (best is null || SemanticVersion.Compare(candidate.Asset.Version, best.Asset.Version) > 0))
|
||||
best = candidate;
|
||||
}
|
||||
|
||||
if (best is not null)
|
||||
await PublishAsync(channel, best, cancellationToken);
|
||||
}
|
||||
|
||||
DeleteUnreferencedPackages();
|
||||
}
|
||||
|
||||
private async Task<Candidate?> ReadCandidateAsync(GitHubRelease release, CancellationToken cancellationToken)
|
||||
{
|
||||
var feed = await GetJsonAsync<VelopackFeed>(FeedAsset(release)!.DownloadUrl, cancellationToken);
|
||||
var full = feed?.Assets?
|
||||
.Where(a => a.Type == "Full" && IsPlainFileName(a.FileName) && SemanticVersion.IsValid(a.Version))
|
||||
.OrderByDescending(a => a.Version, SemanticVersion.Comparer)
|
||||
.FirstOrDefault();
|
||||
var package = full is null ? null : release.Assets.FirstOrDefault(a => a.Name == full.FileName);
|
||||
return package is null ? null : new Candidate(full!, package.DownloadUrl);
|
||||
}
|
||||
|
||||
private async Task PublishAsync(string channel, Candidate candidate, CancellationToken cancellationToken)
|
||||
{
|
||||
var asset = candidate.Asset;
|
||||
var packagePath = Path.Combine(PackagesPath, asset.FileName);
|
||||
if (!File.Exists(packagePath))
|
||||
await DownloadPackageAsync(candidate, packagePath, cancellationToken);
|
||||
|
||||
// Serve only the chosen package, as the feed Velopack wrote describes it.
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(new { Assets = new[] { asset } });
|
||||
var feedPath = Path.Combine(DirectoryPath, channel, FeedFileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(feedPath)!);
|
||||
WriteAtomically(feedPath, json);
|
||||
|
||||
lock (_stateLock)
|
||||
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
|
||||
}
|
||||
|
||||
private async Task DownloadPackageAsync(Candidate candidate, string packagePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var partial = packagePath + ".partial";
|
||||
try
|
||||
{
|
||||
using (var response = await _http.GetAsync(candidate.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var target = File.Create(partial);
|
||||
await source.CopyToAsync(target, cancellationToken);
|
||||
}
|
||||
|
||||
var asset = candidate.Asset;
|
||||
var length = new FileInfo(partial).Length;
|
||||
if (asset.Size > 0 && length != asset.Size)
|
||||
throw new InvalidDataException($"{asset.FileName} is {length} bytes; its feed says {asset.Size}.");
|
||||
if (!string.IsNullOrEmpty(asset.SHA256))
|
||||
{
|
||||
await using var stream = File.OpenRead(partial);
|
||||
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken));
|
||||
if (!hash.Equals(asset.SHA256, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"{asset.FileName} does not match its feed's SHA-256.");
|
||||
}
|
||||
|
||||
File.Move(partial, packagePath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(partial))
|
||||
File.Delete(partial);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteUnreferencedPackages()
|
||||
{
|
||||
HashSet<string> keep;
|
||||
lock (_stateLock)
|
||||
keep = _feeds.Values.Select(f => f.FileName).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(PackagesPath))
|
||||
{
|
||||
if (keep.Contains(Path.GetFileName(path)))
|
||||
continue;
|
||||
try { File.Delete(path); }
|
||||
catch (IOException) { } // Still being served; the next refresh retries.
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadSavedFeeds()
|
||||
{
|
||||
foreach (var channel in Channels)
|
||||
{
|
||||
var path = Path.Combine(DirectoryPath, channel, FeedFileName);
|
||||
if (!File.Exists(path))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllBytes(path);
|
||||
var asset = JsonSerializer.Deserialize<VelopackFeed>(json, FeedOptions)?.Assets?.SingleOrDefault();
|
||||
if (asset is null || !IsPlainFileName(asset.FileName) || !File.Exists(Path.Combine(PackagesPath, asset.FileName)))
|
||||
continue;
|
||||
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
|
||||
// A restart keeps serving what clients were already taking.
|
||||
_wanted[channel] = _clock();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException)
|
||||
{
|
||||
// An unreadable feed is rebuilt by the next refresh.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> GetJsonAsync<T>(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.UserAgent.ParseAdd("OpenNest-Server");
|
||||
if (url.StartsWith("https://api.github.com/", StringComparison.Ordinal))
|
||||
request.Headers.Accept.ParseAdd("application/vnd.github+json");
|
||||
using var response = await _http.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return await JsonSerializer.DeserializeAsync<T>(stream, FeedOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private static GitHubAsset? FeedAsset(GitHubRelease release) =>
|
||||
release.Assets.FirstOrDefault(a => a.Name == FeedFileName);
|
||||
|
||||
// Feed file names become paths under the packages directory and URL segments.
|
||||
private static bool IsPlainFileName(string? name) =>
|
||||
!string.IsNullOrEmpty(name) && name == Path.GetFileName(name) && name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
|
||||
&& name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static void WriteAtomically(string path, byte[] contents)
|
||||
{
|
||||
var temporary = path + ".tmp";
|
||||
File.WriteAllBytes(temporary, contents);
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
|
||||
private sealed record ChannelFeed(byte[] Json, string FileName, string Version);
|
||||
|
||||
private sealed record Candidate(VelopackFeedAsset Asset, string DownloadUrl);
|
||||
|
||||
private sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("draft")] public bool Draft { get; set; }
|
||||
[JsonPropertyName("prerelease")] public bool Prerelease { get; set; }
|
||||
[JsonPropertyName("assets")] public List<GitHubAsset> Assets { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("browser_download_url")] public string DownloadUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
private sealed class VelopackFeed
|
||||
{
|
||||
public List<VelopackFeedAsset>? Assets { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One entry of a Velopack <c>releases.{channel}.json</c>; properties keep Velopack's names.</summary>
|
||||
public sealed class VelopackFeedAsset
|
||||
{
|
||||
public string PackageId { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public string FileName { get; set; } = "";
|
||||
public string? SHA1 { get; set; }
|
||||
public string? SHA256 { get; set; }
|
||||
public long Size { get; set; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesMarkdown { get; set; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesHTML { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>GET /api/updates</c>: the versions each channel serves and the last refresh's outcome.</summary>
|
||||
public sealed class UpdateMirrorStatus
|
||||
{
|
||||
public string? Stable { get; init; }
|
||||
public string? Dev { get; init; }
|
||||
public DateTimeOffset? LastChecked { get; init; }
|
||||
public string? LastError { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>SemVer 2.0 precedence for feed versions such as <c>0.7.0-dev.2020</c>; build metadata is ignored.</summary>
|
||||
public static class SemanticVersion
|
||||
{
|
||||
public static readonly IComparer<string> Comparer = Comparer<string>.Create(Compare);
|
||||
|
||||
public static bool IsValid(string? version) => version is not null && TryParse(version, out _, out _);
|
||||
|
||||
public static int Compare(string? left, string? right)
|
||||
{
|
||||
if (!TryParse(left, out var leftCore, out var leftPre))
|
||||
return TryParse(right, out _, out _) ? -1 : 0;
|
||||
if (!TryParse(right, out var rightCore, out var rightPre))
|
||||
return 1;
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var core = leftCore[i].CompareTo(rightCore[i]);
|
||||
if (core != 0)
|
||||
return core;
|
||||
}
|
||||
|
||||
// A release outranks its pre-releases.
|
||||
if (leftPre.Length == 0 || rightPre.Length == 0)
|
||||
return (leftPre.Length == 0).CompareTo(rightPre.Length == 0);
|
||||
|
||||
for (var i = 0; i < System.Math.Min(leftPre.Length, rightPre.Length); i++)
|
||||
{
|
||||
var leftNumeric = long.TryParse(leftPre[i], out var leftNumber);
|
||||
var rightNumeric = long.TryParse(rightPre[i], out var rightNumber);
|
||||
var part = (leftNumeric, rightNumeric) switch
|
||||
{
|
||||
(true, true) => leftNumber.CompareTo(rightNumber),
|
||||
(true, false) => -1,
|
||||
(false, true) => 1,
|
||||
_ => string.CompareOrdinal(leftPre[i], rightPre[i]),
|
||||
};
|
||||
if (part != 0)
|
||||
return part;
|
||||
}
|
||||
|
||||
return leftPre.Length.CompareTo(rightPre.Length);
|
||||
}
|
||||
|
||||
private static bool TryParse(string? version, out long[] core, out string[] preRelease)
|
||||
{
|
||||
core = Array.Empty<long>();
|
||||
preRelease = Array.Empty<string>();
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return false;
|
||||
|
||||
var withoutMetadata = version.Split('+')[0];
|
||||
var dash = withoutMetadata.IndexOf('-');
|
||||
var numbers = (dash < 0 ? withoutMetadata : withoutMetadata[..dash]).Split('.');
|
||||
if (numbers.Length != 3)
|
||||
return false;
|
||||
|
||||
core = new long[3];
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
if (!long.TryParse(numbers[i], out core[i]) || core[i] < 0)
|
||||
return false;
|
||||
}
|
||||
|
||||
preRelease = dash < 0 ? Array.Empty<string>() : withoutMetadata[(dash + 1)..].Split('.');
|
||||
return preRelease.All(p => p.Length > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the <see cref="UpdateMirror"/> every <see cref="Interval"/>, and at once when a
|
||||
/// client asks for a channel the mirror does not hold yet.
|
||||
/// </summary>
|
||||
public sealed class UpdateMirrorService : BackgroundService
|
||||
{
|
||||
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly UpdateMirror _mirror;
|
||||
private readonly SemaphoreSlim _wake = new(0, 1);
|
||||
|
||||
public UpdateMirrorService(UpdateMirror mirror)
|
||||
{
|
||||
_mirror = mirror;
|
||||
_mirror.RefreshRequested += Wake;
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_mirror.RefreshRequested -= Wake;
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await _mirror.RefreshAsync(stoppingToken);
|
||||
try { await _wake.WaitAsync(Interval, stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private void Wake()
|
||||
{
|
||||
try { _wake.Release(); }
|
||||
catch (SemaphoreFullException) { } // A wake is already pending.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>
|
||||
/// <c>/updates/{channel}/</c>: the desktop app's Velopack feed, mirrored from GitHub by
|
||||
/// <see cref="UpdateMirror"/>. Velopack's <c>SimpleWebSource</c> reads
|
||||
/// <c>releases.win.json</c> (ignoring the query it appends) and then the package it names. A
|
||||
/// channel with nothing mirrored yet answers 503 so clients fall back to GitHub for that check.
|
||||
/// </summary>
|
||||
internal static class UpdateRoutes
|
||||
{
|
||||
public static void MapUpdates(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/updates", (UpdateMirror mirror) => Results.Ok(mirror.GetStatus()));
|
||||
|
||||
app.MapGet("/updates/{channel}/{file}", (string channel, string file, UpdateMirror mirror) =>
|
||||
{
|
||||
if (!UpdateMirror.IsChannel(channel))
|
||||
return Results.NotFound();
|
||||
|
||||
if (file == UpdateMirror.FeedFileName)
|
||||
{
|
||||
return mirror.GetFeed(channel) is { } feed
|
||||
? Results.Bytes(feed, "application/json")
|
||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return mirror.GetPackagePath(channel, file) is { } path
|
||||
? Results.File(path, "application/octet-stream", enableRangeProcessing: true)
|
||||
: Results.NotFound();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,7 @@ OPENNEST_HOST_PORT=8090
|
||||
|
||||
# Docker volume holding nests.db; change only to switch to a verified restore.
|
||||
OPENNEST_DATA_VOLUME=opennest-data
|
||||
|
||||
# Desktop update mirror: the server checks GitHub Releases (outbound HTTPS to api.github.com
|
||||
# and github.com) and serves updates to Database-mode workstations. "off" turns it off.
|
||||
OPENNEST_UPDATES=on
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Posts.Cincinnati;
|
||||
using OpenNest.Posts.CincinnatiCIFiber;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Both Cincinnati posts raise the head before a rapid Plan Cutting marked to pass over parts
|
||||
/// already cut, whatever the operator's head-raise settings; the CI Fiber post refuses the nest
|
||||
/// while its head-raise block is not set.
|
||||
/// </summary>
|
||||
public class HeadRaisePostTests
|
||||
{
|
||||
private const string RaiseCode = "M210 (RAISE HEAD)";
|
||||
|
||||
[Theory]
|
||||
[InlineData(M47Mode.None)]
|
||||
[InlineData(M47Mode.BlockDelete)]
|
||||
[InlineData(M47Mode.Auto)]
|
||||
public void ClPost_RaisesBeforeAMarkedRapid_WhateverTheM47Mode(M47Mode mode)
|
||||
{
|
||||
var lines = ClLines(Nest(markSecond: true), mode);
|
||||
|
||||
var second = lines.FindIndex(l => l.Contains("G0 X10.75 Y1"));
|
||||
Assert.True(second > 0, string.Join("\n", lines));
|
||||
Assert.Equal("M47", lines[second - 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClPost_WithoutAMarker_FollowsTheM47Mode()
|
||||
{
|
||||
var lines = ClLines(Nest(markSecond: false), M47Mode.None);
|
||||
|
||||
var second = lines.FindIndex(l => l.Contains("G0 X10.75 Y1"));
|
||||
Assert.True(second > 0, string.Join("\n", lines));
|
||||
Assert.DoesNotContain("M47", lines[second - 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CiFiberPost_WritesTheRaiseBeforeOnlyTheMarkedRapid()
|
||||
{
|
||||
var post = new CIFiberPostProcessor(new CIFiberPostConfig { HeadRaiseCode = RaiseCode });
|
||||
using var stream = new MemoryStream();
|
||||
post.Post(Nest(markSecond: true), stream);
|
||||
var lines = Lines(stream);
|
||||
|
||||
var raise = Assert.Single(lines, l => l == RaiseCode);
|
||||
Assert.Equal("G0X10.75Y1", lines[lines.IndexOf(raise) + 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CiFiberPost_WithoutARaiseCode_RefusesAMarkedNest()
|
||||
{
|
||||
var post = new CIFiberPostProcessor(new CIFiberPostConfig());
|
||||
|
||||
var refusal = Assert.Throws<InvalidOperationException>(() => post.Post(Nest(markSecond: true), new MemoryStream()));
|
||||
|
||||
Assert.Contains("head-raise code", refusal.Message);
|
||||
post.Post(Nest(markSecond: false), new MemoryStream()); // Nothing marked: nothing to refuse.
|
||||
}
|
||||
|
||||
// Two squares side by side, each with a line lead-in on its left; the second one's rapid marked.
|
||||
private static Nest Nest(bool markSecond)
|
||||
{
|
||||
var nest = new Nest("RAISE");
|
||||
var plate = new Plate(60, 120);
|
||||
nest.Plates.Add(plate);
|
||||
foreach (var x in new[] { 1.0, 11.0 })
|
||||
{
|
||||
var part = new Part(new Drawing("square", Square()), new Vector(x, 0));
|
||||
var program = LeadIn();
|
||||
if (markSecond && x > 1)
|
||||
program = HeadRaise.Mark(program, [0]);
|
||||
Assert.True(part.RestoreLeadInProgram(program, true));
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
return nest;
|
||||
}
|
||||
|
||||
private static Program Square()
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0); p.LineTo(0, 2); p.LineTo(2, 2); p.LineTo(2, 0); p.LineTo(0, 0);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Program LeadIn()
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(-0.25, 1);
|
||||
p.Codes.Add(new LinearMove(0, 1) { Layer = LayerType.Leadin });
|
||||
p.LineTo(0, 2); p.LineTo(2, 2); p.LineTo(2, 0); p.LineTo(0, 0); p.LineTo(0, 1);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static List<string> ClLines(Nest nest, M47Mode mode)
|
||||
{
|
||||
var post = new CincinnatiPostProcessor(new CincinnatiPostConfig { ExteriorM47 = mode, InteriorM47 = mode });
|
||||
using var stream = new MemoryStream();
|
||||
post.Post(nest, stream);
|
||||
return Lines(stream);
|
||||
}
|
||||
|
||||
private static List<string> Lines(MemoryStream stream) =>
|
||||
Encoding.UTF8.GetString(stream.ToArray()).Replace("\r\n", "\n").Split('\n').Select(l => l.Trim()).ToList();
|
||||
}
|
||||
@@ -19,4 +19,17 @@ public class ClampZonePreviewTests
|
||||
Assert.Equal(2, preview.ClampZones.Count);
|
||||
Assert.Null(preview.ClampRail); // a detached display copy never re-resolves
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviewCarriesThePlatesSupportBed()
|
||||
{
|
||||
var plate = BestEffortCuttingPlanTests.Plate(BestEffortCuttingPlanTests.TouchingContours());
|
||||
plate.SupportBed = new OpenNest.Support.SupportGrid(25, 100, 2, 2, stabilityMargin: 0.1);
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||
var preview = proposal.BuildPreview(0);
|
||||
|
||||
Assert.NotNull(preview);
|
||||
Assert.Same(plate.SupportBed, preview.SupportBed);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,97 @@ public class ContourEntryCandidateTests
|
||||
Assert.Equal(8, forward.Count);
|
||||
}
|
||||
|
||||
/// <summary>Square 0..10 whose corners are 0.5-radius fillets, counterclockwise or clockwise.</summary>
|
||||
internal static Program RoundedSquare(bool clockwise)
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(0.5, 0);
|
||||
if (clockwise)
|
||||
{
|
||||
p.ArcTo(new Vector(0, 0.5), new Vector(0.5, 0.5), RotationType.CW);
|
||||
p.LineTo(0, 9.5);
|
||||
p.ArcTo(new Vector(0.5, 10), new Vector(0.5, 9.5), RotationType.CW);
|
||||
p.LineTo(9.5, 10);
|
||||
p.ArcTo(new Vector(10, 9.5), new Vector(9.5, 9.5), RotationType.CW);
|
||||
p.LineTo(10, 0.5);
|
||||
p.ArcTo(new Vector(9.5, 0), new Vector(9.5, 0.5), RotationType.CW);
|
||||
p.LineTo(0.5, 0);
|
||||
return p;
|
||||
}
|
||||
p.LineTo(9.5, 0);
|
||||
p.ArcTo(new Vector(10, 0.5), new Vector(9.5, 0.5), RotationType.CCW);
|
||||
p.LineTo(10, 9.5);
|
||||
p.ArcTo(new Vector(9.5, 10), new Vector(9.5, 9.5), RotationType.CCW);
|
||||
p.LineTo(0.5, 10);
|
||||
p.ArcTo(new Vector(0, 9.5), new Vector(0.5, 9.5), RotationType.CCW);
|
||||
p.LineTo(0, 0.5);
|
||||
p.ArcTo(new Vector(0.5, 0), new Vector(0.5, 0.5), RotationType.CCW);
|
||||
return p;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CornerFilletJoints_AreConvexCorners(bool clockwise)
|
||||
{
|
||||
var prepared = Capture(RoundedSquare(clockwise));
|
||||
|
||||
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
|
||||
|
||||
// Both joints of each fillet start the cut at its corner, one radius from the sharp corner.
|
||||
var corners = candidates.Where(c => c.Kind == AutomaticEntryKind.ConvexCorner).ToList();
|
||||
Assert.Equal(8, corners.Count);
|
||||
Assert.All(corners, c => Assert.Equal(0.5, c.CornerReach, 9));
|
||||
Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(10, 9.5)) < 1e-6);
|
||||
Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(9.5, 10)) < 1e-6);
|
||||
Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TangentJoint);
|
||||
Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.StraightMidpoint));
|
||||
Assert.All(candidates.Where(c => c.Kind == AutomaticEntryKind.StraightMidpoint),
|
||||
c => Assert.Equal(0, c.CornerReach));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilletLongerThanItsEdge_StaysATangentJoint()
|
||||
{
|
||||
// A 5-radius quarter arc after a 1-long bottom edge is a curve, not a rounded corner.
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0);
|
||||
p.LineTo(1, 0);
|
||||
p.ArcTo(new Vector(6, 5), new Vector(1, 5), RotationType.CCW);
|
||||
p.LineTo(6, 6);
|
||||
p.LineTo(0, 6);
|
||||
p.LineTo(0, 0);
|
||||
var prepared = Capture(p);
|
||||
|
||||
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
|
||||
|
||||
Assert.Equal(2, candidates.Count(c => c.Kind == AutomaticEntryKind.TangentJoint));
|
||||
Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.ConvexCorner
|
||||
&& (Distance(c.Choice.Point, new Vector(1, 0)) < 1e-6 || Distance(c.Choice.Point, new Vector(6, 5)) < 1e-6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsideCornerFillet_StaysATangentJoint()
|
||||
{
|
||||
// An L whose inside corner is rounded: that fillet turns the reflex way.
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0);
|
||||
p.LineTo(10, 0);
|
||||
p.LineTo(10, 4);
|
||||
p.LineTo(5, 4);
|
||||
p.ArcTo(new Vector(4, 5), new Vector(5, 5), RotationType.CW);
|
||||
p.LineTo(4, 10);
|
||||
p.LineTo(0, 10);
|
||||
p.LineTo(0, 0);
|
||||
var prepared = Capture(p);
|
||||
|
||||
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
|
||||
|
||||
var joints = candidates.Where(c => c.Kind == AutomaticEntryKind.TangentJoint).ToList();
|
||||
Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(5, 4)) < 1e-6);
|
||||
Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(4, 5)) < 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TangentLineArcJoint_IsTangentJoint_NotACorner()
|
||||
{
|
||||
|
||||
@@ -93,6 +93,22 @@ public class ContourEntryRankingTests
|
||||
Assert.True(rightMid < bottomMid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void FilletedCorner_BeatsAFacingMidpointNearerTheRoute(bool clockwise)
|
||||
{
|
||||
// Travel favours the right edge's midpoint, but a long edge's middle is a last resort:
|
||||
// the joints of the facing rounded corner count on both of its sides and lead.
|
||||
var ranked = Catalogue(ContourEntryCandidateTests.RoundedSquare(clockwise))
|
||||
.RankTowardNextCut(new Vector(14, 6), new Vector(12, 5)).ToList();
|
||||
|
||||
Assert.Equal(AutomaticEntryKind.ConvexCorner, ranked[0].Kind);
|
||||
Assert.True(At(ranked[0], 10, 9.5) || At(ranked[0], 9.5, 10));
|
||||
var rightMid = ranked.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
|
||||
Assert.True(ranked.FindIndex(c => At(c, 10, 0.5)) < rightMid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TangentAndMidpoint_ShareARankTier()
|
||||
{
|
||||
|
||||
@@ -109,6 +109,65 @@ public class CutOffSegmentPlanningTests
|
||||
Assert.Equal(result.RapidDistance, kept.RapidDistance, 6);
|
||||
}
|
||||
|
||||
// A frame with two small holes on its left strip, one above the other, and a window,
|
||||
// between a locked square A at the top left, cut before it, and a locked square B to its
|
||||
// right, cut after it. A line through the frame's right strip stops halfway up, so its one
|
||||
// segment runs from the bottom edge to just below the frame and is cut straight before it.
|
||||
// Planned without the segment, from A, the frame would cut its upper hole first; the tool
|
||||
// is really at the bottom, so it cuts the lower hole first instead of crossing its own
|
||||
// rapid. Replanned with the order kept, the frame plans the same holes from the same point.
|
||||
[Fact]
|
||||
public void PartAfterSegment_PlansItsHolesFromTheSegmentsEnd()
|
||||
{
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Size = new Size(60, 120);
|
||||
plate.Parts.Add(LockedSquare("A", new Vector(2, 42), topLeft: false));
|
||||
var frame = new Part(new Drawing("frame", Frame()), new Vector(30, 5));
|
||||
plate.Parts.Add(frame);
|
||||
plate.Parts.Add(LockedSquare("B", new Vector(80, 40), topLeft: false));
|
||||
var cutOff = new CutOff(new Vector(65, 0), CutOffAxis.Vertical) { EndLimit = 25 };
|
||||
plate.CutOffs.Add(cutOff);
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
PlateSequencing.PlaceCutOffs(plate, [cutOff]);
|
||||
Assert.Single(CutOffSegments.Of(plate, cutOff));
|
||||
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters));
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed, Describe(result));
|
||||
var order = result.ProposedOrder.Select(o => o.SourcePart.BaseDrawing.IsCutOff ? "s" : o.SourcePart.BaseDrawing.Name);
|
||||
Assert.Equal("A s frame B", string.Join(" ", order));
|
||||
var planned = result.ProposedOrder[2];
|
||||
var rapids = planned.Execution.Motions.Where(m => m.Rapid).Select(m => m.End).ToArray();
|
||||
// Into the lower hole (y 14.5 to 15.1 on the sheet), then the upper one (34.5 to 35.1).
|
||||
Assert.InRange(rapids[0].Y, 14, 16);
|
||||
Assert.InRange(rapids[1].Y, 34, 36);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
|
||||
var kept = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters,
|
||||
preservePartOrder: true));
|
||||
Assert.True(kept.Status == CuttingPlanStatus.Ready && kept.IndependentlyReplayed, Describe(kept));
|
||||
Assert.Equal(result.RapidDistance, kept.RapidDistance, 6);
|
||||
}
|
||||
|
||||
// 40 x 40 with 0.6 square holes at (2.5, 9.5) and (2.5, 29.5) and an 18 x 30 window.
|
||||
private static Program Frame()
|
||||
{
|
||||
var program = new Program();
|
||||
void Loop(params Vector[] points)
|
||||
{
|
||||
program.MoveTo(points[0].X, points[0].Y);
|
||||
foreach (var point in points.Skip(1).Append(points[0]))
|
||||
program.LineTo(point.X, point.Y);
|
||||
}
|
||||
Loop(new(0, 0), new(0, 40), new(40, 40), new(40, 0));
|
||||
foreach (var y in new[] { 29.5, 9.5 })
|
||||
Loop(new(2.5, y), new(3.1, y), new(3.1, y + 0.6), new(2.5, y + 0.6));
|
||||
Loop(new(12, 5), new(30, 5), new(30, 35), new(12, 35));
|
||||
return program;
|
||||
}
|
||||
|
||||
private const double Gap = 0.25, Width = 10.6, Neck = 5.6, Height = 20, Margin = 0.25;
|
||||
// The slanted shoulders need more horizontal room than the gap.
|
||||
private const double Pitch = (Width + Neck) / 2 + 2 * Gap;
|
||||
|
||||
@@ -79,6 +79,43 @@ public class CuttingDependencyTests
|
||||
&& f.Message.Contains("before the sheet is separated"));
|
||||
}
|
||||
|
||||
// A column of two wide parts under three narrow ones, beside a sheet separator at X 12.5
|
||||
// pierced at (12.5, 0). Climbing the column ends at the top, where the straight approach to
|
||||
// that pierce crosses the wide parts, and the separator cannot be cut any earlier. The search
|
||||
// learns the other way round, the part the approach left from before the parts it crossed,
|
||||
// and so finishes beside the pierce. It used to learn nothing and spend its whole budget.
|
||||
[Fact]
|
||||
public void Plan_ApproachIntoASeparatorCrossingCutParts_LearnsToLeaveFromBeyondThem()
|
||||
{
|
||||
var (plate, column, separator) = ColumnBesideSeparator();
|
||||
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
|
||||
Assert.Same(separator, order[^1]);
|
||||
Assert.Contains(order[^2], column[..2]);
|
||||
Assert.True(result.Expansions < 400, Describe(result));
|
||||
}
|
||||
|
||||
private static (Plate, Part[] Column, Part Separator) ColumnBesideSeparator()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Size = new Size(100, 100);
|
||||
Part[] column =
|
||||
[
|
||||
Rectangle(2, 2, 10, 10), Rectangle(2, 14, 10, 10),
|
||||
Rectangle(2, 26, 5, 10), Rectangle(2, 38, 5, 10), Rectangle(2, 50, 5, 10),
|
||||
];
|
||||
foreach (var part in column)
|
||||
plate.Parts.Add(part);
|
||||
plate.CutOffs.Add(new CutOff(new Vector(12.5, 0), CutOffAxis.Vertical));
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
return (plate, column, plate.Parts.Single(p => p.BaseDrawing.IsCutOff));
|
||||
}
|
||||
|
||||
// The tour cuts B, then the locked rectangle A (leaving right), then the cutoff pierced at
|
||||
// (10, 0) below-left of A: that approach crosses A. Replanning B's entries can never fix it,
|
||||
// so the search learns "cutoff first" at once instead of retrying each of B's entries.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Plan Cutting raises the head over cut parts only as a last resort: a route that keeps the
|
||||
/// head down always wins, and only the rapids no route could keep clear are marked.
|
||||
/// </summary>
|
||||
public class HeadRaisePlanningTests
|
||||
{
|
||||
[Fact]
|
||||
public void KeptOrderThatMustCrossAWall_IsReadyWithOneRaise()
|
||||
{
|
||||
// A tall wall is cut first, then the square left of it, then the one right of it:
|
||||
// every rapid from the left square to the right one passes over the wall.
|
||||
var (nest, plate) = WallPlate();
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: true).Plan();
|
||||
var result = proposal.Plates[0].Result;
|
||||
|
||||
Assert.True(proposal.Plates[0].IsReady, string.Join("\n", proposal.Describe("in")));
|
||||
Assert.Equal(1, result.HeadRaises);
|
||||
Assert.False(result.IsSafestRouteFallback);
|
||||
var note = Assert.Single(result.Findings);
|
||||
Assert.Contains("raises the head", note.Message);
|
||||
Assert.Equal(0, note.OtherSourceOrdinal); // The wall.
|
||||
Assert.Equal(new[] { false, false, true },
|
||||
result.ProposedOrder.Select(p => HeadRaise.IsMarked(p.CopyProgram())));
|
||||
Assert.Contains(proposal.Describe("in"), line => line.Contains("1 rapid raises the head"));
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
|
||||
Assert.True(HeadRaise.IsMarked(plate.Parts[2].Program));
|
||||
// The pre-post check accepts the raised crossing; nothing else crosses.
|
||||
Assert.DoesNotContain(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
|
||||
f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreeOrder_CutsAroundTheWall_WithoutRaisingTheHead()
|
||||
{
|
||||
var (_, plate) = WallPlate();
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: false).Plan();
|
||||
var result = proposal.Plates[0].Result;
|
||||
|
||||
Assert.True(proposal.Plates[0].IsReady, string.Join("\n", proposal.Describe("in")));
|
||||
Assert.Equal(0, result.HeadRaises);
|
||||
Assert.All(result.ProposedOrder, p => Assert.False(HeadRaise.IsMarked(p.CopyProgram())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrictPlan_NeverMarksARapid()
|
||||
{
|
||||
var (_, plate) = WallPlate();
|
||||
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||
confirmedParameters: ExplicitContourTests.Parameters(), preservePartOrder: true));
|
||||
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Equal(0, result.HeadRaises);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Checker_SetsARaisedRapidsCrossingsApart()
|
||||
{
|
||||
var wall = LeadPathValidationTests.Rectangle(3, 0, 5, 22);
|
||||
var rapid = new Program();
|
||||
rapid.Codes.Add(new Comment(HeadRaise.Marker));
|
||||
rapid.MoveTo(8, 11);
|
||||
rapid.LineTo(8, 12);
|
||||
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||
checker.Check(ExecutionMotionReader.Read(wall, Vector.Zero, null, default), null, 1);
|
||||
|
||||
var execution = ExecutionMotionReader.Read(rapid, Vector.Zero, new Vector(0, 11), default);
|
||||
var (strict, _, raised) = checker.Check(execution, new Vector(0, 11), 2, false, null, default);
|
||||
|
||||
Assert.True(execution.Motions[0].HeadRaised);
|
||||
Assert.Equal(1, execution.Motions[0].Code);
|
||||
Assert.Empty(strict);
|
||||
Assert.Contains(raised, f => f.Kind == PostVerificationKind.RapidCrossing && f.OtherPartNumber == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marker_MustBeFollowedByARapid()
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new Comment(HeadRaise.Marker));
|
||||
program.LineTo(1, 1);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => HeadRaise.RaisedMask(program.Codes));
|
||||
Assert.Throws<ArgumentException>(() => ExecutionMotionReader.Read(program, Vector.Zero, null, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mark_CopiesTheProgramWithAMarkerBeforeEachRapid()
|
||||
{
|
||||
var program = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
|
||||
|
||||
var marked = HeadRaise.Mark(program, [0]);
|
||||
|
||||
Assert.False(HeadRaise.IsMarked(program));
|
||||
Assert.True(HeadRaise.IsMarker(marked.Codes[0]));
|
||||
Assert.IsType<RapidMove>(marked.Codes[1]);
|
||||
Assert.Throws<ArgumentException>(() => HeadRaise.Mark(marked, [1])); // Already marked.
|
||||
Assert.Throws<ArgumentException>(() => HeadRaise.Mark(program, [1])); // Not a rapid.
|
||||
}
|
||||
|
||||
// Plate order: the wall, the square left of it, the square right of it.
|
||||
private static (Nest Nest, Plate Plate) WallPlate()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Size = new Size(40, 40);
|
||||
plate.Parts.Add(new Part(new Drawing("wall", LeadPathValidationTests.Rectangle(0, 0, 2, 22)), new Vector(5, 1)));
|
||||
plate.Parts.Add(new Part(new Drawing("left", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), new Vector(1, 11)));
|
||||
plate.Parts.Add(new Part(new Drawing("right", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), new Vector(9, 11)));
|
||||
return (nest, plate);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ namespace OpenNest.Tests.CuttingPlanning;
|
||||
/// <summary>
|
||||
/// Plans every nest on a dedicated problem-nest OpenNest server and fails when a plate's outcome is
|
||||
/// worse than its recorded baseline. A nest with a cut-off baseline is planned a second time with
|
||||
/// its applied lead-ins and cut-offs cleared and default scrap cut-offs added. Opt-in and private:
|
||||
/// its applied lead-ins and cut-offs cleared and default scrap cut-offs added. A "checks:" comment
|
||||
/// line names the planner areas a nest guards; a run limited to some areas skips nests that guard
|
||||
/// none of them, and a nest without the line is always planned. Opt-in and private:
|
||||
/// the nests and their baselines stay on the server, never in the repository. See docs/problem-nests.md.
|
||||
/// </summary>
|
||||
[Trait("Category", "ProblemNests")]
|
||||
@@ -21,9 +23,14 @@ public partial class ProblemNestRegressionTests(ITestOutputHelper output)
|
||||
internal const string ServerVariable = "OPENNEST_PROBLEM_NESTS_SERVER";
|
||||
internal const string TimeoutVariable = "OPENNEST_PROBLEM_NESTS_TIMEOUT";
|
||||
internal const string CutOffsVariable = "OPENNEST_PROBLEM_NESTS_CUTOFFS";
|
||||
internal const string ChecksVariable = "OPENNEST_PROBLEM_NESTS_CHECKS";
|
||||
internal const string Timeout = "Timeout";
|
||||
internal const string PlanningLine = "planning:";
|
||||
internal const string CutOffPlanningLine = "planning with cut-offs:";
|
||||
internal const string ChecksLine = "checks:";
|
||||
|
||||
/// <summary>The planner areas a nest can guard; docs/problem-nests.md says which changes touch each.</summary>
|
||||
internal static readonly string[] Areas = ["part-order", "entries", "contours", "geometry-checks", "cut-offs", "grid"];
|
||||
// A corpus is a handful of nests; more means the variable names a production archive.
|
||||
internal const int MaximumNests = 200;
|
||||
|
||||
@@ -37,10 +44,19 @@ public partial class ProblemNestRegressionTests(ITestOutputHelper output)
|
||||
var limit = TimeSpan.FromSeconds(int.TryParse(Environment.GetEnvironmentVariable(TimeoutVariable), out var seconds)
|
||||
&& seconds > 0 ? seconds : 120);
|
||||
Assert.True(records.Count > 0, $"No nests on {server}.");
|
||||
var wanted = Checks(Environment.GetEnvironmentVariable(ChecksVariable));
|
||||
|
||||
var failures = new List<string>();
|
||||
foreach (var record in records)
|
||||
{
|
||||
var guards = Checks(CommentLine(record.Comments, ChecksLine));
|
||||
if (!Selected(guards, wanted))
|
||||
{
|
||||
output.WriteLine($"{record.Name}: skipped, checks {string.Join(", ", guards)}");
|
||||
continue;
|
||||
}
|
||||
if (guards.Length == 0)
|
||||
output.WriteLine($"{record.Name}: no {ChecksLine} line, so always planned; add one naming the areas it guards.");
|
||||
var bytes = await repository.GetFileAsync(record.Id)
|
||||
?? throw new InvalidOperationException($"'{record.Name}' has no file.");
|
||||
var baseline = Baseline(record.Comments);
|
||||
@@ -143,14 +159,34 @@ public partial class ProblemNestRegressionTests(ITestOutputHelper output)
|
||||
/// Plate number to outcome, from a "planning: 1=Ready 2=BestEffort" comment line, or from the
|
||||
/// line starting with <paramref name="prefix"/>.
|
||||
/// </summary>
|
||||
internal static Dictionary<int, string> Baseline(string comments, string prefix = PlanningLine)
|
||||
{
|
||||
var line = (comments ?? "").Split('\n').Select(l => l.Trim())
|
||||
.FirstOrDefault(l => l.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
return line == null ? []
|
||||
internal static Dictionary<int, string> Baseline(string comments, string prefix = PlanningLine) =>
|
||||
CommentLine(comments, prefix) is not { } line ? []
|
||||
: BaselineEntry().Matches(line).ToDictionary(m => int.Parse(m.Groups[1].Value), m => m.Groups[2].Value);
|
||||
|
||||
// The rest of the first comment line starting with the prefix; null when there is none.
|
||||
private static string CommentLine(string comments, string prefix) =>
|
||||
(comments ?? "").Split('\n').Select(l => l.Trim())
|
||||
.FirstOrDefault(l => l.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))?[prefix.Length..];
|
||||
|
||||
/// <summary>
|
||||
/// The areas in a comma- or space-separated list, such as "part-order, entries". An area not in
|
||||
/// <see cref="Areas"/> throws, so a misspelt line or filter cannot quietly skip a nest.
|
||||
/// </summary>
|
||||
internal static string[] Checks(string list)
|
||||
{
|
||||
var areas = (list ?? "").Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(a => a.ToLowerInvariant()).Distinct().ToArray();
|
||||
var unknown = areas.Except(Areas).ToArray();
|
||||
if (unknown.Length != 0)
|
||||
throw new ArgumentException(
|
||||
$"Unknown problem-nest area {string.Join(", ", unknown)}; the areas are {string.Join(", ", Areas)}.");
|
||||
return areas;
|
||||
}
|
||||
|
||||
// Every nest when no area is wanted; otherwise one guarding a wanted area, or one naming none.
|
||||
internal static bool Selected(IReadOnlyCollection<string> guards, IReadOnlyCollection<string> wanted) =>
|
||||
wanted.Count == 0 || guards.Count == 0 || guards.Intersect(wanted).Any();
|
||||
|
||||
// Ready means replayed and overlap-clear; a ready route with overlapping parts is still blocked.
|
||||
internal static string Outcome(CuttingPlanPlateResult planned) =>
|
||||
planned.IsReady ? "Ready" : planned.IsRouteReady ? "Overlap" : planned.Result.Status.ToString();
|
||||
@@ -200,6 +236,19 @@ public class ProblemNestBaselineTests
|
||||
ProblemNestRegressionTests.Baseline(comments, ProblemNestRegressionTests.CutOffPlanningLine));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Checks_SelectNestsGuardingAWantedAreaAndThoseNamingNone()
|
||||
{
|
||||
var guards = ProblemNestRegressionTests.Checks(" Part-Order, entries ");
|
||||
|
||||
Assert.Equal(["part-order", "entries"], guards);
|
||||
Assert.True(ProblemNestRegressionTests.Selected(guards, []));
|
||||
Assert.True(ProblemNestRegressionTests.Selected(guards, ["entries", "grid"]));
|
||||
Assert.False(ProblemNestRegressionTests.Selected(guards, ["grid"]));
|
||||
Assert.True(ProblemNestRegressionTests.Selected([], ["grid"]));
|
||||
Assert.Throws<ArgumentException>(() => ProblemNestRegressionTests.Checks("part-order, lead-ins"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rank_OrdersReadyAboveBestEffortAboveBudgetAboveBlockedAboveTimeout()
|
||||
{
|
||||
|
||||
@@ -118,6 +118,29 @@ public class ReorderSearchTests
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreeOrder_ApproachCrossesThePartCutJustBefore_SwapsThatPair()
|
||||
{
|
||||
// Two long bars, one above the other, and a small part off to the right below them. The
|
||||
// shortest tour cuts the lower bar, the upper one, then the small part, whose approach
|
||||
// from the upper bar's left side crosses the lower bar. Cutting the upper bar first lets
|
||||
// the tool leave the lower bar below its corner instead; jumping the small part ahead of
|
||||
// the lower bar would detour.
|
||||
var lower = LeftLeadRectangle("lower", 0, 0, 19, 6, departure: new Vector(-0.25, -0.25));
|
||||
var upper = LeftLeadRectangle("upper", 0, 7, 19, 6);
|
||||
var small = LeftLeadRectangle("small", 28, -19, 4, 4);
|
||||
var start = new Vector(9.5, -10);
|
||||
Assert.Contains(Analyze(start, lower, upper, small).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([lower, upper, small], start,
|
||||
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
Assert.Equal(new[] { upper, lower, small }, result.ProposedOrder.Select(p => p.SourcePart));
|
||||
Assert.Empty(Analyze(start, upper, lower, small).Findings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreeOrder_LearnedOrderContradicts_FallsBackToTheFullSearch()
|
||||
{
|
||||
@@ -197,7 +220,9 @@ public class ReorderSearchTests
|
||||
return part;
|
||||
}
|
||||
|
||||
private static PostVerificationReport Analyze(params Part[] parts)
|
||||
private static PostVerificationReport Analyze(params Part[] parts) => Analyze(Vector.Zero, parts);
|
||||
|
||||
private static PostVerificationReport Analyze(Vector start, params Part[] parts)
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
@@ -207,7 +232,7 @@ public class ReorderSearchTests
|
||||
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
|
||||
plate.Parts.Add(copy);
|
||||
}
|
||||
return PostVerificationAnalyzer.Analyze(nest, Vector.Zero);
|
||||
return PostVerificationAnalyzer.Analyze(nest, start);
|
||||
}
|
||||
|
||||
private static string Describe(CuttingPlanResult r) =>
|
||||
|
||||
@@ -9,9 +9,10 @@ namespace OpenNest.Tests.CuttingPlanning;
|
||||
public class SafestRouteFallbackTests
|
||||
{
|
||||
[Fact]
|
||||
public void KeptOrderThroughAWall_OffersTheSafestRouteAsAnUnverifiedPlan()
|
||||
public void KeptOrderThroughAWall_IntoALockedPart_OffersTheSafestRouteAsAnUnverifiedPlan()
|
||||
{
|
||||
var plate = WalledPlate();
|
||||
// The rapid into a locked program cannot be marked to raise the head, so it stays a finding.
|
||||
var plate = WalledPlate(lockRight: true);
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: true).Plan();
|
||||
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
@@ -22,6 +23,7 @@ public class SafestRouteFallbackTests
|
||||
Assert.False(planned.HasOnlyFixedLeadWarnings);
|
||||
Assert.False(planned.SearchLimitReached);
|
||||
Assert.Equal(plate.Parts, planned.Result.ProposedOrder.Select(p => p.SourcePart));
|
||||
Assert.Equal(0, planned.Result.HeadRaises);
|
||||
// Only the rapid from the left square to the right one has to cross the cut bar.
|
||||
var crossing = Assert.Single(planned.Result.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
Assert.Equal(2, crossing.SourceOrdinal);
|
||||
@@ -37,11 +39,11 @@ public class SafestRouteFallbackTests
|
||||
[Fact]
|
||||
public void SafestRoute_AppliesOnlyWithExplicitAcceptance()
|
||||
{
|
||||
var plate = WalledPlate();
|
||||
var plate = WalledPlate(lockRight: true);
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: true).Plan();
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
|
||||
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||
Assert.All(plate.Parts.Take(2), part => Assert.False(part.HasManualLeadIns));
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status);
|
||||
Assert.All(plate.Parts, part => Assert.True(part.HasManualLeadIns));
|
||||
@@ -55,6 +57,7 @@ public class SafestRouteFallbackTests
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
Assert.True(planned.IsReady);
|
||||
Assert.False(planned.Result.IsSafestRouteFallback);
|
||||
Assert.Equal(0, planned.Result.HeadRaises);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -104,13 +107,16 @@ public class SafestRouteFallbackTests
|
||||
|
||||
// A tall bar between two squares, in the order bar, left, right: once the bar is cut every
|
||||
// straight rapid from the left square to the right one crosses it. Cut last, it crosses nothing.
|
||||
private static Plate WalledPlate()
|
||||
private static Plate WalledPlate(bool lockRight = false)
|
||||
{
|
||||
var plate = new Nest().CreatePlate();
|
||||
plate.Size = new Size(60, 60);
|
||||
plate.Parts.Add(new Part(new Drawing("bar", Rectangle(1, 50)), new Vector(25, 2)));
|
||||
plate.Parts.Add(new Part(new Drawing("left", ExplicitContourTests.Square(false)), new Vector(5, 20)));
|
||||
plate.Parts.Add(new Part(new Drawing("right", ExplicitContourTests.Square(false)), new Vector(36, 20)));
|
||||
var right = new Part(new Drawing("right", ExplicitContourTests.Square(false)), new Vector(36, 20));
|
||||
if (lockRight)
|
||||
Assert.True(right.RestoreLeadInProgram((Program)right.Program.Clone(), true));
|
||||
plate.Parts.Add(right);
|
||||
return plate;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using OpenNest.Data.MaterialLedger;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Before save and post, a nest's codes are checked against the ledger's free stock (plus the
|
||||
/// nest's own open holds) and other open nests; conflicting codes can be taken off the plates.
|
||||
/// </summary>
|
||||
public class LedgerCodeCheckTests
|
||||
{
|
||||
[Fact]
|
||||
public void FreeCodesOnNoOtherNest_AreClear()
|
||||
{
|
||||
var plates = Plates(["S-1"], ["B-1", "B-1"]);
|
||||
|
||||
var conflicts = LedgerCodeCheck.Find(plates, [Item("S-1", 1), Item("B-1", 2)], null,
|
||||
LedgerCodesInUse.Known([]));
|
||||
|
||||
Assert.Empty(conflicts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TakenCodes_AreReportedPerPlateWithTheirReason()
|
||||
{
|
||||
var plates = Plates(["GONE"], ["B-1", "B-1"], ["B-1", "OTHER"]);
|
||||
var inUse = LedgerCodesInUse.Known([LedgerCodeFilterTests.Use("OTHER", "2026-0042")]);
|
||||
|
||||
var conflicts = LedgerCodeCheck.Find(plates, [Item("B-1", 2), Item("OTHER", 1)], null, inUse);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
(1, "GONE", "Not free in MaterialLedger"),
|
||||
(2, "B-1", "Only 2 of the 3 sheets needed free in MaterialLedger"),
|
||||
(3, "B-1", "Only 2 of the 3 sheets needed free in MaterialLedger"),
|
||||
(3, "OTHER", "On nest 2026-0042"),
|
||||
], conflicts.Select(c => (c.PlateNumber, c.ItemCode, c.Reason)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothReasons_AreJoined()
|
||||
{
|
||||
var inUse = LedgerCodesInUse.Known([LedgerCodeFilterTests.Use("S-1", "B"), LedgerCodeFilterTests.Use("S-1", "A")]);
|
||||
|
||||
var conflict = Assert.Single(LedgerCodeCheck.Find(Plates(["S-1"]), [], null, inUse));
|
||||
|
||||
Assert.Equal("Not free in MaterialLedger; on nests A, B", conflict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheNestsOwnOpenHolds_KeepItsSheets()
|
||||
{
|
||||
var plates = Plates(["S-1"], ["B-1", "B-1"]);
|
||||
LedgerHold[] holds =
|
||||
[
|
||||
Hold("S-1", LedgerHoldStatuses.Approved),
|
||||
Hold("B-1", LedgerHoldStatuses.Proposed),
|
||||
Hold("B-1", LedgerHoldStatuses.Cancelled),
|
||||
];
|
||||
|
||||
var conflicts = LedgerCodeCheck.Find(plates, [Item("B-1", 1)], holds, LedgerCodesInUse.Known([]));
|
||||
|
||||
Assert.Empty(conflicts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownCodesInUse_CheckTheLedgerAlone()
|
||||
{
|
||||
var conflicts = LedgerCodeCheck.Find(Plates(["S-1"]), [Item("S-1", 1)], null,
|
||||
LedgerCodesInUse.Unknown("nests are saved as files"));
|
||||
|
||||
Assert.Empty(conflicts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_TakesEveryCopyOffAndKeepsTheRestInOrder()
|
||||
{
|
||||
var plates = Plates(["B-1", "S-1", "B-1"], ["S-2"]);
|
||||
|
||||
var removed = LedgerCodeCheck.Remove(plates, ["B-1"]);
|
||||
|
||||
Assert.Equal(2, removed);
|
||||
Assert.Equal(["S-1"], plates[0].StockItemCodes);
|
||||
Assert.Equal(3, plates[0].Quantity);
|
||||
Assert.Equal(["S-2"], plates[1].StockItemCodes);
|
||||
Assert.True(LedgerCodeCheck.HasCodes(plates));
|
||||
LedgerCodeCheck.Remove(plates, ["S-1", "S-2"]);
|
||||
Assert.False(LedgerCodeCheck.HasCodes(plates));
|
||||
}
|
||||
|
||||
private static List<Plate> Plates(params string[][] codes) =>
|
||||
codes.Select(c =>
|
||||
{
|
||||
var plate = new Plate(new Size(48, 96)) { Quantity = c.Length };
|
||||
plate.StockItemCodes = c;
|
||||
return plate;
|
||||
}).ToList();
|
||||
|
||||
private static LedgerStockItem Item(string code, int quantity) =>
|
||||
new(code, 7, LedgerItemKinds.FullSheet, 48, 96, quantity, LedgerItemShapes.Rectangular, "R1", "Rack 1",
|
||||
null, null, DateTime.UtcNow);
|
||||
|
||||
private static LedgerHold Hold(string code, string status) =>
|
||||
new(1, code, 48, 96, status, "R1", DateTime.UtcNow);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Data.MaterialLedger;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Codes other open nests are coded with leave Auto Nest's ledger rows: every copy of a code,
|
||||
/// with its quantity and location, and rows left empty disappear.
|
||||
/// </summary>
|
||||
public class LedgerCodeFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ABundleLosesEveryCopyOfItsCode_AndAnEmptiedRowIsDropped()
|
||||
{
|
||||
var bundle = Row("ledger:1", ["B-1", "B-1", "B-1"]);
|
||||
var sheet = Row("ledger:2", ["S-1"]);
|
||||
var free = Row("ledger:3", ["S-2"]);
|
||||
|
||||
var rows = LedgerCodeFilter.Exclude([bundle, sheet, free], ["B-1", "S-1"], out var removed);
|
||||
|
||||
Assert.Equal(["ledger:3"], rows.Select(r => r.Id));
|
||||
Assert.Same(free, rows[0]);
|
||||
Assert.Equal(4, removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropsKeepTheirOtherCodesLocationsAndQuantity()
|
||||
{
|
||||
var drops = Row("ledger:1", ["D-1", "D-2", "D-3"], ("D-1", "R1"), ("D-2", "R2"), ("D-3", "R1")) with
|
||||
{
|
||||
Rank = 2,
|
||||
Label = "A36",
|
||||
};
|
||||
|
||||
var row = Assert.Single(LedgerCodeFilter.Exclude([drops], ["D-2", "UNRELATED"], out var removed));
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Equal(2, row.Quantity);
|
||||
Assert.Equal(["D-1", "D-3"], row.ItemCodes);
|
||||
Assert.Equal(["R1"], row.Locations);
|
||||
Assert.Equal(new[] { "D-1", "D-3" }, row.ItemLocations.Keys.Order());
|
||||
Assert.Equal(("ledger:1", 2, "A36"), (row.Id, row.Rank, row.Label));
|
||||
// The original row is left alone.
|
||||
Assert.Equal(3, drops.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoCodesInUse_KeepsEveryRowAsItWas()
|
||||
{
|
||||
var rows = new[] { Row("ledger:1", ["S-1"]) };
|
||||
|
||||
var kept = LedgerCodeFilter.Exclude(rows, Array.Empty<string>(), out var removed);
|
||||
|
||||
Assert.Same(rows[0], Assert.Single(kept));
|
||||
Assert.Equal(0, removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CodesMatchExactly()
|
||||
{
|
||||
var kept = LedgerCodeFilter.Exclude([Row("ledger:1", ["s-1"])], ["S-1"], out var removed);
|
||||
|
||||
Assert.Single(kept);
|
||||
Assert.Equal(0, removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingSummary_CountsTheSheetsOnOtherNests()
|
||||
{
|
||||
var mapping = LedgerStockMapper.Map(
|
||||
[
|
||||
Item("B-1", LedgerItemKinds.FullSheetBundle, 3),
|
||||
Item("D-1", LedgerItemKinds.Drop, 1),
|
||||
Item("D-2", LedgerItemKinds.Drop, 1),
|
||||
], Units.Inches);
|
||||
|
||||
var filtered = LedgerCodeFilter.Exclude(mapping, ["B-1", "D-1"]);
|
||||
|
||||
Assert.Equal(4, filtered.OnOtherNests);
|
||||
Assert.Equal("Ledger: 0 sheet sizes, 1 drop (4 on other nests)", filtered.Summary);
|
||||
Assert.Equal("Ledger: 1 sheet size, 2 drops", mapping.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CodesInUse_NameEachNestOnceAndReportWhyTheyAreUnknown()
|
||||
{
|
||||
var known = LedgerCodesInUse.Known(
|
||||
[
|
||||
Use("S-1", "Nest B"),
|
||||
Use("S-1", "Nest A"),
|
||||
Use("S-2", "Nest A"),
|
||||
]);
|
||||
|
||||
Assert.True(known.IsKnown);
|
||||
Assert.Equal(["S-1", "S-2"], known.Codes.Order());
|
||||
Assert.Equal(["Nest A", "Nest B"], known.NestsUsing("S-1"));
|
||||
|
||||
var unknown = LedgerCodesInUse.Unknown("nests are saved as files");
|
||||
Assert.False(unknown.IsKnown);
|
||||
Assert.Empty(unknown.Codes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadingCodes_NeverThrowsForAnExpectedFailure()
|
||||
{
|
||||
Assert.Equal("nests are saved as files", (await LedgerCodesInUse.ReadAsync(null, Guid.Empty, default)).Unavailable);
|
||||
Assert.Equal("the nest server needs an update",
|
||||
(await LedgerCodesInUse.ReadAsync(new Repository(_ => throw new NotSupportedException()), Guid.Empty, default)).Unavailable);
|
||||
Assert.Equal("the nest server could not be reached",
|
||||
(await LedgerCodesInUse.ReadAsync(new Repository(_ => throw new IOException("down")), Guid.Empty, default)).Unavailable);
|
||||
|
||||
var slow = new Repository(async token =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, token);
|
||||
return [];
|
||||
});
|
||||
Assert.Equal("the nest server did not answer",
|
||||
(await LedgerCodesInUse.ReadAsync(slow, Guid.Empty, default, TimeSpan.FromMilliseconds(20))).Unavailable);
|
||||
|
||||
using var cancel = new CancellationTokenSource();
|
||||
cancel.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => LedgerCodesInUse.ReadAsync(slow, Guid.Empty, cancel.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadingCodes_ExcludesTheNestBeingEdited()
|
||||
{
|
||||
var editing = Guid.NewGuid();
|
||||
Guid asked = default;
|
||||
var repository = new Repository(_ => Task.FromResult<IReadOnlyList<NestStockCodeUse>>([Use("S-1", "Other")]))
|
||||
{
|
||||
OnAsk = id => asked = id,
|
||||
};
|
||||
|
||||
var codes = await LedgerCodesInUse.ReadAsync(repository, editing, default);
|
||||
|
||||
Assert.Equal(editing, asked);
|
||||
Assert.Equal(["S-1"], codes.Codes);
|
||||
}
|
||||
|
||||
internal static NestStockCodeUse Use(string code, string nest) =>
|
||||
new() { ItemCode = code, NestId = Guid.NewGuid(), NestName = nest, Status = NestStatus.Quote };
|
||||
|
||||
private static LedgerStockRow Row(string id, string[] codes, params (string Code, string Location)[] locations) =>
|
||||
new(id, new Size(48, 96), codes.Length, IsDrop: codes.Distinct().Count() > 1, IsIrregular: false, codes,
|
||||
locations.Select(l => l.Location).Distinct().ToList())
|
||||
{
|
||||
ItemLocations = locations.ToDictionary(l => l.Code, l => l.Location, StringComparer.Ordinal),
|
||||
};
|
||||
|
||||
private static LedgerStockItem Item(string code, string kind, int quantity) =>
|
||||
new(code, 7, kind, kind == LedgerItemKinds.Drop ? 24 : 48, kind == LedgerItemKinds.Drop ? 36 : 96, quantity,
|
||||
LedgerItemShapes.Rectangular, "R1", "Rack 1", null, null, DateTime.UtcNow);
|
||||
|
||||
private sealed class Repository(Func<CancellationToken, Task<IReadOnlyList<NestStockCodeUse>>> read) : INestRepository
|
||||
{
|
||||
public Action<Guid>? OnAsk { get; init; }
|
||||
|
||||
public Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId, CancellationToken cancellationToken)
|
||||
{
|
||||
OnAsk?.Invoke(excludeNestId);
|
||||
return read(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken) => throw new NotImplementedException();
|
||||
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken) => throw new NotImplementedException();
|
||||
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken) => throw new NotImplementedException();
|
||||
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken) => throw new NotImplementedException();
|
||||
public Task<NestRecord> UploadAsync(byte[] nestFile, NestRecord record, CancellationToken cancellationToken) => throw new NotImplementedException();
|
||||
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken) => throw new NotImplementedException();
|
||||
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken cancellationToken) => throw new NotImplementedException();
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,41 @@ public class LibraryDrawingInsertTests
|
||||
Assert.NotNull(drawing.Library);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordWithoutMaterialOrThickness_SuitsAnyNestAndTakesItsMaterial()
|
||||
{
|
||||
var nest = new Nest("Other job") { Units = Units.Inches, Thickness = 0.1875, Material = new Material("Other Steel", "Grade B") };
|
||||
var record = Record();
|
||||
record.MaterialName = "";
|
||||
record.MaterialGrade = " ";
|
||||
record.Thickness = 0;
|
||||
|
||||
var insert = LibraryDrawingInsert.Plan(nest, Server, record, Package(), 1);
|
||||
|
||||
Assert.True(insert.KeepPins);
|
||||
Assert.Empty(insert.Warnings);
|
||||
insert.Apply();
|
||||
var drawing = Assert.Single(nest.Drawings);
|
||||
Assert.Single(drawing.CutPins);
|
||||
Assert.Equal(("Other Steel", "Grade B"), (drawing.Material.Name, drawing.Material.Grade));
|
||||
Assert.Equal((0.0, ""), (drawing.Library!.Thickness, drawing.Library.MaterialName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordWithOnlyAMaterial_StillNamesAMaterialDifference()
|
||||
{
|
||||
var nest = new Nest("Other job") { Units = Units.Inches, Thickness = 0.1875, Material = new Material("Other Steel", "Grade B") };
|
||||
var record = Record();
|
||||
record.MaterialGrade = "";
|
||||
record.Thickness = 0;
|
||||
|
||||
var warning = Assert.Single(LibraryDrawingInsert.Plan(nest, Server, record, Package(), 1).Warnings);
|
||||
|
||||
Assert.Contains("material Synthetic Steel (nest Other Steel)", warning);
|
||||
Assert.DoesNotContain("grade", warning);
|
||||
Assert.DoesNotContain("thickness", warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryDifference_IsNamed()
|
||||
{
|
||||
|
||||
@@ -387,6 +387,39 @@ namespace OpenNest.Tests.Fill
|
||||
AssertClearance(moving, frame, spacing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(0.25)]
|
||||
public void Push_RestingInHoleOfFrameWithUnclosedSourceContour_SlidesAwayFromFloor(
|
||||
double spacing
|
||||
)
|
||||
{
|
||||
// PEP-rounded contours can miss closing by 1e-6, inside ShapeBuilder's chaining
|
||||
// tolerance. The slide classifier must chain them the same way, or the frame's
|
||||
// material sides are unknown and the floor contact blocks every direction.
|
||||
var program = new OpenNest.CNC.Program();
|
||||
program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(60, 0)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(60, 60)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 60)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0.000001)));
|
||||
program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(10, 10)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(50, 10)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(50, 50)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 50)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 10)));
|
||||
var frame = new Part(new Drawing("frame", program));
|
||||
var moving = MakeRectPart(10 + spacing, 10 + spacing, 5, 5);
|
||||
var plate = new Plate(100, 100) { PartSpacing = spacing };
|
||||
plate.Parts.Add(frame);
|
||||
plate.Parts.Add(moving);
|
||||
|
||||
var distance = Compactor.Push(new List<Part> { moving }, plate, PushDirection.Up);
|
||||
|
||||
Assert.Equal(35 - 2 * spacing, distance, 7);
|
||||
Assert.Equal(50 - spacing, moving.BoundingBox.Top, 7);
|
||||
}
|
||||
|
||||
private static void AssertClearance(Part moving, Part obstacle, double spacing)
|
||||
{
|
||||
var clearance = double.MaxValue;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sketch search (prototype)
|
||||
|
||||
Finds drawings that look like a hand sketch, a phone photo of one, or an MS Paint
|
||||
outline. A DINOv2 image model compares the sketch with a rendering of every
|
||||
drawing. The model is pretrained, optionally fine-tuned on synthetic sketches of
|
||||
the library itself (see [Fine-tune](#fine-tune)).
|
||||
|
||||
## Setup
|
||||
|
||||
```sh
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu124
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
A CPU-only torch works, but embedding is much slower than on a GPU.
|
||||
|
||||
## Build the index
|
||||
|
||||
```sh
|
||||
python build_index.py --index C:\SketchIndex --server http://barge.lan:8091
|
||||
python build_index.py --index C:\SketchIndex --dxf X:\Archive
|
||||
```
|
||||
|
||||
`--server` reads the server's drawing library with GET requests only. `--dxf`
|
||||
walks a folder of DXF files and skips text, dimensions, hatches and
|
||||
`ETCH`/`SCRIBE` layers. Both options can be repeated and combined. Programs are
|
||||
cached under `<index>/cache`. Re-running the build only processes drawings whose
|
||||
revision or file changed.
|
||||
|
||||
## Search
|
||||
|
||||
```sh
|
||||
python search.py --index C:\SketchIndex sketch.png --open
|
||||
python search.py --index C:\SketchIndex photo.jpg --photo --size 10x4.5
|
||||
```
|
||||
|
||||
- **MS Paint:** draw the outline in a dark color and save the file, or crop a
|
||||
screenshot to the canvas. Window chrome in the image counts as part of the
|
||||
sketch. Either outlines or a filled silhouette work.
|
||||
- **Photos and scans:** pass `--photo` to use local thresholding, which handles
|
||||
uneven lighting. Crop to the sketch and take the photo square-on.
|
||||
- **`--size WxL`:** keeps only drawings of that overall size (either
|
||||
orientation, inches or millimetres, default ±15%). The size is a hard filter,
|
||||
so use it only when the sketch's dimensions are reliable.
|
||||
- **Leave out text and small details:** draw the outline and the holes.
|
||||
Rotation and mirroring do not matter.
|
||||
|
||||
Results go to `<index>/last-search.html`. The page shows the sketch as the model
|
||||
sees it, followed by the closest drawings with name, customer, material and
|
||||
size.
|
||||
|
||||
## Measure accuracy
|
||||
|
||||
```sh
|
||||
python evaluate.py --index C:\SketchIndex --synthetic 500
|
||||
python evaluate.py --index C:\SketchIndex --real C:\Sketches
|
||||
```
|
||||
|
||||
`--synthetic` turns random held-out drawings into fake sketches and reports how
|
||||
often the source drawing ranks in the top N. Fake sketches have wobbly lines,
|
||||
±15% proportions, dropped small holes, random rotation, mirroring, line weight
|
||||
and fill. "Same shape" also counts a drawing that renders identically to the
|
||||
answer, such as duplicates and unchanged revisions.
|
||||
|
||||
`--real` takes a folder of real sketches, each named after its drawing
|
||||
(`BRKT-12.png`, or `BRKT-12__2.jpg` for extra sketches of the same part). Real
|
||||
sketches are the result that decides whether the approach works.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Each drawing's cut geometry is rasterized: G-code from the server, or DXF
|
||||
entities. Scribe, etch and break motions are skipped.
|
||||
2. Drawings and sketches share one `normalize()` step. It reduces the ink to
|
||||
1px centre lines (a filled silhouette becomes its boundary), crops and scales
|
||||
the result into a 224×224 canvas, and redraws it at a fixed stroke width.
|
||||
3. DINOv2 embeds the image: the CLS token joined with the mean patch token.
|
||||
4. The query is embedded in all 8 rotations and mirrors, and each drawing scores
|
||||
its best match. A small penalty applies when the drawing's proportions
|
||||
differ from the sketch's (`ASPECT_WEIGHT` in `search.py`).
|
||||
|
||||
## Fine-tune
|
||||
|
||||
The pretrained model alone is weak on a 40k-drawing library. Training it on
|
||||
synthetic sketches of the library itself needs no labelling:
|
||||
|
||||
```sh
|
||||
python train.py --index C:\SketchIndex --out C:\SketchIndex\model
|
||||
python build_index.py --index C:\SketchIndex --server http://barge.lan:8091 --model C:\SketchIndex\model
|
||||
python evaluate.py --index C:\SketchIndex --synthetic 400
|
||||
```
|
||||
|
||||
Each epoch pairs every training drawing's rendering with a freshly generated
|
||||
fake sketch (`synthetic.py`, kept in the drawing's orientation because search
|
||||
tries all 8). The model learns to pick each sketch's own drawing out of the
|
||||
batch. Two groups of drawings are left out of training:
|
||||
|
||||
- Near-duplicate renderings, which would count as wrong answers within a batch.
|
||||
- About 10% of drawings chosen by key (`is_holdout`). `evaluate.py` samples only
|
||||
these, so its scores are not inflated by training.
|
||||
|
||||
Rebuilding the index with a new `--model` reuses the existing renders and only
|
||||
re-embeds them.
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Builds or refreshes a sketch-search index from an OpenNest server and/or DXF folders.
|
||||
|
||||
python build_index.py --index C:\\SketchIndex --server http://barge.lan:8091
|
||||
python build_index.py --index C:\\SketchIndex --dxf X:\\Archive
|
||||
|
||||
Re-running only downloads, renders and embeds drawings whose revision changed.
|
||||
"""
|
||||
import argparse
|
||||
import concurrent.futures as cf
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
import sketch_common as sc
|
||||
|
||||
|
||||
def list_server(server, limit=None):
|
||||
items, offset = [], 0
|
||||
while True:
|
||||
page = json.load(urllib.request.urlopen(f"{server}/api/drawings/query?limit=500&offset={offset}", timeout=60))
|
||||
for r in page["items"]:
|
||||
items.append({
|
||||
"key": f"server:{r['id']}", "source": "server", "id": r["id"], "revision": r["revision"],
|
||||
"sha": r["packageSha256"], "name": r["name"], "customer": r.get("customer", ""),
|
||||
"material": " ".join(x for x in (r.get("materialName"), r.get("materialGrade")) if x),
|
||||
"thickness": r.get("thickness"), "units": r.get("units", ""),
|
||||
})
|
||||
offset += len(page["items"])
|
||||
if not page["items"] or offset >= page["total"] or (limit and offset >= limit):
|
||||
return items[:limit] if limit else items
|
||||
|
||||
|
||||
def list_dxf(folder, limit=None):
|
||||
items = []
|
||||
for root, _, files in os.walk(folder):
|
||||
for f in files:
|
||||
if f.lower().endswith(".dxf"):
|
||||
p = Path(root, f)
|
||||
st = p.stat()
|
||||
items.append({"key": f"dxf:{p}", "source": "dxf", "path": str(p), "name": p.stem,
|
||||
"sha": f"{st.st_size}-{int(st.st_mtime)}", "units": ""})
|
||||
if limit and len(items) >= limit:
|
||||
return items
|
||||
return items
|
||||
|
||||
|
||||
def program_path(index, item):
|
||||
return Path(index, "cache", f"{item['id']}-r{item['revision']}.gcode")
|
||||
|
||||
|
||||
def download(server, index, item):
|
||||
path = program_path(index, item)
|
||||
if path.exists():
|
||||
return None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
data = urllib.request.urlopen(f"{server}/api/drawings/{item['id']}/file", timeout=60).read()
|
||||
z = zipfile.ZipFile(io.BytesIO(data))
|
||||
programs = sorted(n for n in z.namelist() if n.startswith("programs/program-") and not n.endswith("-subs"))
|
||||
path.write_bytes(z.read(programs[0]) if programs else b"")
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001 - reported per drawing
|
||||
err = e
|
||||
time.sleep(1 + attempt)
|
||||
return f"{item['name']}: {err}"
|
||||
|
||||
|
||||
def load_polys(index, item):
|
||||
if item["source"] == "server":
|
||||
return sc.parse_program(program_path(index, item).read_text(errors="replace"))
|
||||
return sc.read_dxf(item["path"])
|
||||
|
||||
|
||||
def render_one(args):
|
||||
index, item = args
|
||||
try:
|
||||
polys = load_polys(index, item)
|
||||
if not polys:
|
||||
return item["key"], None, "no cut geometry"
|
||||
lo, hi = sc.bounds(polys)
|
||||
sc.normalize(sc.render_mask(polys)).convert("L").save(Path(index, "thumbs", thumb_name(item)))
|
||||
return item["key"], (float(hi[0] - lo[0]), float(hi[1] - lo[1])), None
|
||||
except Exception as e: # noqa: BLE001 - reported per drawing
|
||||
return item["key"], None, str(e)
|
||||
|
||||
|
||||
def thumb_name(item):
|
||||
if item["source"] == "server":
|
||||
return f"{item['id']}.png"
|
||||
import hashlib
|
||||
return "dxf-" + hashlib.sha1(item["path"].encode()).hexdigest()[:16] + ".png"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--index", required=True, help="index folder (created if missing)")
|
||||
ap.add_argument("--server", action="append", default=[], help="OpenNest server URL")
|
||||
ap.add_argument("--dxf", action="append", default=[], help="folder of DXF files (searched recursively)")
|
||||
ap.add_argument("--model", default="facebook/dinov2-small", help="Hugging Face model or fine-tuned model folder")
|
||||
ap.add_argument("--limit", type=int, help="index at most this many drawings per source (testing)")
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
args = ap.parse_args()
|
||||
if not args.server and not args.dxf:
|
||||
ap.error("give at least one --server or --dxf source")
|
||||
|
||||
index = Path(args.index)
|
||||
for sub in ("cache", "thumbs"):
|
||||
Path(index, sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
items = []
|
||||
for server in args.server:
|
||||
server = server.rstrip("/")
|
||||
found = list_server(server, args.limit)
|
||||
print(f"{server}: {len(found)} drawings")
|
||||
with cf.ThreadPoolExecutor(12) as pool:
|
||||
errors = [e for e in pool.map(lambda it: download(server, index, it), found) if e]
|
||||
for e in errors[:10]:
|
||||
print(" download failed:", e)
|
||||
items += found
|
||||
for folder in args.dxf:
|
||||
found = list_dxf(folder, args.limit)
|
||||
print(f"{folder}: {len(found)} DXF files")
|
||||
items += found
|
||||
|
||||
config = {"model": args.model}
|
||||
old_items, old_emb = {}, None
|
||||
if Path(index, "items.jsonl").exists() and Path(index, "config.json").exists():
|
||||
old_list = [json.loads(l) for l in Path(index, "items.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
old_items = {it["key"]: (it, i) for i, it in enumerate(old_list)}
|
||||
if json.loads(Path(index, "config.json").read_text()) == config:
|
||||
old_emb = np.load(Path(index, "embeddings.npy"))
|
||||
|
||||
# Renders survive a model change; embeddings only survive with the same model.
|
||||
embedded, unembedded, todo = [], [], []
|
||||
for it in items:
|
||||
prev = old_items.get(it["key"])
|
||||
if prev and prev[0]["sha"] == it["sha"] and Path(index, "thumbs", thumb_name(it)).exists():
|
||||
it.update(width=prev[0]["width"], height=prev[0]["height"])
|
||||
if old_emb is not None:
|
||||
embedded.append((it, old_emb[prev[1]]))
|
||||
else:
|
||||
unembedded.append(it)
|
||||
else:
|
||||
todo.append(it)
|
||||
print(f"reusing {len(embedded) + len(unembedded)} renders, rendering {len(todo)}")
|
||||
|
||||
by_key = {it["key"]: it for it in todo}
|
||||
failures = []
|
||||
started = time.time()
|
||||
with cf.ProcessPoolExecutor(args.workers) as pool:
|
||||
for n, (key, size, err) in enumerate(pool.map(render_one, [(str(index), it) for it in todo], chunksize=16), 1):
|
||||
if err:
|
||||
failures.append((key, err))
|
||||
else:
|
||||
by_key[key].update(width=size[0], height=size[1])
|
||||
unembedded.append(by_key[key])
|
||||
if n % 2000 == 0:
|
||||
print(f" rendered {n}/{len(todo)} ({time.time() - started:.0f}s)")
|
||||
print(f"rendered {len(todo) - len(failures)} in {time.time() - started:.0f}s, {len(failures)} skipped")
|
||||
for key, err in failures[:15]:
|
||||
print(" skipped", key, "-", err)
|
||||
|
||||
rows = [e for _, e in embedded]
|
||||
if unembedded:
|
||||
embedder = sc.Embedder(args.model)
|
||||
started = time.time()
|
||||
load = lambda it: Image.open(Path(index, "thumbs", thumb_name(it))).convert("RGB")
|
||||
with cf.ThreadPoolExecutor(8) as pool:
|
||||
for i in range(0, len(unembedded), 2048):
|
||||
rows += list(embedder.embed(list(pool.map(load, unembedded[i:i + 2048]))))
|
||||
print(f"embedded {len(unembedded)} in {time.time() - started:.0f}s")
|
||||
|
||||
all_items = [it for it, _ in embedded] + unembedded
|
||||
for it in all_items:
|
||||
it["thumb"] = thumb_name(it)
|
||||
np.save(Path(index, "embeddings.npy"), np.stack(rows).astype(np.float16))
|
||||
Path(index, "items.jsonl").write_text("\n".join(json.dumps(it) for it in all_items), encoding="utf-8")
|
||||
Path(index, "config.json").write_text(json.dumps(config))
|
||||
print(f"index: {len(all_items)} drawings in {index}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Measures how often search finds the right drawing.
|
||||
|
||||
Synthetic: turns random held-out drawings (never trained on) into fake hand
|
||||
sketches and records where the source drawing ranks among the whole index.
|
||||
|
||||
python evaluate.py --index C:\\SketchIndex --synthetic 300
|
||||
|
||||
Real: a folder of sketch images, each named after the drawing it should find
|
||||
(the drawing name, or name + anything after "__", e.g. "BRKT-12__photo.jpg").
|
||||
|
||||
python evaluate.py --index C:\\SketchIndex --real C:\\Sketches [--photo]
|
||||
|
||||
A "same shape" hit also counts a drawing whose rendering is effectively identical
|
||||
to the answer (duplicates and unchanged revisions are common in a part library).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
import build_index
|
||||
from synthetic import sketchify
|
||||
import sketch_common as sc
|
||||
from search import ASPECT_WEIGHT, SketchIndex
|
||||
|
||||
SAME_SHAPE = 0.985
|
||||
CUTOFFS = (1, 5, 10, 20, 50)
|
||||
|
||||
|
||||
def rank_of(index, results_keys, truth_idx):
|
||||
"""(strict rank, same-shape rank), 1-based; None when beyond the list."""
|
||||
truth_emb = index.embeddings[truth_idx]
|
||||
strict = same = None
|
||||
for r, i in enumerate(results_keys, 1):
|
||||
if strict is None and i == truth_idx:
|
||||
strict = r
|
||||
if same is None and (i == truth_idx or float(index.embeddings[i] @ truth_emb) >= SAME_SHAPE):
|
||||
same = r
|
||||
return strict, same
|
||||
|
||||
|
||||
def full_ranking(index, mask, k, aspect_weight):
|
||||
scores = index.scores(sc.normalize(mask), sc.aspect(mask), aspect_weight)
|
||||
return list(np.argsort(-scores)[:k])
|
||||
|
||||
|
||||
def report(name, ranks, k):
|
||||
print(f"\n{name}: {len(ranks)} queries")
|
||||
for label, col in (("exact drawing", 0), ("same shape", 1)):
|
||||
found = [r[col] for r in ranks]
|
||||
cells = " ".join(f"top{c}: {100 * sum(1 for f in found if f and f <= c) / len(found):5.1f}%" for c in CUTOFFS)
|
||||
print(f" {label:13} {cells} (median rank {np.median([f or k + 1 for f in found]):.0f})")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--index", required=True)
|
||||
ap.add_argument("--synthetic", type=int, help="number of synthetic sketches")
|
||||
ap.add_argument("--real", help="folder of real sketches named after their drawing")
|
||||
ap.add_argument("--photo", action="store_true")
|
||||
ap.add_argument("--model", help="evaluate this model instead of the index's (re-embeds renders in memory)")
|
||||
ap.add_argument("--seed", type=int, default=1)
|
||||
ap.add_argument("--save", help="folder to save synthetic sketches and their normalized form")
|
||||
ap.add_argument("--aspect-weight", type=float, nargs="+", default=[ASPECT_WEIGHT],
|
||||
help="one or more proportion-penalty weights to compare")
|
||||
args = ap.parse_args()
|
||||
k = 200
|
||||
index = SketchIndex(args.index, args.model)
|
||||
|
||||
if args.synthetic:
|
||||
rng = random.Random(args.seed)
|
||||
holdout = [i for i, it in enumerate(index.items) if sc.is_holdout(it["key"])]
|
||||
picks = rng.sample(holdout, min(args.synthetic, len(holdout)))
|
||||
ranks = {w: [] for w in args.aspect_weight}
|
||||
if args.save:
|
||||
Path(args.save).mkdir(parents=True, exist_ok=True)
|
||||
for n, i in enumerate(picks):
|
||||
it = index.items[i]
|
||||
sketch = sketchify(build_index.load_polys(args.index, it), rng)
|
||||
mask = sc.sketch_mask(sketch)
|
||||
for w in args.aspect_weight:
|
||||
ranks[w].append(rank_of(index, full_ranking(index, mask, k, w), i))
|
||||
if args.save and n < 40:
|
||||
sketch.save(Path(args.save, f"{n:03}-sketch.png"))
|
||||
sc.normalize(mask).save(Path(args.save, f"{n:03}-query.png"))
|
||||
Path(args.save, f"{n:03}-answer.txt").write_text(
|
||||
json.dumps({"name": it["name"], "thumb": it["thumb"], "rank": ranks[args.aspect_weight[0]][-1]}))
|
||||
for w in args.aspect_weight:
|
||||
report(f"synthetic sketches vs {len(index.items)} drawings, aspect weight {w:g}", ranks[w], k)
|
||||
|
||||
if args.real:
|
||||
by_name = {}
|
||||
for i, it in enumerate(index.items):
|
||||
by_name.setdefault(it["name"].lower(), []).append(i)
|
||||
ranks = []
|
||||
for f in sorted(Path(args.real).iterdir()):
|
||||
if f.suffix.lower() not in (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"):
|
||||
continue
|
||||
name = f.stem.split("__")[0].lower()
|
||||
if name not in by_name:
|
||||
print(f" {f.name}: no indexed drawing named '{name}'")
|
||||
continue
|
||||
ranking = full_ranking(index, sc.sketch_mask(Image.open(f), photo=args.photo), k, args.aspect_weight[0])
|
||||
best = min((rank_of(index, ranking, i) for i in by_name[name]),
|
||||
key=lambda r: (r[0] or k + 1, r[1] or k + 1))
|
||||
print(f" {f.name}: rank {best[0] or f'>{k}'} (same shape {best[1] or f'>{k}'})")
|
||||
ranks.append(best)
|
||||
if ranks:
|
||||
report("real sketches", ranks, k)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
# PyTorch: install the CUDA build first for GPU embedding, e.g.
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/cu124
|
||||
torch>=2.4
|
||||
transformers>=4.45
|
||||
numpy>=1.26
|
||||
pillow>=10.0
|
||||
scikit-image>=0.22
|
||||
ezdxf>=1.3
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Finds the drawings that look most like a sketch.
|
||||
|
||||
python search.py --index C:\\SketchIndex sketch.png
|
||||
python search.py --index C:\\SketchIndex sketch.jpg --photo --size 10x4.5 --open
|
||||
|
||||
Writes an HTML page of the best matches beside the index (or --out) and prints them.
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import sys
|
||||
import webbrowser
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
import sketch_common as sc
|
||||
|
||||
MM_PER_INCH = 25.4
|
||||
ASPECT_WEIGHT = 0.1
|
||||
|
||||
|
||||
class SketchIndex:
|
||||
def __init__(self, folder, model=None):
|
||||
"""model overrides the index's model: its renders are re-embedded in memory."""
|
||||
self.folder = Path(folder)
|
||||
config = json.loads(Path(folder, "config.json").read_text())
|
||||
self.items = [json.loads(l) for l in Path(folder, "items.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
self.embedder = sc.Embedder(model or config["model"])
|
||||
if model and model != config["model"]:
|
||||
load = lambda it: Image.open(Path(folder, "thumbs", it["thumb"])).convert("RGB")
|
||||
with ThreadPoolExecutor(8) as pool:
|
||||
self.embeddings = np.concatenate([self.embedder.embed(list(pool.map(load, self.items[i:i + 2048])))
|
||||
for i in range(0, len(self.items), 2048)])
|
||||
else:
|
||||
self.embeddings = np.load(Path(folder, "embeddings.npy")).astype(np.float32)
|
||||
self._aspects = None
|
||||
|
||||
def size_mask(self, size, tolerance):
|
||||
"""Drawings whose extents match size (either orientation, inches or millimetres)."""
|
||||
want = np.sort(np.array(size, dtype=np.float64))
|
||||
have = np.sort(np.array([[it["width"], it["height"]] for it in self.items]), axis=1)
|
||||
ok = np.zeros(len(self.items), bool)
|
||||
for factor in (1.0, MM_PER_INCH, 1 / MM_PER_INCH):
|
||||
ok |= np.all(np.abs(have * factor - want) <= tolerance * want, axis=1)
|
||||
return ok
|
||||
|
||||
def scores(self, query, aspect=None, aspect_weight=ASPECT_WEIGHT):
|
||||
"""Similarity of every drawing to a normalized query image; aspect is the
|
||||
sketch's short/long side ratio, which penalizes drawings of other proportions."""
|
||||
variants = self.embedder.embed(sc.dihedral(query))
|
||||
scores = (self.embeddings @ variants.T).max(axis=1)
|
||||
if aspect and aspect_weight:
|
||||
if self._aspects is None:
|
||||
wh = np.array([[it["width"], it["height"]] for it in self.items])
|
||||
self._aspects = np.maximum(wh.min(axis=1), 1e-9) / np.maximum(wh.max(axis=1), 1e-9)
|
||||
scores = scores - aspect_weight * np.abs(np.log(self._aspects / aspect))
|
||||
return scores
|
||||
|
||||
def search(self, query, k=30, size=None, tolerance=0.15, aspect=None, aspect_weight=ASPECT_WEIGHT):
|
||||
"""query is a normalized image; returns [(score, item)] best first."""
|
||||
scores = self.scores(query, aspect, aspect_weight)
|
||||
if size:
|
||||
scores = np.where(self.size_mask(size, tolerance), scores, -np.inf)
|
||||
order = np.argsort(-scores)[:k]
|
||||
return [(float(scores[i]), self.items[i]) for i in order if np.isfinite(scores[i])]
|
||||
|
||||
|
||||
def parse_size(text):
|
||||
parts = text.lower().replace(" ", "").split("x")
|
||||
if len(parts) != 2:
|
||||
raise argparse.ArgumentTypeError("size looks like 10x4.5")
|
||||
return float(parts[0]), float(parts[1])
|
||||
|
||||
|
||||
def write_html(path, index_folder, query_image, results, title):
|
||||
path = Path(path)
|
||||
query_file = path.with_suffix(".query.png")
|
||||
query_image.save(query_file)
|
||||
thumbs = Path(index_folder, "thumbs").resolve().as_uri()
|
||||
cards = []
|
||||
for rank, (score, it) in enumerate(results, 1):
|
||||
details = " · ".join(str(x) for x in (
|
||||
it.get("customer"), it.get("material"),
|
||||
f"{it['thickness']:g} thk" if it.get("thickness") else None,
|
||||
f"{it['width']:.3g} × {it['height']:.3g} {it.get('units', '')}".strip()) if x)
|
||||
where = it.get("path") or f"server drawing {it.get('id')}"
|
||||
cards.append(f"""<figure><img src="{thumbs}/{it['thumb']}"><figcaption>
|
||||
<b>{rank}. {html.escape(it['name'])}</b><span>{html.escape(details)}</span>
|
||||
<small title="{html.escape(where)}">score {score:.3f} · {html.escape(where)}</small></figcaption></figure>""")
|
||||
path.write_text(f"""<!doctype html><meta charset="utf-8"><title>{html.escape(title)}</title>
|
||||
<style>
|
||||
:root {{ color-scheme: light dark; --bg:#fafafa; --card:#fff; --fg:#222; --muted:#666; --line:#ddd; }}
|
||||
@media (prefers-color-scheme: dark) {{ :root {{ --bg:#1b1b1b; --card:#262626; --fg:#eee; --muted:#aaa; --line:#3a3a3a; }} }}
|
||||
body {{ font: 14px system-ui, sans-serif; background: var(--bg); color: var(--fg); margin: 16px; }}
|
||||
header {{ display: flex; gap: 16px; align-items: center; margin-bottom: 16px; }}
|
||||
header img {{ width: 160px; border: 1px solid var(--line); background: #fff; }}
|
||||
main {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }}
|
||||
figure {{ margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }}
|
||||
figure img {{ width: 100%; background: #fff; border-radius: 4px; }}
|
||||
figcaption {{ display: flex; flex-direction: column; gap: 2px; overflow-wrap: anywhere; }}
|
||||
span, small {{ color: var(--muted); }}
|
||||
</style>
|
||||
<header><img src="{query_file.name}"><div><h2>{html.escape(title)}</h2>
|
||||
<p>The sketch as the model sees it (left), then the {len(results)} closest drawings.</p></div></header>
|
||||
<main>{''.join(cards)}</main>""", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("sketch", help="image of the sketch (PNG/JPG; crop off any window chrome)")
|
||||
ap.add_argument("--index", required=True)
|
||||
ap.add_argument("-k", type=int, default=30, help="number of matches")
|
||||
ap.add_argument("--photo", action="store_true", help="camera photo or scan: adaptive thresholding")
|
||||
ap.add_argument("--size", type=parse_size, help="overall size, e.g. 10x4.5 (either orientation, in or mm)")
|
||||
ap.add_argument("--tolerance", type=float, default=0.15, help="relative size tolerance (default 0.15)")
|
||||
ap.add_argument("--out", help="HTML results file (default: <index>/last-search.html)")
|
||||
ap.add_argument("--open", action="store_true", help="open the results in a browser")
|
||||
args = ap.parse_args()
|
||||
|
||||
mask = sc.sketch_mask(Image.open(args.sketch), photo=args.photo)
|
||||
index = SketchIndex(args.index)
|
||||
query = sc.normalize(mask)
|
||||
results = index.search(query, args.k, args.size, args.tolerance, sc.aspect(mask))
|
||||
for rank, (score, it) in enumerate(results, 1):
|
||||
print(f"{rank:3}. {score:.3f} {it['name']} [{it.get('customer', '')}] "
|
||||
f"{it['width']:.3g}x{it['height']:.3g}")
|
||||
out = write_html(args.out or Path(args.index, "last-search.html"), args.index, query, results,
|
||||
f"Matches for {Path(args.sketch).name}")
|
||||
print(f"results: {out}")
|
||||
if args.open:
|
||||
webbrowser.open(out.resolve().as_uri())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Shared geometry reading, image normalization and embedding for sketch search.
|
||||
|
||||
Drawings and sketches go through the same normalize() so the model compares like
|
||||
with like: ink is reduced to 1px centre lines, cropped, scaled to fit a square
|
||||
canvas and re-stroked at a fixed width.
|
||||
"""
|
||||
import math
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
from skimage.filters import threshold_local
|
||||
from skimage.morphology import binary_erosion, remove_small_objects, skeletonize
|
||||
|
||||
CANVAS = 224
|
||||
MARGIN = 14
|
||||
STROKE = 3
|
||||
RENDER = 1024
|
||||
FILLED_RATIO = 0.35
|
||||
|
||||
# Motions with these tags are marks or open break cuts, not part outline.
|
||||
SKIPPED_TAGS = {"SCRIBE", "ETCH", "BREAK", "LEADIN", "LEADOUT"}
|
||||
_WORD = re.compile(r"([A-Z])\s*(-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)")
|
||||
|
||||
|
||||
def is_holdout(key):
|
||||
"""About 10% of drawings, chosen by key, are never trained on and are what evaluation samples."""
|
||||
import hashlib
|
||||
return hashlib.md5(key.encode()).digest()[0] < 26
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- geometry
|
||||
|
||||
def _arc_points(sx, sy, ex, ey, cx, cy, clockwise):
|
||||
r = math.hypot(sx - cx, sy - cy)
|
||||
a0 = math.atan2(sy - cy, sx - cx)
|
||||
a1 = math.atan2(ey - cy, ex - cx)
|
||||
sweep = a1 - a0
|
||||
if clockwise:
|
||||
if sweep >= -1e-9:
|
||||
sweep -= 2 * math.pi
|
||||
elif sweep <= 1e-9:
|
||||
sweep += 2 * math.pi
|
||||
n = max(4, math.ceil(abs(sweep) / (math.pi / 36)))
|
||||
return [(cx + r * math.cos(a0 + sweep * i / n), cy + r * math.sin(a0 + sweep * i / n))
|
||||
for i in range(1, n + 1)]
|
||||
|
||||
|
||||
def parse_program(text):
|
||||
"""Returns the cut polylines of an OpenNest drawing program (G90/G91, G00-G03)."""
|
||||
absolute = True
|
||||
motion = 0
|
||||
x = y = 0.0
|
||||
polys = []
|
||||
current = None
|
||||
for raw in text.splitlines():
|
||||
code, _, tag = raw.partition(":")
|
||||
words = _WORD.findall(code.upper())
|
||||
if not words:
|
||||
continue
|
||||
values = {}
|
||||
for letter, number in words:
|
||||
if letter == "G":
|
||||
g = int(float(number))
|
||||
if g == 90:
|
||||
absolute = True
|
||||
elif g == 91:
|
||||
absolute = False
|
||||
elif g in (0, 1, 2, 3):
|
||||
motion = g
|
||||
else:
|
||||
values = None # G65 calls and other codes are not geometry
|
||||
break
|
||||
else:
|
||||
values[letter] = float(number)
|
||||
if not values or ("X" not in values and "Y" not in values):
|
||||
continue
|
||||
if absolute:
|
||||
nx, ny = values.get("X", x), values.get("Y", y)
|
||||
else:
|
||||
nx, ny = x + values.get("X", 0.0), y + values.get("Y", 0.0)
|
||||
skipped = tag.strip().upper().split(" ")[0] in SKIPPED_TAGS
|
||||
if motion == 0 or skipped:
|
||||
current = None
|
||||
else:
|
||||
if current is None:
|
||||
current = [(x, y)]
|
||||
polys.append(current)
|
||||
if motion == 1:
|
||||
current.append((nx, ny))
|
||||
else:
|
||||
cx, cy = x + values.get("I", 0.0), y + values.get("J", 0.0)
|
||||
current.extend(_arc_points(x, y, nx, ny, cx, cy, motion == 2))
|
||||
x, y = nx, ny
|
||||
return [p for p in polys if len(p) > 1]
|
||||
|
||||
|
||||
_DXF_SKIP_TYPES = {"TEXT", "MTEXT", "DIMENSION", "HATCH", "POINT", "ATTDEF", "ATTRIB",
|
||||
"LEADER", "MULTILEADER", "IMAGE", "WIPEOUT", "VIEWPORT", "SOLID", "3DFACE"}
|
||||
|
||||
|
||||
def read_dxf(path):
|
||||
"""Returns the polylines of a DXF's model space, skipping text, dimensions and marks."""
|
||||
import ezdxf
|
||||
from ezdxf import path as ezpath
|
||||
from ezdxf import recover
|
||||
|
||||
doc, _ = recover.readfile(str(path))
|
||||
polys = []
|
||||
|
||||
def visit(entities, depth=0):
|
||||
for e in entities:
|
||||
kind = e.dxftype()
|
||||
layer = (e.dxf.get("layer", "") or "").upper()
|
||||
if kind in _DXF_SKIP_TYPES or "ETCH" in layer or "SCRIBE" in layer:
|
||||
continue
|
||||
if kind == "INSERT":
|
||||
if depth < 8:
|
||||
try:
|
||||
visit(e.virtual_entities(), depth + 1)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
p = ezpath.make_path(e)
|
||||
except (TypeError, ValueError, ezdxf.DXFError):
|
||||
continue
|
||||
pts = [(v.x, v.y) for v in p.flattening(0.002, segments=8)]
|
||||
if len(pts) > 1:
|
||||
polys.append(pts)
|
||||
|
||||
visit(doc.modelspace())
|
||||
return polys
|
||||
|
||||
|
||||
def bounds(polys):
|
||||
pts = np.array([pt for p in polys for pt in p])
|
||||
lo, hi = pts.min(axis=0), pts.max(axis=0)
|
||||
return lo, hi
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- images
|
||||
|
||||
def render_mask(polys, size=RENDER, width=2):
|
||||
"""Rasterizes polylines to a boolean ink mask, aspect preserved."""
|
||||
lo, hi = bounds(polys)
|
||||
span = max(hi[0] - lo[0], hi[1] - lo[1], 1e-9)
|
||||
scale = (size - 8) / span
|
||||
w = max(8, int((hi[0] - lo[0]) * scale) + 8)
|
||||
h = max(8, int((hi[1] - lo[1]) * scale) + 8)
|
||||
img = Image.new("L", (w, h), 0)
|
||||
draw = ImageDraw.Draw(img)
|
||||
for p in polys:
|
||||
draw.line([((px - lo[0]) * scale + 4, h - 1 - ((py - lo[1]) * scale + 4)) for px, py in p],
|
||||
fill=255, width=width)
|
||||
return np.asarray(img) > 0
|
||||
|
||||
|
||||
def sketch_mask(image, photo=False):
|
||||
"""Converts a sketch image (scan, photo or MS Paint drawing) to a boolean ink mask."""
|
||||
if image.mode in ("RGBA", "LA") or "transparency" in image.info:
|
||||
bg = Image.new("RGBA", image.size, (255, 255, 255, 255))
|
||||
image = Image.alpha_composite(bg, image.convert("RGBA"))
|
||||
gray = np.asarray(image.convert("L"), dtype=np.float32)
|
||||
if gray.max() - gray.min() < 10:
|
||||
raise ValueError("The sketch image is blank.")
|
||||
if photo:
|
||||
gray = np.asarray(Image.fromarray(gray.astype(np.uint8)).filter(ImageFilter.GaussianBlur(1.5)),
|
||||
dtype=np.float32)
|
||||
block = max(15, (min(gray.shape) // 20) | 1)
|
||||
ink = gray < threshold_local(gray, block, offset=10)
|
||||
else:
|
||||
# Anything clearly darker than the paper is ink. Otsu would split faint
|
||||
# strokes from dark ones; the border is paper even when a fill dominates.
|
||||
border = np.concatenate([gray[0], gray[-1], gray[:, 0], gray[:, -1]])
|
||||
paper = float(np.median(border))
|
||||
if paper < 128: # light lines on a dark background
|
||||
gray, paper = 255 - gray, 255 - paper
|
||||
darkest = float(np.percentile(gray, 0.1))
|
||||
ink = gray < paper - 0.35 * (paper - darkest)
|
||||
return remove_small_objects(ink, max(16, ink.size // 200000))
|
||||
|
||||
|
||||
def aspect(mask):
|
||||
"""Short/long side ratio of the ink's bounding box."""
|
||||
ys, xs = np.nonzero(mask)
|
||||
if len(xs) == 0:
|
||||
raise ValueError("No ink found.")
|
||||
h, w = ys.max() - ys.min() + 1, xs.max() - xs.min() + 1
|
||||
return min(h, w) / max(h, w)
|
||||
|
||||
|
||||
def normalize(mask):
|
||||
"""Crops, centre-lines, scales and re-strokes an ink mask; returns a CANVAS x CANVAS RGB image."""
|
||||
ys, xs = np.nonzero(mask)
|
||||
if len(xs) == 0:
|
||||
raise ValueError("No ink found.")
|
||||
mask = mask[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
|
||||
mask = np.pad(mask, 2)
|
||||
if mask.mean() > FILLED_RATIO: # a filled silhouette: keep its boundary
|
||||
mask = mask & ~binary_erosion(mask)
|
||||
mask = skeletonize(mask)
|
||||
h, w = mask.shape
|
||||
s = (CANVAS - 2 * MARGIN) / max(h, w)
|
||||
tw, th = max(1, round(w * s)), max(1, round(h * s))
|
||||
small = Image.fromarray(mask.astype(np.uint8) * 255).resize((tw, th), Image.BOX)
|
||||
small = small.point(lambda v: 255 if v > 0 else 0).filter(ImageFilter.MaxFilter(STROKE))
|
||||
canvas = Image.new("L", (CANVAS, CANVAS), 0)
|
||||
canvas.paste(small, ((CANVAS - tw) // 2, (CANVAS - th) // 2))
|
||||
return Image.eval(canvas, lambda v: 255 - v).convert("RGB")
|
||||
|
||||
|
||||
def dihedral(image):
|
||||
"""The 8 rotations/mirrors of a square image."""
|
||||
out = []
|
||||
for flip in (False, True):
|
||||
base = image.transpose(Image.FLIP_LEFT_RIGHT) if flip else image
|
||||
for k in range(4):
|
||||
out.append(base.rotate(90 * k))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- embedding
|
||||
|
||||
class Embedder:
|
||||
def __init__(self, model_name="facebook/dinov2-small", device=None):
|
||||
import torch
|
||||
from transformers import AutoModel
|
||||
|
||||
self.torch = torch
|
||||
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
||||
self.dtype = torch.float16 if self.device == "cuda" else torch.float32
|
||||
self.model = AutoModel.from_pretrained(model_name).to(self.device, self.dtype).eval()
|
||||
self.model_name = model_name
|
||||
|
||||
def embed(self, images, batch=64):
|
||||
"""L2-normalized embeddings (CLS token joined with mean patch token) as float32."""
|
||||
torch = self.torch
|
||||
out = []
|
||||
with torch.inference_mode():
|
||||
for i in range(0, len(images), batch):
|
||||
x = torch.from_numpy(np.stack([pixels(im) for im in images[i:i + batch]]))
|
||||
out.append(features(self.model, x.to(self.device, self.dtype)).cpu().numpy())
|
||||
return np.concatenate(out) if out else np.zeros((0, 0), np.float32)
|
||||
|
||||
|
||||
_MEAN = np.array([0.485, 0.456, 0.406], np.float32).reshape(3, 1, 1)
|
||||
_STD = np.array([0.229, 0.224, 0.225], np.float32).reshape(3, 1, 1)
|
||||
|
||||
|
||||
def pixels(image):
|
||||
"""A normalized RGB image as a CHW float32 array in the model's input scale."""
|
||||
return (np.asarray(image, dtype=np.float32).transpose(2, 0, 1) / 255 - _MEAN) / _STD
|
||||
|
||||
|
||||
def features(model, x):
|
||||
"""Embeddings of a batch of pixels() tensors; differentiable for training."""
|
||||
import torch.nn.functional as F
|
||||
import torch
|
||||
|
||||
hidden = model(pixel_values=x).last_hidden_state
|
||||
cls = F.normalize(hidden[:, 0].float(), dim=1)
|
||||
patch = F.normalize(hidden[:, 1:].float().mean(dim=1), dim=1)
|
||||
return F.normalize(torch.cat([cls, patch], dim=1), dim=1)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Fake hand sketches generated from drawing geometry, for evaluation and training.
|
||||
|
||||
Sketches get wobbly lines, up to 15% distorted proportions, about half their small
|
||||
holes left out, a few degrees of tilt, random line weight and pen darkness, and
|
||||
are sometimes filled in as a silhouette.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
import sketch_common as sc
|
||||
|
||||
|
||||
def sketchify(polys, rng, reorient=True):
|
||||
"""A fake hand sketch of a drawing as a grayscale image. With reorient=False the
|
||||
sketch keeps the drawing's orientation apart from a few degrees of tilt."""
|
||||
lo, hi = sc.bounds(polys)
|
||||
span = max(hi - lo)
|
||||
kept = []
|
||||
for p in polys:
|
||||
a = np.array(p)
|
||||
closed = np.hypot(*(a[0] - a[-1])) < 1e-3 * span
|
||||
if closed and max(a.max(0) - a.min(0)) < 0.08 * span and rng.random() < 0.5:
|
||||
continue
|
||||
kept.append(a)
|
||||
if not kept:
|
||||
kept = [np.array(p) for p in polys]
|
||||
|
||||
angle = (rng.choice([0, 90, 180, 270]) if reorient else 0) + rng.gauss(0, 3)
|
||||
t = np.radians(angle)
|
||||
rot = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]])
|
||||
stretch = np.diag([rng.uniform(0.85, 1.15), 1.0])
|
||||
mirror = np.diag([-1.0, 1.0]) if reorient and rng.random() < 0.25 else np.eye(2)
|
||||
m = rot @ stretch @ mirror
|
||||
|
||||
nprng = np.random.default_rng(rng.randrange(1 << 30))
|
||||
amp = span * rng.uniform(0.001, 0.005)
|
||||
shaky = []
|
||||
for a in kept:
|
||||
seg = np.hypot(*np.diff(a, axis=0).T)
|
||||
length = np.concatenate([[0], np.cumsum(seg)])
|
||||
n = max(8, int(length[-1] / (span * 0.004)))
|
||||
s = np.linspace(0, length[-1], n)
|
||||
pts = np.stack([np.interp(s, length, a[:, 0]), np.interp(s, length, a[:, 1])], axis=1)
|
||||
noise = nprng.normal(size=(n + 30, 2))
|
||||
kernel = np.hanning(31) / np.hanning(31).sum()
|
||||
smooth = np.stack([np.convolve(noise[:, i], kernel, "valid") for i in range(2)], axis=1)[:n]
|
||||
smooth *= amp / (smooth.std() + 1e-12)
|
||||
shaky.append((pts - lo) @ m.T + smooth)
|
||||
|
||||
allpts = np.concatenate(shaky)
|
||||
lo2, hi2 = allpts.min(0), allpts.max(0)
|
||||
scale = 700 / max(hi2 - lo2)
|
||||
w, h = int((hi2[0] - lo2[0]) * scale) + 100, int((hi2[1] - lo2[1]) * scale) + 100
|
||||
to_px = lambda a: [((x - lo2[0]) * scale + 50, h - ((y - lo2[1]) * scale + 50)) for x, y in a]
|
||||
|
||||
if rng.random() < 0.2: # filled silhouette, holes by even-odd
|
||||
ink = np.zeros((h, w), bool)
|
||||
for a in shaky:
|
||||
layer = Image.new("1", (w, h), 0)
|
||||
ImageDraw.Draw(layer).polygon(to_px(a), fill=1)
|
||||
ink ^= np.asarray(layer)
|
||||
img = Image.fromarray(np.where(ink, 0, 255).astype(np.uint8))
|
||||
else:
|
||||
img = Image.new("L", (w, h), 255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
width, pen = rng.randint(2, 9), rng.randint(0, 120)
|
||||
for a in shaky:
|
||||
draw.line(to_px(a), fill=pen, width=width, joint="curve")
|
||||
return img
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Fine-tunes the sketch-search embedding on fake sketches of indexed drawings.
|
||||
|
||||
Each step pairs fresh synthetic sketches with their drawings' renderings and trains
|
||||
the model to pick each sketch's own drawing out of the batch (symmetric InfoNCE).
|
||||
Held-out drawings (sketch_common.is_holdout) and near-duplicate renderings, which
|
||||
would be false negatives within a batch, are left out. No hand labelling is needed.
|
||||
|
||||
python train.py --index C:\\SketchIndex --out C:\\SketchIndex\\model
|
||||
python build_index.py --index C:\\SketchIndex --server http://barge.lan:8091 --model C:\\SketchIndex\\model
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from PIL import Image
|
||||
|
||||
import build_index
|
||||
import sketch_common as sc
|
||||
from synthetic import sketchify
|
||||
|
||||
DUPLICATE = 0.985
|
||||
|
||||
|
||||
class SketchPairs(torch.utils.data.Dataset):
|
||||
def __init__(self, index, items):
|
||||
self.index, self.items = str(index), items
|
||||
|
||||
def __len__(self):
|
||||
return len(self.items)
|
||||
|
||||
def __getitem__(self, i):
|
||||
it = self.items[i]
|
||||
clean = Image.open(Path(self.index, "thumbs", it["thumb"])).convert("RGB")
|
||||
try:
|
||||
polys = build_index.load_polys(self.index, it)
|
||||
sketch = sc.normalize(sc.sketch_mask(sketchify(polys, random.Random(), reorient=False)))
|
||||
except Exception: # noqa: BLE001 - a degenerate sketch falls back to the clean image
|
||||
sketch = clean
|
||||
return torch.from_numpy(sc.pixels(sketch)), torch.from_numpy(sc.pixels(clean))
|
||||
|
||||
|
||||
def distinct(items, embeddings, device):
|
||||
"""Indices of items whose rendering is not a near-duplicate of an earlier item's."""
|
||||
e = torch.from_numpy(embeddings).to(device, torch.float16)
|
||||
keep = []
|
||||
for start in range(0, len(items), 4096):
|
||||
block = e[start:start + 4096] @ e[:start + 4096].T
|
||||
rows = torch.arange(block.shape[0], device=device) + start
|
||||
earlier = torch.arange(block.shape[1], device=device)[None, :] < rows[:, None]
|
||||
best = block.masked_fill(~earlier, -1).max(dim=1).values
|
||||
keep += [start + i for i, dup in enumerate((best >= DUPLICATE).cpu().numpy()) if not dup]
|
||||
return keep
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--index", required=True, help="index built with the base model")
|
||||
ap.add_argument("--out", required=True, help="folder for the fine-tuned model")
|
||||
ap.add_argument("--base", help="model to start from (default: the index's model)")
|
||||
ap.add_argument("--epochs", type=int, default=5)
|
||||
ap.add_argument("--batch", type=int, default=96)
|
||||
ap.add_argument("--lr", type=float, default=2e-5)
|
||||
ap.add_argument("--temperature", type=float, default=0.05)
|
||||
ap.add_argument("--workers", type=int, default=12)
|
||||
args = ap.parse_args()
|
||||
|
||||
from transformers import AutoModel
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
config = json.loads(Path(args.index, "config.json").read_text())
|
||||
items = [json.loads(l) for l in Path(args.index, "items.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
embeddings = np.load(Path(args.index, "embeddings.npy"))
|
||||
keep = distinct(items, embeddings, device)
|
||||
train = [items[i] for i in keep if not sc.is_holdout(items[i]["key"])]
|
||||
print(f"{len(items)} drawings, {len(keep)} distinct, training on {len(train)}")
|
||||
|
||||
loader = torch.utils.data.DataLoader(
|
||||
SketchPairs(args.index, train), batch_size=args.batch, shuffle=True, drop_last=True,
|
||||
num_workers=args.workers, persistent_workers=args.workers > 0, pin_memory=True)
|
||||
model = AutoModel.from_pretrained(args.base or config["model"]).to(device).train()
|
||||
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.05)
|
||||
total = args.epochs * len(loader)
|
||||
warmup = max(1, total // 20)
|
||||
sched = torch.optim.lr_scheduler.LambdaLR(
|
||||
opt, lambda s: min(1, (s + 1) / warmup) * 0.5 * (1 + math.cos(math.pi * min(1, s / total))))
|
||||
|
||||
step, started = 0, time.time()
|
||||
for epoch in range(args.epochs):
|
||||
losses, hits = [], []
|
||||
for sketches, cleans in loader:
|
||||
x = torch.cat([sketches, cleans]).to(device, non_blocking=True)
|
||||
with torch.autocast(device, dtype=torch.bfloat16):
|
||||
f = sc.features(model, x)
|
||||
fs, fc = f[:len(sketches)], f[len(sketches):]
|
||||
logits = fs @ fc.T / args.temperature
|
||||
target = torch.arange(len(fs), device=device)
|
||||
loss = (F.cross_entropy(logits, target) + F.cross_entropy(logits.T, target)) / 2
|
||||
opt.zero_grad(set_to_none=True)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
||||
opt.step()
|
||||
sched.step()
|
||||
step += 1
|
||||
losses.append(loss.item())
|
||||
hits.append((logits.argmax(dim=1) == target).float().mean().item())
|
||||
if step % 50 == 0:
|
||||
print(f" step {step}/{total} loss {np.mean(losses[-50:]):.3f} "
|
||||
f"in-batch top1 {100 * np.mean(hits[-50:]):.1f}% ({time.time() - started:.0f}s)", flush=True)
|
||||
print(f"epoch {epoch + 1}: loss {np.mean(losses):.3f}, in-batch top1 {100 * np.mean(hits):.1f}%", flush=True)
|
||||
model.save_pretrained(args.out)
|
||||
print(f"saved {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Configuration;
|
||||
using System.Drawing;
|
||||
using System.Reflection;
|
||||
using OpenNest.Properties;
|
||||
|
||||
namespace OpenNest.WinForms.Tests;
|
||||
|
||||
public sealed class AppDataSettingsProviderTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "OpenNestSettings-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
private string SettingsPath => Path.Combine(_directory, "user-settings.json");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory))
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsUseTheAppDataProvider()
|
||||
{
|
||||
var attribute = typeof(Settings).GetCustomAttribute<SettingsProviderAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(typeof(AppDataSettingsProvider).AssemblyQualifiedName, attribute.ProviderTypeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavedValuesReadBackInANewProvider()
|
||||
{
|
||||
var properties = Properties();
|
||||
var values = new AppDataSettingsProvider(SettingsPath, null).GetPropertyValues(new SettingsContext(), properties);
|
||||
values["IncludeDevBuilds"].PropertyValue = true;
|
||||
values["WindowLocation"].PropertyValue = new Point(40, 60);
|
||||
new AppDataSettingsProvider(SettingsPath, null).SetPropertyValues(new SettingsContext(), values);
|
||||
|
||||
var reloaded = new AppDataSettingsProvider(SettingsPath, null).GetPropertyValues(new SettingsContext(), Properties());
|
||||
|
||||
Assert.Equal(true, reloaded["IncludeDevBuilds"].PropertyValue);
|
||||
Assert.Equal(new Point(40, 60), reloaded["WindowLocation"].PropertyValue);
|
||||
Assert.Equal("", reloaded["FeedUrl"].PropertyValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntouchedDefaultsAreNotWritten()
|
||||
{
|
||||
var provider = new AppDataSettingsProvider(SettingsPath, null);
|
||||
var values = provider.GetPropertyValues(new SettingsContext(), Properties());
|
||||
_ = values["IncludeDevBuilds"].PropertyValue;
|
||||
values["FeedUrl"].PropertyValue = "http://feed";
|
||||
provider.SetPropertyValues(new SettingsContext(), values);
|
||||
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
|
||||
Assert.Contains("FeedUrl", json);
|
||||
Assert.DoesNotContain("IncludeDevBuilds", json);
|
||||
Assert.DoesNotContain("WindowLocation", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntriesThisBuildDoesNotKnowAreKept()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
File.WriteAllText(SettingsPath, """{ "FromNewerBuild": "x" }""");
|
||||
var provider = new AppDataSettingsProvider(SettingsPath, null);
|
||||
var values = provider.GetPropertyValues(new SettingsContext(), Properties());
|
||||
values["FeedUrl"].PropertyValue = "http://feed";
|
||||
|
||||
provider.SetPropertyValues(new SettingsContext(), values);
|
||||
|
||||
Assert.Contains("FromNewerBuild", File.ReadAllText(SettingsPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyValuesAreCarriedOverOnceWhenTheFileIsMissing()
|
||||
{
|
||||
var legacyCalls = 0;
|
||||
SettingsProvider Legacy()
|
||||
{
|
||||
legacyCalls++;
|
||||
return new FixedProvider(new() { ["IncludeDevBuilds"] = "True" });
|
||||
}
|
||||
|
||||
var values = new AppDataSettingsProvider(SettingsPath, Legacy).GetPropertyValues(new SettingsContext(), Properties());
|
||||
var reloaded = new AppDataSettingsProvider(SettingsPath, Legacy).GetPropertyValues(new SettingsContext(), Properties());
|
||||
|
||||
Assert.Equal(true, values["IncludeDevBuilds"].PropertyValue);
|
||||
Assert.Equal(true, reloaded["IncludeDevBuilds"].PropertyValue);
|
||||
Assert.Equal(1, legacyCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheDefaultUserConfigProviderCanBeReadForCarryOver()
|
||||
{
|
||||
var context = new SettingsContext { ["GroupName"] = typeof(AppDataSettingsProviderTests).FullName, ["SettingsKey"] = "" };
|
||||
|
||||
var values = new AppDataSettingsProvider(SettingsPath, () => new LocalFileSettingsProvider())
|
||||
.GetPropertyValues(context, Properties());
|
||||
|
||||
Assert.Equal(false, values["IncludeDevBuilds"].PropertyValue);
|
||||
Assert.False(File.Exists(SettingsPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACorruptFileReadsAsDefaults()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
File.WriteAllText(SettingsPath, "not json");
|
||||
|
||||
var values = new AppDataSettingsProvider(SettingsPath, null).GetPropertyValues(new SettingsContext(), Properties());
|
||||
|
||||
Assert.Equal(false, values["IncludeDevBuilds"].PropertyValue);
|
||||
}
|
||||
|
||||
private static SettingsPropertyCollection Properties()
|
||||
{
|
||||
return new SettingsPropertyCollection
|
||||
{
|
||||
UserSetting("IncludeDevBuilds", typeof(bool), "False"),
|
||||
UserSetting("FeedUrl", typeof(string), ""),
|
||||
UserSetting("WindowLocation", typeof(Point), "0, 0"),
|
||||
};
|
||||
}
|
||||
|
||||
private static SettingsProperty UserSetting(string name, Type type, string defaultValue)
|
||||
{
|
||||
var attributes = new SettingsAttributeDictionary { [typeof(UserScopedSettingAttribute)] = new UserScopedSettingAttribute() };
|
||||
return new SettingsProperty(name, type, null, false, defaultValue, SettingsSerializeAs.String, attributes, false, false);
|
||||
}
|
||||
|
||||
/// <summary>Stands in for user.config: returns the given serialized values.</summary>
|
||||
private sealed class FixedProvider : SettingsProvider
|
||||
{
|
||||
private readonly Dictionary<string, string> _values;
|
||||
|
||||
public FixedProvider(Dictionary<string, string> values) => _values = values;
|
||||
|
||||
public override string ApplicationName { get; set; } = "Test";
|
||||
|
||||
public override void Initialize(string name, NameValueCollection config) => base.Initialize("Fixed", config);
|
||||
|
||||
public override SettingsPropertyValueCollection GetPropertyValues(SettingsContext context, SettingsPropertyCollection collection)
|
||||
{
|
||||
var values = new SettingsPropertyValueCollection();
|
||||
foreach (SettingsProperty property in collection)
|
||||
{
|
||||
var value = new SettingsPropertyValue(property);
|
||||
if (_values.TryGetValue(property.Name, out var serialized))
|
||||
value.SerializedValue = serialized;
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
public override void SetPropertyValues(SettingsContext context, SettingsPropertyValueCollection collection) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Bends are kept in the drawing's source frame while its program starts at the origin, so a
|
||||
/// placed part draws them with <c>Source.Offset</c> removed.
|
||||
/// </summary>
|
||||
public class BendLinePlacementTests
|
||||
{
|
||||
[Fact]
|
||||
public void BendToWorld_RemovesSourceOffsetBeforePlacing()
|
||||
{
|
||||
var drawing = new Drawing("bent", new CNC.Program());
|
||||
drawing.Source.Offset = new Vector(100, 50);
|
||||
// In the source frame the bend runs across the part at local x = 5.
|
||||
var bend = new Bend { StartPoint = new Vector(105, 50), EndPoint = new Vector(105, 60) };
|
||||
var part = new Part(drawing, new Vector(10, 20));
|
||||
|
||||
Assert.Equal(new Vector(15, 20), PlateRenderer.BendToWorld(part, bend.StartPoint));
|
||||
Assert.Equal(new Vector(15, 30), PlateRenderer.BendToWorld(part, bend.EndPoint));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BendToWorld_RotatesAboutTheProgramOrigin()
|
||||
{
|
||||
var drawing = new Drawing("bent", new CNC.Program());
|
||||
drawing.Source.Offset = new Vector(100, 50);
|
||||
var part = new Part(drawing, new Vector(10, 20));
|
||||
part.Rotate(Angle.HalfPI, part.Location);
|
||||
|
||||
var world = PlateRenderer.BendToWorld(part, new Vector(105, 50));
|
||||
|
||||
Assert.Equal(10, world.X, 9);
|
||||
Assert.Equal(25, world.Y, 9);
|
||||
}
|
||||
}
|
||||
@@ -67,19 +67,29 @@ public class CutOffSegmentLabelTests
|
||||
.Select(p => Point.Round(p.LabelScreenPoint) + new System.Drawing.Size(dx, dy)).ToList();
|
||||
Assert.Equal(2, labels.Count);
|
||||
Assert.True(CountOrange(image, dx + 48, dx + 52, dy, dy + 99) >= 10, "Expected the cut-off line outside its ids.");
|
||||
// The id is drawn white on a disc of the line's color. Struck through, the 2px cut covers
|
||||
// the id's strokes on its own columns with orange; drawn over the cut, some stay white.
|
||||
foreach (var label in labels)
|
||||
Assert.Equal(0, CountOrange(image, label.X - 2, label.X + 2, label.Y - 4, label.Y + 4));
|
||||
{
|
||||
Assert.True(CountOrange(image, label.X - 6, label.X + 6, label.Y - 6, label.Y + 6) >= 10, "Expected the id's disc.");
|
||||
Assert.True(CountWhite(image, label.X - 1, label.X, label.Y - 5, label.Y + 3) >= 3, "Expected the id over the cut-off line.");
|
||||
}
|
||||
}, TimeSpan.FromMinutes(1), "Cut-off segment id background test timed out.");
|
||||
|
||||
private static int CountOrange(Bitmap image, int left, int right, int top, int bottom)
|
||||
private static int CountOrange(Bitmap image, int left, int right, int top, int bottom) =>
|
||||
Count(image, left, right, top, bottom, c => c.R >= 200 && c.G <= 120 && c.B <= 80);
|
||||
|
||||
private static int CountWhite(Bitmap image, int left, int right, int top, int bottom) =>
|
||||
Count(image, left, right, top, bottom, c => c.R >= 230 && c.G >= 230 && c.B >= 230);
|
||||
|
||||
private static int Count(Bitmap image, int left, int right, int top, int bottom, Func<Color, bool> match)
|
||||
{
|
||||
var count = 0;
|
||||
for (var y = top; y <= bottom; y++)
|
||||
{
|
||||
for (var x = left; x <= right; x++)
|
||||
{
|
||||
var c = image.GetPixel(x, y);
|
||||
if (c.R >= 200 && c.G <= 120 && c.B <= 80)
|
||||
if (match(image.GetPixel(x, y)))
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Controls;
|
||||
|
||||
@@ -79,6 +80,33 @@ public class GridStyleTests
|
||||
Assert.Equal(column.Width, firstPaintWidth());
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void DisposingAGridStopsItsShowingKeyboardToolTip() => ArchUnitsTests.RunSta(() =>
|
||||
{
|
||||
// WinForms leaves the keyboard tooltip's auto-pop timer running after the grid is
|
||||
// disposed; when it fires it hides the tooltip through the disposed grid and throws.
|
||||
const BindingFlags members = BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
using var form = new Form { Width = 600, Height = 300, ShowInTaskbar = false };
|
||||
var grid = GridStyle.Apply(new DataGridView { Dock = DockStyle.Fill, AllowUserToAddRows = false });
|
||||
grid.Columns.Add("Version", "Version");
|
||||
grid.Rows.Add("2");
|
||||
form.Controls.Add(grid);
|
||||
form.Show();
|
||||
Pump(grid);
|
||||
|
||||
// As when the keyboard moves to a cell in the embedded version history.
|
||||
var toolTip = (ToolTip)typeof(DataGridView).GetProperty("KeyboardToolTip", members).GetValue(grid);
|
||||
typeof(ToolTip).GetMethod("ShowKeyboardToolTip", members)
|
||||
.Invoke(toolTip, new object[] { "2", grid.Rows[0].Cells[0], 3000 });
|
||||
var timer = typeof(ToolTip).GetField("_timer", members);
|
||||
Assert.NotNull(timer.GetValue(toolTip));
|
||||
|
||||
form.Controls.Remove(grid);
|
||||
grid.Dispose();
|
||||
|
||||
Assert.Null(timer.GetValue(toolTip));
|
||||
});
|
||||
|
||||
private static Func<int> WidthAtFirstPaint(DataGridView grid, DataGridViewColumn column)
|
||||
{
|
||||
var width = -1;
|
||||
|
||||
@@ -54,7 +54,7 @@ public class LayoutPartLabelZoomTests
|
||||
using var image = new Bitmap(120, 120);
|
||||
using var g = Graphics.FromImage(image);
|
||||
g.Clear(Color.White);
|
||||
layout.DrawLabel(g, "12", muted: false, Brushes.Yellow);
|
||||
layout.DrawLabel(g, "12", Brushes.Yellow, Brushes.Black);
|
||||
|
||||
Assert.Equal(shown, !IsBlank(image));
|
||||
layout.ReleaseDisplayResources();
|
||||
|
||||
@@ -160,6 +160,54 @@ public class AutoNestFormLedgerStockTests
|
||||
Assert.Equal(25.4 * 48, form.GetPlateOptions().First(o => o.Id == "ledger:1").Width, 9);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void LoadLeavesOutCodesOnOtherOpenNests() => RunSta(() =>
|
||||
{
|
||||
var editing = Guid.NewGuid();
|
||||
using var form = new LedgerTestForm
|
||||
{
|
||||
ServerNestId = editing,
|
||||
InUse = LedgerCodesInUse.Known([Use("B-1", "2026-0042"), Use("D-1", "2026-0043")]),
|
||||
};
|
||||
GetGrid(form);
|
||||
|
||||
Assert.True(form.LoadLedgerStockAsync().IsCompleted);
|
||||
|
||||
Assert.Equal(editing, form.ExcludedNestId);
|
||||
var row = Assert.Single(form.LedgerRows);
|
||||
Assert.Equal(["D-2"], row.ItemCodes);
|
||||
Assert.Equal(1, row.Quantity);
|
||||
Assert.Equal("Ledger: 0 sheet sizes, 1 drop (4 on other nests)", Label(form).Text);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void LoadWithoutOtherNests_OffersEverythingAndSaysSo() => RunSta(() =>
|
||||
{
|
||||
using var form = new LedgerTestForm { InUse = LedgerCodesInUse.Unknown("the nest server could not be reached") };
|
||||
GetGrid(form);
|
||||
|
||||
Assert.True(form.LoadLedgerStockAsync().IsCompleted);
|
||||
|
||||
Assert.Equal(new[] { 3, 2 }, form.LedgerRows.Select(r => r.Quantity));
|
||||
Assert.Equal("Ledger: 1 sheet size, 2 drops (other nests not checked: the nest server could not be reached)",
|
||||
Label(form).Text);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void EveryRowOnOtherNests_IsNotReportedAsNoStock() => RunSta(() =>
|
||||
{
|
||||
using var form = new LedgerTestForm
|
||||
{
|
||||
InUse = LedgerCodesInUse.Known([Use("B-1", "A"), Use("D-1", "A"), Use("D-2", "A")]),
|
||||
};
|
||||
GetGrid(form);
|
||||
|
||||
Assert.True(form.LoadLedgerStockAsync().IsCompleted);
|
||||
|
||||
Assert.Empty(form.LedgerRows);
|
||||
Assert.Equal("Ledger: 0 sheet sizes, 0 drops (5 on other nests)", Label(form).Text);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void ClosingTheFormCancelsAPendingLoad() => RunSta(() =>
|
||||
{
|
||||
@@ -277,6 +325,14 @@ public class AutoNestFormLedgerStockTests
|
||||
public TaskCompletionSource<LedgerStockResult>? Pending { get; set; }
|
||||
public List<(string, string, double)> Requests { get; } = new();
|
||||
public CancellationToken LastToken { get; private set; }
|
||||
public LedgerCodesInUse InUse { get; set; } = LedgerCodesInUse.Known([]);
|
||||
public Guid? ExcludedNestId { get; private set; }
|
||||
|
||||
internal override Task<LedgerCodesInUse> FetchCodesInUseAsync(Guid excludeNestId, CancellationToken cancellationToken)
|
||||
{
|
||||
ExcludedNestId = excludeNestId;
|
||||
return Task.FromResult(InUse);
|
||||
}
|
||||
|
||||
internal override bool LedgerConfigured => Configured;
|
||||
|
||||
@@ -289,6 +345,9 @@ public class AutoNestFormLedgerStockTests
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenNest.Data.NestStockCodeUse Use(string code, string nest) =>
|
||||
new() { ItemCode = code, NestId = Guid.NewGuid(), NestName = nest, Status = NestStatus.Quote };
|
||||
|
||||
private static Nest LedgerNest() => new() { Material = new Material("Steel", "A36"), Thickness = 0.25 };
|
||||
|
||||
private static List<PlateOption> Manual(double firstCost, double secondCost) => new()
|
||||
|
||||
@@ -187,6 +187,88 @@ public class CuttingInspectionPanelTests
|
||||
Assert.Same(original, editor.PlateView.Plate);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void ScopeSelectorReplansForAllPlatesAndBackAndRemembersTheChoice() => RunSta(() =>
|
||||
{
|
||||
using var editor = TwoPlateEditor();
|
||||
editor.Show();
|
||||
editor.PlateManager.LoadAt(0);
|
||||
editor.OpenCuttingInspection();
|
||||
var panel = editor.CuttingInspection!;
|
||||
var remembered = new List<bool>();
|
||||
panel.RememberScope = remembered.Add;
|
||||
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
|
||||
var scope = Field<ComboBox>(panel, "scopeSelector");
|
||||
Assert.True(scope.Visible);
|
||||
Assert.Equal(0, scope.SelectedIndex);
|
||||
Assert.Equal("All plates (2)", scope.Items[1]);
|
||||
Assert.Single(panel.Proposal.Plates);
|
||||
|
||||
scope.SelectedIndex = 1;
|
||||
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
|
||||
Assert.True(panel.AllPlates);
|
||||
Assert.Equal(2, panel.Proposal.Plates.Count);
|
||||
Assert.True(Field<Button>(panel, "nextButton").Visible);
|
||||
Assert.Contains("2 plates", Field<Label>(panel, "statusLabel").Text);
|
||||
|
||||
scope.SelectedIndex = 0;
|
||||
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
|
||||
Assert.False(panel.AllPlates);
|
||||
Assert.Single(panel.Proposal.Plates);
|
||||
Assert.False(Field<Button>(panel, "nextButton").Visible);
|
||||
Assert.Equal(new[] { true, false }, remembered);
|
||||
panel.RequestClose();
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void ScopeChangedMidPlanReplansOnceTheRunningPlanCancels() => RunSta(() =>
|
||||
{
|
||||
using var editor = TwoPlateEditor();
|
||||
editor.Show();
|
||||
editor.PlateManager.LoadAt(0);
|
||||
using var entered = new ManualResetEventSlim();
|
||||
using var release = new ManualResetEventSlim();
|
||||
var runs = 0;
|
||||
editor.CuttingBeforePlan = token =>
|
||||
{
|
||||
if (Interlocked.Increment(ref runs) > 1) return;
|
||||
entered.Set();
|
||||
release.Wait(TimeSpan.FromSeconds(30));
|
||||
Assert.True(token.IsCancellationRequested);
|
||||
};
|
||||
try
|
||||
{
|
||||
editor.OpenCuttingInspection();
|
||||
var panel = editor.CuttingInspection!;
|
||||
panel.RememberScope = _ => { };
|
||||
Assert.True(entered.Wait(TimeSpan.FromSeconds(30)));
|
||||
Field<ComboBox>(panel, "scopeSelector").SelectedIndex = 1;
|
||||
Assert.False(panel.AllPlates); // The running plan still reads the old scope.
|
||||
release.Set();
|
||||
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
|
||||
Assert.True(panel.AllPlates);
|
||||
Assert.Equal(2, panel.Proposal.Plates.Count);
|
||||
panel.RequestClose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void ScopeSelectorIsHiddenWhenOnlyOnePlateHasParts() => RunSta(() =>
|
||||
{
|
||||
using var editor = Editor();
|
||||
editor.Show();
|
||||
editor.PlateManager.LoadAt(0);
|
||||
editor.OpenCuttingInspection();
|
||||
var panel = editor.CuttingInspection!;
|
||||
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
|
||||
Assert.False(Field<ComboBox>(panel, "scopeSelector").Visible);
|
||||
panel.RequestClose();
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void BlockedOverlapRowShadesTheOverlapOnTheLivePlate() => RunSta(() =>
|
||||
{
|
||||
@@ -950,6 +1032,16 @@ public class CuttingInspectionPanelTests
|
||||
return new EditNestForm(nest);
|
||||
}
|
||||
|
||||
private static EditNestForm TwoPlateEditor()
|
||||
{
|
||||
var editor = Editor();
|
||||
var second = editor.Nest.Plates[^1]; // Fill the managed empty sentinel; a new one is appended.
|
||||
second.Size = new OpenNest.Geometry.Size(100, 100);
|
||||
second.Parts.Add(Square("left", 1));
|
||||
second.Parts.Add(Square("right", 20));
|
||||
return editor;
|
||||
}
|
||||
|
||||
private static Part Square(string name, double x)
|
||||
{
|
||||
var program = new OpenNest.CNC.Program();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Data.MaterialLedger;
|
||||
using OpenNest.Forms;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Save and post re-check a nest's ledger item codes: taken codes are listed and the
|
||||
/// programmer goes on, removes them (one undoable edit) or cancels; a ledger that cannot be
|
||||
/// read never blocks.
|
||||
/// </summary>
|
||||
public class LedgerCodeRecheckTests
|
||||
{
|
||||
[Fact]
|
||||
public void Proceed_KeepsTheCodesAndRecordsNothing() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var fake = new Fakes { Answer = LedgerCodeRecheck.Choice.Proceed };
|
||||
var before = UndoTestEnv.Describe(env.Nest);
|
||||
var count = env.Editor.History.UndoCount;
|
||||
|
||||
Assert.True(Confirm(fake, env, "Post"));
|
||||
|
||||
Assert.Equal(before, UndoTestEnv.Describe(env.Nest));
|
||||
Assert.Equal(count, env.Editor.History.UndoCount);
|
||||
Assert.Equal("Post", fake.AskedAction);
|
||||
Assert.Equal(
|
||||
[(1, "B-1", "Only 1 of the 2 sheets needed free in MaterialLedger"), (2, "S-1", "On nest 2026-0042")],
|
||||
fake.Asked!.Select(c => (c.PlateNumber, c.ItemCode, c.Reason)));
|
||||
}, CodedNest);
|
||||
|
||||
[Fact]
|
||||
public void RemoveCodes_IsOneUndoableEditThatKeepsTheLayout() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var fake = new Fakes { Answer = LedgerCodeRecheck.Choice.RemoveCodes };
|
||||
var parts = env.Nest.Plates.Sum(p => p.Parts.Count);
|
||||
|
||||
env.AssertOneUndoableEdit(LedgerCodeRecheck.RemoveLabel, () => Assert.True(Confirm(fake, env, "Save")));
|
||||
|
||||
Assert.Equal(parts, env.Nest.Plates.Sum(p => p.Parts.Count));
|
||||
Assert.Equal(2, env.Nest.Plates[0].Quantity);
|
||||
Assert.Empty(env.Nest.Plates[0].StockItemCodes);
|
||||
Assert.Equal(["KEEP-1"], env.Nest.Plates[3].StockItemCodes);
|
||||
Assert.Equal("Removed 2 ledger item codes from 3 sheets", fake.Status);
|
||||
}, CodedNest);
|
||||
|
||||
[Fact]
|
||||
public void Cancel_StopsTheAttemptAndChangesNothing() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var fake = new Fakes { Answer = LedgerCodeRecheck.Choice.Cancel };
|
||||
var before = UndoTestEnv.Describe(env.Nest);
|
||||
|
||||
Assert.False(Confirm(fake, env, "Save"));
|
||||
|
||||
Assert.Equal(before, UndoTestEnv.Describe(env.Nest));
|
||||
Assert.Equal("Save cancelled", fake.Status);
|
||||
}, CodedNest);
|
||||
|
||||
[Fact]
|
||||
public void UnreachableLedger_SkipsTheCheckWithANote() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var fake = new Fakes { Stock = new LedgerStockResult.Unreachable("No answer within 5 s.") };
|
||||
|
||||
Assert.True(Confirm(fake, env, "Save"));
|
||||
|
||||
Assert.Null(fake.Asked);
|
||||
Assert.Equal("Ledger codes not re-checked: MaterialLedger unreachable (No answer within 5 s.)", fake.Status);
|
||||
|
||||
var failing = new Fakes { StockError = new HttpRequestException("refused") };
|
||||
Assert.True(Confirm(failing, env, "Post"));
|
||||
Assert.Null(failing.Asked);
|
||||
Assert.Equal("Ledger codes not re-checked: refused", failing.Status);
|
||||
}, CodedNest);
|
||||
|
||||
[Fact]
|
||||
public void NoLedgerOrNoCodes_ChecksNothing() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var unconfigured = new Fakes { Configured = false };
|
||||
Assert.True(Confirm(unconfigured, env, "Save"));
|
||||
Assert.Equal(0, unconfigured.Fetches);
|
||||
Assert.Null(unconfigured.Status);
|
||||
|
||||
foreach (var plate in env.Nest.Plates)
|
||||
plate.StockItemCodes = [];
|
||||
var uncoded = new Fakes();
|
||||
Assert.True(Confirm(uncoded, env, "Save"));
|
||||
Assert.Equal(0, uncoded.Fetches);
|
||||
}, CodedNest);
|
||||
|
||||
[Fact]
|
||||
public void OtherNestsUnknown_ChecksTheLedgerAloneAndSaysSo() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var fake = new Fakes
|
||||
{
|
||||
Stock = Free(("B-1", 2), ("S-1", 1), ("KEEP-1", 1)),
|
||||
InUse = LedgerCodesInUse.Unknown("nests are saved as files"),
|
||||
};
|
||||
|
||||
Assert.True(Confirm(fake, env, "Save"));
|
||||
|
||||
Assert.Null(fake.Asked);
|
||||
Assert.Equal("Ledger codes checked against MaterialLedger only; other nests not checked: nests are saved as files", fake.Status);
|
||||
}, CodedNest);
|
||||
|
||||
[Fact]
|
||||
public void Dialog_GoesOnOnlyAfterTheAcknowledgement() => StaTestThread.Run(() =>
|
||||
{
|
||||
LedgerCodeConflict[] conflicts = [new(1, "B-1", "Not free in MaterialLedger")];
|
||||
using (var form = new LedgerCodeConflictForm("Post", conflicts))
|
||||
{
|
||||
form.Show();
|
||||
var proceed = Find<Button>(form, "proceedButton");
|
||||
Assert.Equal("Post Anyway", proceed.Text);
|
||||
Assert.False(proceed.Enabled);
|
||||
Assert.Single(Find<ListView>(form, "conflictList").Items);
|
||||
|
||||
Find<CheckBox>(form, "acknowledgeBox").Checked = true;
|
||||
Assert.True(proceed.Enabled);
|
||||
proceed.PerformClick();
|
||||
Assert.Equal(LedgerCodeRecheck.Choice.Proceed, form.Choice);
|
||||
}
|
||||
|
||||
using (var form = new LedgerCodeConflictForm("Save", conflicts))
|
||||
{
|
||||
form.Show();
|
||||
Find<Button>(form, "removeButton").PerformClick();
|
||||
Assert.Equal(LedgerCodeRecheck.Choice.RemoveCodes, form.Choice);
|
||||
}
|
||||
|
||||
using (var form = new LedgerCodeConflictForm("Save", conflicts))
|
||||
{
|
||||
form.Show();
|
||||
Find<Button>(form, "cancelButton").PerformClick();
|
||||
Assert.Equal(LedgerCodeRecheck.Choice.Cancel, form.Choice);
|
||||
}
|
||||
}, TimeSpan.FromSeconds(15), "The STA test did not complete.");
|
||||
|
||||
private static bool Confirm(Fakes fake, UndoTestEnv env, string action) =>
|
||||
fake.Create().ConfirmAsync(env.Host, env.Editor, action).GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// The undo nest with a material: plate 1 cuts two sheets of bundle B-1, plate 2 is on S-1,
|
||||
/// plate 4 on KEEP-1 (free and on no other nest).
|
||||
/// </summary>
|
||||
private static Nest CodedNest()
|
||||
{
|
||||
var nest = UndoTestEnv.BuildNest();
|
||||
nest.Material = new Material("Steel", "A36");
|
||||
nest.Thickness = 0.25;
|
||||
nest.Plates[0].Quantity = 2;
|
||||
nest.Plates[0].StockItemCodes = ["B-1", "B-1"];
|
||||
nest.Plates[1].StockItemCodes = ["S-1"];
|
||||
nest.Plates[3].StockItemCodes = ["KEEP-1"];
|
||||
return nest;
|
||||
}
|
||||
|
||||
private static LedgerStockResult Free(params (string Code, int Quantity)[] items) =>
|
||||
new LedgerStockResult.Ok(Array.Empty<LedgerStockSpec>(), items.Select(i =>
|
||||
new LedgerStockItem(i.Code, 7, LedgerItemKinds.FullSheet, 48, 96, i.Quantity,
|
||||
LedgerItemShapes.Rectangular, "R1", "Rack 1", null, null, DateTime.UtcNow)).ToList());
|
||||
|
||||
private static T Find<T>(Form form, string name) where T : Control =>
|
||||
Assert.IsType<T>(form.Controls.Find(name, true).Single());
|
||||
|
||||
private sealed class Fakes
|
||||
{
|
||||
public bool Configured { get; init; } = true;
|
||||
public LedgerStockResult Stock { get; init; } = Free(("B-1", 1), ("S-1", 1), ("KEEP-1", 1));
|
||||
public Exception? StockError { get; init; }
|
||||
public LedgerCodesInUse InUse { get; init; } = LedgerCodesInUse.Known(
|
||||
[new NestStockCodeUse { ItemCode = "S-1", NestId = Guid.NewGuid(), NestName = "2026-0042" }]);
|
||||
public LedgerCodeRecheck.Choice Answer { get; init; }
|
||||
public int Fetches { get; private set; }
|
||||
public string? AskedAction { get; private set; }
|
||||
public IReadOnlyList<LedgerCodeConflict>? Asked { get; private set; }
|
||||
public string? Status { get; private set; }
|
||||
|
||||
public LedgerCodeRecheck Create() => new(
|
||||
() => Configured,
|
||||
(_, _, _, _) =>
|
||||
{
|
||||
Fetches++;
|
||||
return StockError != null ? Task.FromException<LedgerStockResult>(StockError) : Task.FromResult(Stock);
|
||||
},
|
||||
(_, _) => Task.FromResult<LedgerHoldsResult>(new LedgerHoldsResult.Ok(Array.Empty<LedgerHold>())),
|
||||
(_, _) => Task.FromResult(InUse),
|
||||
(_, action, conflicts) =>
|
||||
{
|
||||
AskedAction = action;
|
||||
Asked = conflicts;
|
||||
return Answer;
|
||||
},
|
||||
text => Status = text);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ using OpenNest.Forms;
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Machine clamps in the desktop: the Clamps tab of Machine Configuration edits a machine's rail,
|
||||
/// Machine clamps and support beds in the desktop: the Clamps and Support Bed tabs of Machine
|
||||
/// Configuration edit a machine's rail and bed,
|
||||
/// and Nest > Clamps saves its machine defaults to the nest's machine.
|
||||
/// </summary>
|
||||
public class MachineClampsFormsTests
|
||||
@@ -51,6 +52,47 @@ public class MachineClampsFormsTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportBedTab_EditsTheMachineBed()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
var provider = new LocalJsonProvider(directory);
|
||||
var machine = new MachineConfig
|
||||
{
|
||||
Name = "Plasma",
|
||||
SupportGrid = new SupportGridSettings { PitchX = 3, SlatPitch = 4 },
|
||||
};
|
||||
provider.SaveMachine(machine);
|
||||
|
||||
using var form = new MachineConfigForm(provider);
|
||||
var node = Find<TreeView>(form, "_tree").Nodes.Cast<TreeNode>().Single(n => n.Tag is MachineConfig);
|
||||
typeof(MachineConfigForm)
|
||||
.GetMethod("Tree_AfterSelect", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
|
||||
.Invoke(form, new object[] { null, new TreeViewEventArgs(node) });
|
||||
var shown = (MachineConfig)node.Tag;
|
||||
|
||||
var tab = All<TabControl>(form).Single().TabPages.Cast<TabPage>().Single(p => p.Text == "Support Bed");
|
||||
All<CheckBox>(tab).Single(c => c.Text == "Give new nests this support bed").Checked = true;
|
||||
All<ComboBox>(tab).Single(c => c.Items.Contains("Slats")).SelectedIndex = 1;
|
||||
|
||||
Assert.True(shown.SupportGrid!.Enabled);
|
||||
Assert.True(shown.SupportGrid.UseSlats);
|
||||
Assert.Equal(3, shown.SupportGrid.PitchX);
|
||||
var slats = Assert.IsType<OpenNest.Support.SupportSlats>(shown.SupportGrid.BedFor(Units.Inches));
|
||||
Assert.Equal(4, slats.Pitch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampsDialog_WithoutAMachine_CannotSaveMachineDefaults()
|
||||
{
|
||||
|
||||
@@ -59,6 +59,56 @@ public class SavedNestsFormTests
|
||||
"The saved-nest dialog test did not complete.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingAHistoricalVersion_ShowsItsPlatesInTheDetailsAndPreview()
|
||||
{
|
||||
StaTestThread.Run(
|
||||
() =>
|
||||
{
|
||||
var job = Record("Versioned job");
|
||||
var current = NestFile((Quantity: 1, Parts: 1));
|
||||
var earlier = NestFile((Quantity: 1, Parts: 3), (Quantity: 1, Parts: 2));
|
||||
job.Revision = 3;
|
||||
job.FileSize = current.Length;
|
||||
var repository = new FakeRepository(job);
|
||||
repository.Files[job.Id] = current;
|
||||
repository.RevisionFiles[(job.Id, 2)] = earlier;
|
||||
repository.Revisions[job.Id] =
|
||||
[
|
||||
new NestRecord { Id = job.Id, Revision = 3, Name = job.Name, FileSize = current.Length },
|
||||
new NestRecord { Id = job.Id, Revision = 2, Name = job.Name, FileSize = earlier.Length },
|
||||
];
|
||||
|
||||
using var form = new SavedNestsForm(repository);
|
||||
form.Show();
|
||||
var plates = Field<DataGridView>(form, "platesGrid");
|
||||
var preview = Field<OpenNest.Controls.PlateView>(form, "platePreview");
|
||||
var status = Field<Label>(form, "detailsStatus");
|
||||
PumpUntil(() => plates.Rows.Count == 1, "the current version's plates");
|
||||
var currentPlate = preview.Plate;
|
||||
|
||||
Field<TabControl>(form, "detailsTabs").SelectedIndex = 2;
|
||||
NestHistoryForm? history = null;
|
||||
PumpUntil(
|
||||
() => (history = Field<NestHistoryForm>(form, "historyView")) != null
|
||||
&& Field<DataGridView>(history, "revisions").Rows.Count == 2,
|
||||
"the version history");
|
||||
var revisions = Field<DataGridView>(history!, "revisions");
|
||||
|
||||
revisions.CurrentCell = revisions.Rows[1].Cells[0];
|
||||
PumpUntil(() => plates.Rows.Count == 2 && status.Text.StartsWith("Version 2"), "version 2's plates");
|
||||
Assert.NotSame(currentPlate, preview.Plate);
|
||||
Assert.Equal(3, preview.Plate.Parts.Count);
|
||||
Assert.True(preview.Visible);
|
||||
|
||||
revisions.CurrentCell = revisions.Rows[0].Cells[0];
|
||||
PumpUntil(() => plates.Rows.Count == 1 && !status.Text.StartsWith("Version"), "the current version again");
|
||||
Assert.Single(preview.Plate.Parts);
|
||||
},
|
||||
TestTimeout,
|
||||
"The version preview test did not complete.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatePreview_StepsThroughThePlates_AndFollowsThePlatesTable()
|
||||
{
|
||||
@@ -350,12 +400,18 @@ public class SavedNestsFormTests
|
||||
public int UpdateAttempts { get; private set; }
|
||||
public int ExpectedRevision { get; private set; }
|
||||
public Dictionary<Guid, byte[]> Files { get; } = new();
|
||||
public Dictionary<(Guid Id, int Revision), byte[]> RevisionFiles { get; } = new();
|
||||
public Dictionary<Guid, NestRecord[]> Revisions { get; } = new();
|
||||
|
||||
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) =>
|
||||
GetFileAsync(id, cancellationToken);
|
||||
RevisionFiles.TryGetValue((id, revisionNumber), out var file)
|
||||
? Task.FromResult<byte[]?>(file)
|
||||
: GetFileAsync(id, cancellationToken);
|
||||
|
||||
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new NestRevisionPage());
|
||||
Task.FromResult(Revisions.TryGetValue(id, out var items)
|
||||
? new NestRevisionPage { Items = items, Total = items.Length, Offset = offset, Limit = limit }
|
||||
: new NestRevisionPage());
|
||||
|
||||
public async Task<NestRecord> UpdateFileAsync(Guid id, byte[] bytes, NestRecord metadata, int expectedRevision,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using Velopack;
|
||||
using Velopack.Logging;
|
||||
using Velopack.Sources;
|
||||
|
||||
namespace OpenNest.WinForms.Tests;
|
||||
|
||||
public class ServerFirstUpdateSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void FeedUrlWinsOverTheNestServer()
|
||||
{
|
||||
var source = Assert.IsType<SimpleWebSource>(
|
||||
UpdaterService.CreateSource("http://feed.example:8080", includeDevBuilds: true, serverUrl: "http://nests.example:8090"));
|
||||
|
||||
Assert.Equal("http://feed.example:8080/", source.BaseUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutANestServerUpdatesComeFromGitHub()
|
||||
{
|
||||
Assert.IsType<GithubSource>(UpdaterService.CreateSource(null, includeDevBuilds: false, serverUrl: null));
|
||||
Assert.IsType<GithubSource>(UpdaterService.CreateSource("", includeDevBuilds: false, serverUrl: " "));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "http://nests.example:8090/updates/stable")]
|
||||
[InlineData(true, "http://nests.example:8090/updates/dev")]
|
||||
public void DatabaseModeReadsTheServersChannelFirst(bool includeDevBuilds, string expected)
|
||||
{
|
||||
Assert.IsType<ServerFirstUpdateSource>(
|
||||
UpdaterService.CreateSource(null, includeDevBuilds, "http://nests.example:8090/"));
|
||||
|
||||
Assert.Equal(expected, ServerChannelUrl(includeDevBuilds, "http://nests.example:8090/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AServerFeedIsUsedAndItsPackagesDownloadFromTheServer()
|
||||
{
|
||||
var server = new FakeSource("0.6.1");
|
||||
var github = new FakeSource("0.6.1");
|
||||
var source = new ServerFirstUpdateSource(server, github);
|
||||
|
||||
var feed = await source.GetReleaseFeed(NullVelopackLogger.Instance, "OpenNest", "win");
|
||||
await source.DownloadReleaseEntry(NullVelopackLogger.Instance, feed.Assets[0], "package", _ => { });
|
||||
|
||||
Assert.Same(server, source.LastFeedSource);
|
||||
Assert.Equal((1, 1), (server.FeedRequests, server.Downloads));
|
||||
Assert.Equal((0, 0), (github.FeedRequests, github.Downloads));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenTheServerHasNoFeedGitHubAnswersAndServesItsOwnPackage()
|
||||
{
|
||||
var server = new FakeSource(null);
|
||||
var github = new FakeSource("0.6.1");
|
||||
var source = new ServerFirstUpdateSource(server, github);
|
||||
|
||||
var feed = await source.GetReleaseFeed(NullVelopackLogger.Instance, "OpenNest", "win");
|
||||
await source.DownloadReleaseEntry(NullVelopackLogger.Instance, feed.Assets[0], "package", _ => { });
|
||||
|
||||
Assert.Same(github, source.LastFeedSource);
|
||||
Assert.Equal((1, 0), (server.FeedRequests, server.Downloads));
|
||||
Assert.Equal((1, 1), (github.FeedRequests, github.Downloads));
|
||||
}
|
||||
|
||||
private static string ServerChannelUrl(bool includeDevBuilds, string serverUrl)
|
||||
{
|
||||
var source = (ServerFirstUpdateSource)UpdaterService.CreateSource(null, includeDevBuilds, serverUrl);
|
||||
var field = typeof(ServerFirstUpdateSource).GetField("_server", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
return ((SimpleWebSource)field.GetValue(source)!).BaseUri.ToString().TrimEnd('/');
|
||||
}
|
||||
|
||||
/// <summary>A feed with one full package, or (version null) a source that fails like an unreachable server.</summary>
|
||||
private sealed class FakeSource : IUpdateSource
|
||||
{
|
||||
private readonly string? _version;
|
||||
|
||||
public FakeSource(string? version) => _version = version;
|
||||
|
||||
public int FeedRequests { get; private set; }
|
||||
|
||||
public int Downloads { get; private set; }
|
||||
|
||||
public Task<VelopackAssetFeed> GetReleaseFeed(IVelopackLogger logger, string appId, string channel,
|
||||
Guid? stagingId = null, VelopackAsset? latestLocalRelease = null)
|
||||
{
|
||||
FeedRequests++;
|
||||
if (_version is null)
|
||||
throw new HttpRequestException("Response status code does not indicate success: 503 (Service Unavailable).");
|
||||
|
||||
return Task.FromResult(new VelopackAssetFeed
|
||||
{
|
||||
Assets = new[]
|
||||
{
|
||||
new VelopackAsset { PackageId = appId, Version = SemanticVersion.Parse(_version), Type = VelopackAssetType.Full, FileName = $"OpenNest-{_version}-full.nupkg" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public Task DownloadReleaseEntry(IVelopackLogger logger, VelopackAsset releaseEntry, string localFile,
|
||||
Action<int> progress, CancellationToken cancelToken = default)
|
||||
{
|
||||
Downloads++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user