mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-28 12:41:05 -04:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -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." }
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ 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
|
||||
@@ -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
|
||||
|
||||
@@ -70,6 +73,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): GitHub Releases by default, `UpdateFeedUrl` for a self-hosted feed, release Velopack assets 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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace OpenNest.WinForms.Tests;
|
||||
|
||||
public class UpdaterServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ManualCheckOutsideAnInstallationReportsNotInstalled()
|
||||
{
|
||||
// Test hosts never start through VelopackApp, so there is no locator.
|
||||
using var updater = new UpdaterService(feedUrl: null, includeDevBuilds: false);
|
||||
|
||||
Assert.False(updater.CanUpdate);
|
||||
Assert.Equal(UpdaterService.CheckResult.NotInstalled, await updater.CheckNowAsync());
|
||||
Assert.False(updater.HasPendingUpdate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionOutsideAnInstallationIsTheAssemblyVersionWithoutBuildMetadata()
|
||||
{
|
||||
using var updater = new UpdaterService(feedUrl: null, includeDevBuilds: false);
|
||||
|
||||
Assert.Equal(UpdaterService.DisplayVersion(), updater.CurrentVersion);
|
||||
Assert.DoesNotContain("+", updater.CurrentVersion);
|
||||
Assert.StartsWith("0.", updater.CurrentVersion);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server.Tests", "OpenNest.Server.Tests\OpenNest.Server.Tests.csproj", "{91DCA585-CEBF-4B80-B239-591CD0C919FA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Desktop", "OpenNest.Desktop\OpenNest.Desktop.csproj", "{A20A0A3F-06FA-4B94-91E2-302D9040B380}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Desktop.Tests", "OpenNest.Desktop.Tests\OpenNest.Desktop.Tests.csproj", "{1702C640-0699-4815-AB1F-686A2563F3A0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Avalonia", "OpenNest.Avalonia\OpenNest.Avalonia.csproj", "{BE640107-4749-4C43-AC3A-0F2349422E16}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -326,6 +332,42 @@ Global
|
||||
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.Build.0 = Release|Any CPU
|
||||
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace OpenNest.Controls
|
||||
private const int LabelWidth = 150;
|
||||
private const int FieldIndent = 16;
|
||||
private const int InputWidth = 100;
|
||||
// The lead preview's least height, below the type with the most fields.
|
||||
private const int PreviewHeight = 110;
|
||||
|
||||
private static readonly Color HintColor = SystemColors.GrayText;
|
||||
|
||||
@@ -40,6 +42,9 @@ namespace OpenNest.Controls
|
||||
private readonly TableLayoutPanel pnlInternalLeadIn,
|
||||
pnlInternalLeadOut;
|
||||
|
||||
private readonly LeadPreview externalPreview = new LeadPreview { Emphasis = ContourType.External };
|
||||
private readonly LeadPreview internalPreview = new LeadPreview { Emphasis = ContourType.Internal };
|
||||
|
||||
private readonly CheckBox chkTabsEnabled;
|
||||
private readonly TableLayoutPanel tabOptions;
|
||||
private readonly NumericUpDown nudTabWidth;
|
||||
@@ -118,6 +123,7 @@ namespace OpenNest.Controls
|
||||
|
||||
var externalContent = SetupTab(
|
||||
tabExternal,
|
||||
externalPreview,
|
||||
out cboExternalLeadIn,
|
||||
out pnlExternalLeadIn,
|
||||
out cboExternalLeadOut,
|
||||
@@ -125,6 +131,7 @@ namespace OpenNest.Controls
|
||||
);
|
||||
SetupTab(
|
||||
tabInternal,
|
||||
internalPreview,
|
||||
out cboInternalLeadIn,
|
||||
out pnlInternalLeadIn,
|
||||
out cboInternalLeadOut,
|
||||
@@ -275,6 +282,7 @@ namespace OpenNest.Controls
|
||||
PopulateDropdowns();
|
||||
FitLeadTabs(externalContent);
|
||||
WireChangeEvents();
|
||||
UpdatePreviews();
|
||||
}
|
||||
|
||||
public override Size GetPreferredSize(Size proposedSize) => layout.GetPreferredSize(proposedSize);
|
||||
@@ -333,6 +341,14 @@ namespace OpenNest.Controls
|
||||
nudLeadInAngleIncrement.Enabled = p.RoundLeadInAngles;
|
||||
|
||||
suppressEvents = false;
|
||||
UpdatePreviews();
|
||||
}
|
||||
|
||||
private void UpdatePreviews()
|
||||
{
|
||||
var parameters = BuildParameters();
|
||||
externalPreview.Parameters = parameters;
|
||||
internalPreview.Parameters = parameters;
|
||||
}
|
||||
|
||||
private void UpdateSupportTabOptions()
|
||||
@@ -347,8 +363,10 @@ namespace OpenNest.Controls
|
||||
|
||||
private void OnParametersChanged()
|
||||
{
|
||||
if (!suppressEvents)
|
||||
ParametersChanged?.Invoke(this, EventArgs.Empty);
|
||||
if (suppressEvents)
|
||||
return;
|
||||
UpdatePreviews();
|
||||
ParametersChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -374,13 +392,15 @@ namespace OpenNest.Controls
|
||||
BuildLeadInParamControls(pnlExternalLeadIn, 0);
|
||||
BuildLeadOutParamControls(pnlExternalLeadOut, 0);
|
||||
|
||||
var contentHeight = content.GetPreferredSize(Size.Empty).Height + tallestLeadIn + tallestLeadOut;
|
||||
var contentHeight = content.GetPreferredSize(Size.Empty).Height + tallestLeadIn + tallestLeadOut
|
||||
+ PreviewHeight;
|
||||
// Tab strip, page padding and borders.
|
||||
tabControl.Height = contentHeight + Font.Height + 28;
|
||||
}
|
||||
|
||||
private TableLayoutPanel SetupTab(
|
||||
TabPage tab,
|
||||
LeadPreview preview,
|
||||
out ComboBox leadInCombo,
|
||||
out TableLayoutPanel leadInPanel,
|
||||
out ComboBox leadOutCombo,
|
||||
@@ -412,7 +432,12 @@ namespace OpenNest.Controls
|
||||
leadOutPanel = CreateGrid(FieldIndent);
|
||||
content.Controls.Add(leadOutPanel);
|
||||
|
||||
// The preview fills whatever the fields leave, so it grows for types with fewer.
|
||||
preview.Dock = DockStyle.Fill;
|
||||
preview.Margin = new Padding(0, 6, 0, 0);
|
||||
tab.Controls.Add(preview);
|
||||
tab.Controls.Add(content);
|
||||
preview.BringToFront();
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Draws a sample part with one hole and the leads <see cref="CuttingParameters"/> would cut
|
||||
/// them with. The part is sized from the leads so they stay readable whatever their size;
|
||||
/// the leads of <see cref="Emphasis"/> are drawn strong, the other contour's faint.
|
||||
/// </summary>
|
||||
public class LeadPreview : Control
|
||||
{
|
||||
private const float Inset = 10;
|
||||
|
||||
// Perimeters run CW and holes CCW, as most parts are drawn.
|
||||
private static readonly RotationType OutsideTurn =
|
||||
ContourCuttingStrategy.LeadTurn(ContourType.External, RotationType.CW);
|
||||
private static readonly RotationType HoleTurn =
|
||||
ContourCuttingStrategy.LeadTurn(ContourType.Internal, RotationType.CCW);
|
||||
|
||||
private static readonly Color MaterialColor = Color.FromArgb(232, 236, 240);
|
||||
private static readonly Color ContourColor = Color.FromArgb(70, 70, 70);
|
||||
private static readonly Color LeadColor = Color.OrangeRed;
|
||||
private static readonly Color FaintLeadColor = Color.FromArgb(110, Color.OrangeRed);
|
||||
|
||||
private CuttingParameters parameters;
|
||||
|
||||
public LeadPreview()
|
||||
{
|
||||
SetStyle(
|
||||
ControlStyles.AllPaintingInWmPaint
|
||||
| ControlStyles.OptimizedDoubleBuffer
|
||||
| ControlStyles.ResizeRedraw
|
||||
| ControlStyles.UserPaint,
|
||||
true
|
||||
);
|
||||
BackColor = Color.White;
|
||||
}
|
||||
|
||||
public ContourType Emphasis { get; set; } = ContourType.External;
|
||||
|
||||
public CuttingParameters Parameters
|
||||
{
|
||||
get => parameters;
|
||||
set
|
||||
{
|
||||
parameters = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
if (parameters == null)
|
||||
return;
|
||||
|
||||
var leadIns = new[] { parameters.ExternalLeadIn, parameters.InternalLeadIn };
|
||||
var leadOuts = new[] { parameters.ExternalLeadOut, parameters.InternalLeadOut };
|
||||
var outsideReach = Reach(leadIns[0], leadOuts[0], OutsideTurn);
|
||||
var holeReach = Reach(leadIns[1], leadOuts[1], HoleTurn);
|
||||
var unit = System.Math.Max(System.Math.Max(outsideReach, holeReach), 0.1);
|
||||
|
||||
// The hole holds its own leads with room to spare; the part holds the hole.
|
||||
var holeRadius = System.Math.Max(holeReach * 1.4, unit * 0.6);
|
||||
var height = 2 * holeRadius + 2 * unit;
|
||||
var width = height * 1.6;
|
||||
var holeCenter = new Vector(width * 0.4, height / 2);
|
||||
|
||||
var outsidePoint = new Vector(width, height / 2);
|
||||
var holePoint = new Vector(holeCenter.X + holeRadius, holeCenter.Y);
|
||||
|
||||
using var material = new GraphicsPath(FillMode.Alternate);
|
||||
material.AddRectangle(new RectangleF(0, 0, (float)width, (float)height));
|
||||
material.AddEllipse(
|
||||
(float)(holeCenter.X - holeRadius),
|
||||
(float)(holeCenter.Y - holeRadius),
|
||||
(float)(2 * holeRadius),
|
||||
(float)(2 * holeRadius)
|
||||
);
|
||||
|
||||
using var outsideLeads = LeadPath(leadIns[0], leadOuts[0], outsidePoint, 0, OutsideTurn);
|
||||
using var holeLeads = LeadPath(leadIns[1], leadOuts[1], holePoint, System.Math.PI, HoleTurn);
|
||||
var outsidePierce = Pierce(leadIns[0], outsidePoint, 0, OutsideTurn);
|
||||
var holePierce = Pierce(leadIns[1], holePoint, System.Math.PI, HoleTurn);
|
||||
|
||||
var bounds = material.GetBounds();
|
||||
bounds = Union(bounds, outsideLeads.GetBounds(), holeLeads.GetBounds());
|
||||
bounds = Union(bounds, new RectangleF(outsidePierce, SizeF.Empty), new RectangleF(holePierce, SizeF.Empty));
|
||||
|
||||
var view = new RectangleF(Inset, Inset, Width - 2 * Inset, Height - 2 * Inset);
|
||||
if (view.Width <= 0 || view.Height <= 0 || bounds.Width <= 0 || bounds.Height <= 0)
|
||||
return;
|
||||
|
||||
// Fit and center, flipping Y so the preview reads like the plate view.
|
||||
var scale = System.Math.Min(view.Width / bounds.Width, view.Height / bounds.Height);
|
||||
using var transform = new Matrix();
|
||||
transform.Translate(
|
||||
view.Left + (view.Width - bounds.Width * scale) / 2,
|
||||
view.Top + (view.Height + bounds.Height * scale) / 2
|
||||
);
|
||||
transform.Scale(scale, -scale);
|
||||
transform.Translate(-bounds.Left, -bounds.Top);
|
||||
|
||||
material.Transform(transform);
|
||||
outsideLeads.Transform(transform);
|
||||
holeLeads.Transform(transform);
|
||||
var pierces = new[] { outsidePierce, holePierce };
|
||||
transform.TransformPoints(pierces);
|
||||
|
||||
var g = e.Graphics;
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
|
||||
using (var fill = new SolidBrush(MaterialColor))
|
||||
g.FillPath(fill, material);
|
||||
using (var outline = new Pen(ContourColor, 1.5f))
|
||||
g.DrawPath(outline, material);
|
||||
|
||||
var outsideStrong = Emphasis == ContourType.External;
|
||||
DrawLeads(g, outsideLeads, pierces[0], outsideStrong);
|
||||
DrawLeads(g, holeLeads, pierces[1], !outsideStrong);
|
||||
}
|
||||
|
||||
private static void DrawLeads(Graphics g, GraphicsPath leads, PointF pierce, bool strong)
|
||||
{
|
||||
var color = strong ? LeadColor : FaintLeadColor;
|
||||
using var pen = new Pen(color, strong ? 2f : 1.25f);
|
||||
g.DrawPath(pen, leads);
|
||||
|
||||
var size = strong ? 6f : 4f;
|
||||
using var brush = new SolidBrush(color);
|
||||
g.FillEllipse(brush, pierce.X - size / 2, pierce.Y - size / 2, size, size);
|
||||
}
|
||||
|
||||
/// <summary>How far a contour's leads reach from their contour point.</summary>
|
||||
private static double Reach(LeadIn leadIn, LeadOut leadOut, RotationType turn)
|
||||
{
|
||||
using var path = LeadPath(leadIn, leadOut, Vector.Zero, 0, turn);
|
||||
var bounds = Union(path.GetBounds(), new RectangleF(Pierce(leadIn, Vector.Zero, 0, turn), SizeF.Empty));
|
||||
return System.Math.Max(
|
||||
System.Math.Max(System.Math.Abs(bounds.Left), System.Math.Abs(bounds.Right)),
|
||||
System.Math.Max(System.Math.Abs(bounds.Top), System.Math.Abs(bounds.Bottom))
|
||||
);
|
||||
}
|
||||
|
||||
private static GraphicsPath LeadPath(LeadIn leadIn, LeadOut leadOut, Vector point, double normal,
|
||||
RotationType turn)
|
||||
{
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new RapidMove(point));
|
||||
if (leadIn != null)
|
||||
program.Codes.AddRange(leadIn.Generate(point, normal, turn));
|
||||
// The lead-out leaves from where the cut ends: back at the lead-in's contour point.
|
||||
program.Codes.Add(new RapidMove(point));
|
||||
if (leadOut != null)
|
||||
program.Codes.AddRange(leadOut.Generate(point, normal, turn));
|
||||
|
||||
program.GetGraphicsPaths(Vector.Zero, out var cutPath, out var leadPath);
|
||||
cutPath.Dispose();
|
||||
return leadPath;
|
||||
}
|
||||
|
||||
private static PointF Pierce(LeadIn leadIn, Vector point, double normal, RotationType turn)
|
||||
{
|
||||
var pierce = leadIn?.GetPiercePoint(point, normal, turn) ?? point;
|
||||
return new PointF((float)pierce.X, (float)pierce.Y);
|
||||
}
|
||||
|
||||
private static RectangleF Union(RectangleF bounds, params RectangleF[] others)
|
||||
{
|
||||
foreach (var other in others)
|
||||
{
|
||||
// An empty lead path reports an empty rectangle at the origin; skip it.
|
||||
if (other.Width <= 0 && other.Height <= 0 && other.Location == PointF.Empty)
|
||||
continue;
|
||||
bounds = RectangleF.FromLTRB(
|
||||
System.Math.Min(bounds.Left, other.Left),
|
||||
System.Math.Min(bounds.Top, other.Top),
|
||||
System.Math.Max(bounds.Right, other.Right),
|
||||
System.Math.Max(bounds.Bottom, other.Bottom)
|
||||
);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,9 +281,11 @@ namespace OpenNest.Controls
|
||||
{
|
||||
DrawCutOffLines(g);
|
||||
|
||||
// Over the lines, on a disc of the plate's color, so the cut does not strike them through.
|
||||
// Over the lines, on a disc of the line's color so the cut does not strike them through.
|
||||
// A disc of the plate's color read as a notch in any part edge the cut runs along.
|
||||
using var disc = new SolidBrush(CutOffLineColor);
|
||||
foreach (var (part, id, muted) in cutOffLabels)
|
||||
part.DrawLabel(g, id, muted, view.ColorScheme.LayoutFillBrush);
|
||||
part.DrawLabel(g, id, muted ? Brushes.Gray : disc, Brushes.White);
|
||||
cutOffLabels.Clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -549,6 +549,16 @@ namespace OpenNest.Forms
|
||||
double thicknessIn, CancellationToken cancellationToken) =>
|
||||
MaterialLedgerStore.CreateClient().GetStockAsync(material, grade, thicknessIn, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// The nest server id of the nest being nested, whose own codes stay offered; empty for
|
||||
/// a nest not saved to the server.
|
||||
/// </summary>
|
||||
public Guid ServerNestId { get; set; }
|
||||
|
||||
/// <summary>Reads the codes on other open server nests; the form's tests replace it.</summary>
|
||||
internal virtual Task<LedgerCodesInUse> FetchCodesInUseAsync(Guid excludeNestId, CancellationToken cancellationToken) =>
|
||||
LedgerCodesOnOtherNests.ReadAsync(excludeNestId, cancellationToken);
|
||||
|
||||
private string MaterialName => nest?.Material?.Name?.Trim() ?? "";
|
||||
|
||||
internal void UpdateLedgerButton()
|
||||
@@ -577,11 +587,14 @@ namespace OpenNest.Forms
|
||||
ledgerSummaryLabel.Text = "Loading stock from MaterialLedger...";
|
||||
try
|
||||
{
|
||||
// Codes on other open nests are not held in ML yet, so they are left out here.
|
||||
var inUseTask = FetchCodesInUseAsync(ServerNestId, token);
|
||||
var result = await FetchLedgerStockAsync(MaterialName, nest.Material?.Grade?.Trim() ?? "",
|
||||
LedgerStockMapper.ThicknessInches(nest.Thickness, nest.Units), token);
|
||||
var inUse = await inUseTask;
|
||||
if (token.IsCancellationRequested || IsDisposed)
|
||||
return;
|
||||
ApplyLedgerStock(result);
|
||||
ApplyLedgerStock(result, inUse);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
@@ -600,17 +613,24 @@ namespace OpenNest.Forms
|
||||
/// <summary>
|
||||
/// Replaces the ledger rows with <paramref name="result"/>'s stock (an unmapped material
|
||||
/// clears them; a failed request keeps them) and reports the outcome under the grid.
|
||||
/// Codes in <paramref name="inUse"/> are left out; when it is unknown the stock loads
|
||||
/// whole and the summary says other nests were not checked.
|
||||
/// </summary>
|
||||
internal void ApplyLedgerStock(LedgerStockResult result)
|
||||
internal void ApplyLedgerStock(LedgerStockResult result, LedgerCodesInUse inUse = null)
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case LedgerStockResult.Ok ok:
|
||||
var mapping = LedgerStockMapper.Map(ok.Items, nest.Units, PartEnvelopes(), ok.Specs);
|
||||
if (inUse is { IsKnown: true })
|
||||
mapping = LedgerCodeFilter.Exclude(mapping, inUse.Codes);
|
||||
ReplaceLedgerRows(mapping.Rows);
|
||||
ledgerSummary = mapping.Rows.Count == 0 && mapping.SkippedUnknownShape + mapping.SkippedTooSmall == 0
|
||||
ledgerSummary = mapping.Rows.Count == 0
|
||||
&& mapping.SkippedUnknownShape + mapping.SkippedTooSmall + mapping.OnOtherNests == 0
|
||||
? $"Ledger: no free stock of {DescribeMaterial()}."
|
||||
: mapping.Summary;
|
||||
if (inUse is { IsKnown: false })
|
||||
ledgerSummary += $" (other nests not checked: {inUse.Unavailable})";
|
||||
break;
|
||||
case LedgerStockResult.Unmapped:
|
||||
ReplaceLedgerRows(Array.Empty<LedgerStockRow>());
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace OpenNest.Forms.Commands
|
||||
/// </summary>
|
||||
public static AutoNestCommand Prompt(IWin32Window owner, EditNestForm target)
|
||||
{
|
||||
using var form = new AutoNestForm(target.Nest);
|
||||
using var form = new AutoNestForm(target.Nest) { ServerNestId = target.Document.RemoteId };
|
||||
if (target.Nest.PlateOptions.Count > 0)
|
||||
form.LoadPlateOptions(target.Nest.PlateOptions, target.Nest.SalvageRate);
|
||||
if (target.Nest.MinimumRemnantDimension > 0)
|
||||
|
||||
@@ -21,11 +21,17 @@ namespace OpenNest.Forms.Commands
|
||||
|
||||
private readonly Form owner;
|
||||
private readonly Action<string> showStatus;
|
||||
private readonly LedgerCodeRecheck ledgerRecheck;
|
||||
|
||||
public NestStorageController(Form owner, Action<string> showStatus)
|
||||
{
|
||||
this.owner = owner;
|
||||
this.showStatus = showStatus;
|
||||
ledgerRecheck = new LedgerCodeRecheck(text =>
|
||||
{
|
||||
if (!owner.IsDisposed)
|
||||
showStatus(text);
|
||||
});
|
||||
}
|
||||
|
||||
public event EventHandler StateChanged;
|
||||
@@ -112,6 +118,9 @@ namespace OpenNest.Forms.Commands
|
||||
: $"Found saved version {reconciled.Revision}. Review the current nest before saving again.");
|
||||
return; // Never combine reconciliation with a write in the same click.
|
||||
}
|
||||
// Item codes another nest took since Auto Nest are asked about on every save.
|
||||
if (!await ledgerRecheck.ConfirmAsync(owner, form, saveCopy ? "Save As" : "Save", asCopy: saveCopy))
|
||||
return;
|
||||
if (copyName != null)
|
||||
form.Nest.Name = copyName;
|
||||
else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl)
|
||||
|
||||
@@ -25,8 +25,8 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
private readonly PlateView view;
|
||||
private readonly Plate plate;
|
||||
private CuttingParameters parameters;
|
||||
private readonly Plate[] plates;
|
||||
private readonly int[] plateNumbers;
|
||||
private Plate[] plates;
|
||||
private int[] plateNumbers;
|
||||
private int selectedPlate;
|
||||
private Plate preview;
|
||||
private bool populatingWarnings;
|
||||
@@ -36,6 +36,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
private readonly OverlapDisplayMode overlapDisplay;
|
||||
private CuttingProposalInspection inspection = new();
|
||||
private readonly ComboBox modeSelector = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
|
||||
private readonly ComboBox scopeSelector = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
|
||||
private CancellationTokenSource validationCancellation;
|
||||
private bool closeRequested;
|
||||
private CuttingInspectionMode? pendingMode;
|
||||
@@ -43,6 +44,8 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
private bool pendingReplan;
|
||||
private bool pendingClear;
|
||||
private bool pendingCutOffSettings;
|
||||
// A scope chosen while a plan ran; applied once that run's cancellation lands.
|
||||
private bool? pendingScope;
|
||||
// The running plan's headline, re-shown each second with its elapsed time; null when not ticking.
|
||||
private string planningStatus;
|
||||
private readonly System.Diagnostics.Stopwatch planningClock = new();
|
||||
@@ -94,7 +97,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
};
|
||||
private readonly Button cutOffSettingsButton = new() { Text = "Cut-off Settings…", AutoSize = true };
|
||||
private readonly Units units;
|
||||
private readonly bool allPlates;
|
||||
private bool allPlates;
|
||||
private AutomaticCutOffOptions cutOffOptions;
|
||||
// Last cut-off settings chosen here, per unit system, for the rest of this session.
|
||||
private static readonly System.Collections.Generic.Dictionary<Units, AutomaticCutOffOptions> lastCutOffOptions = new();
|
||||
@@ -155,12 +158,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
this.editor = editor;
|
||||
view = editor.PlateView;
|
||||
plate = view.Plate;
|
||||
var all = editor.Nest.Plates.ToList();
|
||||
plates = allPlates ? all.Where(p => p.Parts.Count > 0).ToArray() : [plate];
|
||||
if (plates.Length == 0)
|
||||
plates = [plate];
|
||||
plateNumbers = plates.Select(p => all.IndexOf(p) + 1).ToArray();
|
||||
selectedPlate = System.Math.Max(0, Array.IndexOf(plates, plate));
|
||||
SetPlates(allPlates);
|
||||
this.parameters = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
|
||||
context = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
|
||||
viewport = view.CaptureViewport();
|
||||
@@ -182,7 +180,11 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
{
|
||||
if (!configuringMode) SwitchMode((CuttingInspectionMode)modeSelector.SelectedIndex);
|
||||
};
|
||||
this.allPlates = allPlates;
|
||||
toolTip.SetToolTip(scopeSelector, "Plan the plate on screen, or every plate with parts in one plan.");
|
||||
scopeSelector.SelectedIndexChanged += (_, _) =>
|
||||
{
|
||||
if (!configuringMode) ChangeScope(scopeSelector.SelectedIndex == 1);
|
||||
};
|
||||
units = editor.Nest.Units;
|
||||
cutOffOptions = lastCutOffOptions.TryGetValue(units, out var last) ? Copy(last) : DefaultCutOffOptions(units);
|
||||
ShowCutOffOptions();
|
||||
@@ -197,7 +199,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
existingWorkLabel.Font = new Font(Font, FontStyle.Bold);
|
||||
existingWorkBanner.Controls.AddRange([existingWorkLabel, clearExistingButton]);
|
||||
existingWorkBanner.SetTone(Tone.Bad);
|
||||
header.Controls.AddRange([modeSelector, existingWorkBanner, settingsRow, keepOrderCheckBox, cutOffRow,
|
||||
header.Controls.AddRange([modeSelector, scopeSelector, existingWorkBanner, settingsRow, keepOrderCheckBox, cutOffRow,
|
||||
validationScopeLabel, buttons, acceptWarningsCheckBox, navigation, previewLabel, detailLabel]);
|
||||
statusBanner.Controls.Add(statusLabel);
|
||||
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 };
|
||||
@@ -415,10 +417,11 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
|
||||
private void ConfigureMode()
|
||||
{
|
||||
var planning = Mode == CuttingInspectionMode.Plan;
|
||||
configuringMode = true;
|
||||
modeSelector.SelectedIndex = (int)Mode;
|
||||
ShowScope(planning);
|
||||
configuringMode = false;
|
||||
var planning = Mode == CuttingInspectionMode.Plan;
|
||||
settingsButton.Visible = settingsLabel.Visible = keepOrderCheckBox.Visible = applyButton.Visible = planning;
|
||||
autoCutOffsCheckBox.Visible = cutOffSettingsButton.Visible = planning;
|
||||
RefreshExistingWork();
|
||||
@@ -429,6 +432,89 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
previewLabel.Text = "No current preview.";
|
||||
}
|
||||
|
||||
/// <summary>True when this panel plans every plate with parts rather than only the plate on screen.</summary>
|
||||
internal bool AllPlates => allPlates;
|
||||
|
||||
/// <summary>Saves the scope chosen in the panel for the next toolbar open; tests replace it.</summary>
|
||||
internal Action<bool> RememberScope { get; set; } = all =>
|
||||
{
|
||||
Properties.Settings.Default.CuttingPlanAllPlates = all;
|
||||
Properties.Settings.Default.Save();
|
||||
};
|
||||
|
||||
/// <summary>Scopes the panel to the plate on screen or to every plate with parts (that plate when none has).</summary>
|
||||
private void SetPlates(bool all)
|
||||
{
|
||||
allPlates = all;
|
||||
var nestPlates = editor.Nest.Plates.ToList();
|
||||
var current = nestPlates.Contains(view.Plate) ? view.Plate : plate;
|
||||
plates = ScopePlates(nestPlates, current, all);
|
||||
plateNumbers = plates.Select(p => nestPlates.IndexOf(p) + 1).ToArray();
|
||||
selectedPlate = System.Math.Max(0, Array.IndexOf(plates, current));
|
||||
}
|
||||
|
||||
private static Plate[] ScopePlates(System.Collections.Generic.List<Plate> nestPlates, Plate current, bool all)
|
||||
{
|
||||
var scoped = all ? nestPlates.Where(p => p.Parts.Count > 0).ToArray() : [current];
|
||||
return scoped.Length == 0 ? [current] : scoped;
|
||||
}
|
||||
|
||||
// Shown only when the two scopes would plan different plates.
|
||||
private void ShowScope(bool planning)
|
||||
{
|
||||
var nestPlates = editor.Nest.Plates.ToList();
|
||||
var current = nestPlates.Contains(view.Plate) ? view.Plate : plate;
|
||||
var everyPlate = ScopePlates(nestPlates, current, true);
|
||||
scopeSelector.Items.Clear();
|
||||
scopeSelector.Items.AddRange(["Current plate only", $"All plates ({everyPlate.Length})"]);
|
||||
scopeSelector.SelectedIndex = allPlates ? 1 : 0;
|
||||
scopeSelector.Visible = planning && (allPlates || everyPlate.Length > 1 || !ReferenceEquals(everyPlate[0], current));
|
||||
}
|
||||
|
||||
/// <summary>Replans for the chosen scope; a running plan is cancelled first so the plates never change under it.</summary>
|
||||
internal void ChangeScope(bool all)
|
||||
{
|
||||
if (closed || closeRequested || pendingMode != null || Mode != CuttingInspectionMode.Plan
|
||||
|| all == (pendingScope ?? allPlates))
|
||||
return;
|
||||
RememberScope?.Invoke(all);
|
||||
if (IsPlanning)
|
||||
{
|
||||
pendingScope = all;
|
||||
if (inspection.CloseRequested)
|
||||
return; // Already finishing; the scope is applied when that lands.
|
||||
pendingReplan = true;
|
||||
ClearResult();
|
||||
SetPlanning(true);
|
||||
SetStatus(Tone.Neutral, "Restarting…");
|
||||
inspection.RequestClose();
|
||||
return;
|
||||
}
|
||||
if (ApplyScope(all))
|
||||
StartPlanning();
|
||||
}
|
||||
|
||||
// Takes the unapplied cut-offs off the old scope's plates before rescoping; false when they couldn't come off.
|
||||
private bool ApplyScope(bool all)
|
||||
{
|
||||
ClearResult();
|
||||
try
|
||||
{
|
||||
RevertStagedCutOffs();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(Tone.Bad, "Couldn't remove the unapplied cut-offs.", ex.Message);
|
||||
configuringMode = true;
|
||||
ShowScope(true); // The plates stay in the old scope, so the selector does too.
|
||||
configuringMode = false;
|
||||
return false;
|
||||
}
|
||||
SetPlates(all);
|
||||
ConfigureMode();
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void SwitchMode(CuttingInspectionMode mode)
|
||||
{
|
||||
if (closed || closeRequested || mode == Mode && pendingMode == null) return;
|
||||
@@ -704,6 +790,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
if (closeRequested || pendingMode != null)
|
||||
{
|
||||
pendingReplan = pendingClear = pendingCutOffSettings = false;
|
||||
pendingScope = null;
|
||||
FinishTransition();
|
||||
return;
|
||||
}
|
||||
@@ -717,6 +804,17 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
pendingClear = false; // The cancelled run no longer reads the plates.
|
||||
if (!ClearAppliedWork())
|
||||
{
|
||||
pendingScope = null;
|
||||
SetPlanning(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (pendingScope is { } scoped)
|
||||
{
|
||||
pendingScope = null; // The clear above, if any, covered the scope it was confirmed for.
|
||||
if (!ApplyScope(scoped))
|
||||
{
|
||||
pendingCutOffSettings = false;
|
||||
SetPlanning(false);
|
||||
return;
|
||||
}
|
||||
@@ -875,7 +973,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
// unless closing or switching; validation still locks them while it runs.
|
||||
var replannable = !running || Mode == CuttingInspectionMode.Plan && !closeRequested && pendingMode == null;
|
||||
settingsButton.Enabled = keepOrderCheckBox.Enabled = planButton.Enabled = replannable;
|
||||
cutOffSettingsButton.Enabled = clearExistingButton.Enabled = replannable;
|
||||
cutOffSettingsButton.Enabled = clearExistingButton.Enabled = scopeSelector.Enabled = replannable;
|
||||
previousButton.Enabled = !running && selectedPlate > 0;
|
||||
nextButton.Enabled = !running && selectedPlate < plates.Length - 1;
|
||||
acceptWarningsCheckBox.Enabled = !running;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data.MaterialLedger;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Lists the nest's item codes that are no longer free for it and asks what to do on this
|
||||
/// save or post: go on after acknowledging, remove the codes, or cancel. Like pre-post
|
||||
/// verification, going on needs the acknowledgement each time; nothing remembers it.
|
||||
/// </summary>
|
||||
public sealed class LedgerCodeConflictForm : Form
|
||||
{
|
||||
private readonly CheckBox acknowledgeBox;
|
||||
private readonly Button proceedButton;
|
||||
private readonly Button removeButton;
|
||||
|
||||
public LedgerCodeConflictForm(string action, IReadOnlyList<LedgerCodeConflict> conflicts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(conflicts);
|
||||
Text = "Ledger Stock Changed";
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
ClientSize = new Size(560, 340);
|
||||
MinimumSize = new Size(440, 300);
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
FormBorderStyle = FormBorderStyle.Sizable;
|
||||
|
||||
var codes = conflicts.Select(c => c.ItemCode).Distinct(StringComparer.Ordinal).Count();
|
||||
var headline = new Label
|
||||
{
|
||||
Text = (codes == 1 ? "1 item code on this nest is" : $"{codes} item codes on this nest are")
|
||||
+ " no longer free for it. Another job may cut the same sheet.\n"
|
||||
+ "Removing the codes keeps the layout; MaterialLedger then matches those sheets by size.",
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(536, 0),
|
||||
Padding = new Padding(0, 0, 0, 8),
|
||||
};
|
||||
|
||||
var list = new ListView
|
||||
{
|
||||
Name = "conflictList",
|
||||
Dock = DockStyle.Fill,
|
||||
View = View.Details,
|
||||
FullRowSelect = true,
|
||||
HeaderStyle = ColumnHeaderStyle.Nonclickable,
|
||||
};
|
||||
list.Columns.Add("Plate", 50);
|
||||
list.Columns.Add("Item code", 120);
|
||||
list.Columns.Add("Why", 340);
|
||||
foreach (var conflict in conflicts)
|
||||
list.Items.Add(new ListViewItem(new[] { conflict.PlateNumber.ToString(), conflict.ItemCode, conflict.Reason }));
|
||||
|
||||
acknowledgeBox = new CheckBox
|
||||
{
|
||||
Name = "acknowledgeBox",
|
||||
Text = $"{action} with these codes although the sheets may be taken",
|
||||
Dock = DockStyle.Bottom,
|
||||
AutoSize = true,
|
||||
Padding = new Padding(0, 6, 0, 0),
|
||||
};
|
||||
acknowledgeBox.CheckedChanged += (_, _) => proceedButton.Enabled = acknowledgeBox.Checked;
|
||||
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
AutoSize = true,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
Padding = new Padding(0, 6, 0, 0),
|
||||
};
|
||||
var cancel = new Button { Name = "cancelButton", Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||||
proceedButton = new Button { Name = "proceedButton", Text = $"{action} Anyway", AutoSize = true, Enabled = false };
|
||||
removeButton = new Button { Name = "removeButton", Text = $"Remove Codes and {action}", AutoSize = true };
|
||||
proceedButton.Click += (_, _) => Finish(acknowledgeBox.Checked ? LedgerCodeRecheck.Choice.Proceed : LedgerCodeRecheck.Choice.Cancel);
|
||||
removeButton.Click += (_, _) => Finish(LedgerCodeRecheck.Choice.RemoveCodes);
|
||||
buttons.Controls.Add(cancel);
|
||||
buttons.Controls.Add(proceedButton);
|
||||
buttons.Controls.Add(removeButton);
|
||||
|
||||
var body = new Panel { Dock = DockStyle.Fill, Padding = new Padding(12) };
|
||||
body.Controls.Add(list);
|
||||
body.Controls.Add(headline);
|
||||
body.Controls.Add(acknowledgeBox);
|
||||
body.Controls.Add(buttons);
|
||||
Controls.Add(body);
|
||||
|
||||
AcceptButton = removeButton;
|
||||
CancelButton = cancel;
|
||||
}
|
||||
|
||||
/// <summary>What the programmer chose; Cancel until a button is clicked.</summary>
|
||||
internal LedgerCodeRecheck.Choice Choice { get; private set; } = LedgerCodeRecheck.Choice.Cancel;
|
||||
|
||||
private void Finish(LedgerCodeRecheck.Choice choice)
|
||||
{
|
||||
// Check again here; an enabled button alone is not consent enforcement.
|
||||
if (choice == LedgerCodeRecheck.Choice.Cancel)
|
||||
return;
|
||||
Choice = choice;
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data.MaterialLedger;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// Before a nest with ledger item codes is saved to the nest server or posted, rereads the
|
||||
/// ledger's free stock and the codes on other open nests and asks about any code that is no
|
||||
/// longer this nest's to use. The answer counts for this attempt only; nothing is persisted.
|
||||
/// A ledger that is not set up or cannot be read skips the check: it never blocks a save.
|
||||
/// </summary>
|
||||
internal sealed class LedgerCodeRecheck
|
||||
{
|
||||
public enum Choice
|
||||
{
|
||||
Cancel,
|
||||
Proceed,
|
||||
RemoveCodes,
|
||||
}
|
||||
|
||||
public const string RemoveLabel = "Remove Ledger Codes";
|
||||
|
||||
private readonly Func<bool> ledgerConfigured;
|
||||
private readonly Func<string, string, double, CancellationToken, Task<LedgerStockResult>> fetchStock;
|
||||
private readonly Func<Guid, CancellationToken, Task<LedgerHoldsResult>> fetchHolds;
|
||||
private readonly Func<Guid, CancellationToken, Task<LedgerCodesInUse>> fetchCodesInUse;
|
||||
private readonly Func<IWin32Window, string, IReadOnlyList<LedgerCodeConflict>, Choice> ask;
|
||||
private readonly Action<string> showStatus;
|
||||
|
||||
public LedgerCodeRecheck(Action<string> showStatus)
|
||||
: this(() => MaterialLedgerStore.Settings.IsConfigured,
|
||||
(material, grade, thickness, token) => MaterialLedgerStore.CreateClient().GetStockAsync(material, grade, thickness, token),
|
||||
(id, token) => MaterialLedgerStore.CreateClient().GetHoldsAsync(id, token),
|
||||
LedgerCodesOnOtherNests.ReadAsync,
|
||||
Ask,
|
||||
showStatus)
|
||||
{
|
||||
}
|
||||
|
||||
internal LedgerCodeRecheck(Func<bool> ledgerConfigured,
|
||||
Func<string, string, double, CancellationToken, Task<LedgerStockResult>> fetchStock,
|
||||
Func<Guid, CancellationToken, Task<LedgerHoldsResult>> fetchHolds,
|
||||
Func<Guid, CancellationToken, Task<LedgerCodesInUse>> fetchCodesInUse,
|
||||
Func<IWin32Window, string, IReadOnlyList<LedgerCodeConflict>, Choice> ask,
|
||||
Action<string> showStatus)
|
||||
{
|
||||
this.ledgerConfigured = ledgerConfigured;
|
||||
this.fetchStock = fetchStock;
|
||||
this.fetchHolds = fetchHolds;
|
||||
this.fetchCodesInUse = fetchCodesInUse;
|
||||
this.ask = ask;
|
||||
this.showStatus = showStatus ?? (_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="action"/> ("Save" or "Post") may go on: no codes, nothing
|
||||
/// to check, nothing taken, or the programmer chose to go on or to remove the codes.
|
||||
/// False when they cancelled or the window closed meanwhile. A Save As
|
||||
/// (<paramref name="asCopy"/>) is a new nest: the original's codes and holds count as
|
||||
/// another nest's.
|
||||
/// </summary>
|
||||
public async Task<bool> ConfirmAsync(IWin32Window owner, EditNestForm form, string action, bool asCopy = false)
|
||||
{
|
||||
var nest = form.Nest;
|
||||
if (!LedgerCodeCheck.HasCodes(nest.Plates) || !ledgerConfigured())
|
||||
return true;
|
||||
var material = nest.Material?.Name?.Trim() ?? "";
|
||||
if (material.Length == 0 || !(nest.Thickness > 0))
|
||||
return true;
|
||||
|
||||
var remoteId = asCopy ? Guid.Empty : form.Document.RemoteId;
|
||||
LedgerStockResult stock;
|
||||
LedgerHoldsResult holds;
|
||||
LedgerCodesInUse inUse;
|
||||
try
|
||||
{
|
||||
var stockTask = fetchStock(material, nest.Material.Grade?.Trim() ?? "",
|
||||
LedgerStockMapper.ThicknessInches(nest.Thickness, nest.Units), CancellationToken.None);
|
||||
var holdsTask = remoteId == Guid.Empty
|
||||
? Task.FromResult<LedgerHoldsResult>(new LedgerHoldsResult.Ok(Array.Empty<LedgerHold>()))
|
||||
: fetchHolds(remoteId, CancellationToken.None);
|
||||
var inUseTask = fetchCodesInUse(remoteId, CancellationToken.None);
|
||||
await Task.WhenAll(stockTask, holdsTask, inUseTask);
|
||||
(stock, holds, inUse) = (stockTask.Result, holdsTask.Result, inUseTask.Result);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
showStatus($"Ledger codes not re-checked: {ex.Message}");
|
||||
return !form.IsDisposed;
|
||||
}
|
||||
if (form.IsDisposed)
|
||||
return false;
|
||||
|
||||
// The nest's own holds tell its sheets from ones taken by other work; without them
|
||||
// every held sheet would look taken.
|
||||
if (stock is not LedgerStockResult.Ok ok || holds is not LedgerHoldsResult.Ok held)
|
||||
{
|
||||
if ((Unavailable(stock) ?? Unavailable(holds)) is { } reason)
|
||||
showStatus($"Ledger codes not re-checked: {reason}");
|
||||
return true;
|
||||
}
|
||||
|
||||
var conflicts = LedgerCodeCheck.Find(nest.Plates, ok.Items, held.Holds, inUse);
|
||||
if (conflicts.Count == 0)
|
||||
{
|
||||
if (!inUse.IsKnown)
|
||||
showStatus($"Ledger codes checked against MaterialLedger only; other nests not checked: {inUse.Unavailable}");
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (ask(owner, action, conflicts))
|
||||
{
|
||||
case Choice.Proceed:
|
||||
return !form.IsDisposed;
|
||||
case Choice.RemoveCodes when !form.IsDisposed:
|
||||
var codes = conflicts.Select(c => c.ItemCode).Distinct(StringComparer.Ordinal).ToList();
|
||||
var removed = 0;
|
||||
form.RecordChange(RemoveLabel, () => removed = LedgerCodeCheck.Remove(form.Nest.Plates, codes));
|
||||
form.PlateView.Invalidate();
|
||||
showStatus($"Removed {codes.Count} ledger item code{(codes.Count == 1 ? "" : "s")} from {removed} sheet{(removed == 1 ? "" : "s")}");
|
||||
return true;
|
||||
default:
|
||||
showStatus($"{action} cancelled");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Unavailable(LedgerStockResult result) => result switch
|
||||
{
|
||||
LedgerStockResult.Ok => null,
|
||||
LedgerStockResult.NotConfigured => null,
|
||||
LedgerStockResult.Unmapped => "MaterialLedger has no mapping for this material",
|
||||
LedgerStockResult.Unauthorized => "MaterialLedger rejected the API key",
|
||||
LedgerStockResult.Unreachable unreachable => $"MaterialLedger unreachable ({unreachable.Reason})",
|
||||
_ => "MaterialLedger did not answer",
|
||||
};
|
||||
|
||||
private static string Unavailable(LedgerHoldsResult result) => result switch
|
||||
{
|
||||
LedgerHoldsResult.Ok => null,
|
||||
LedgerHoldsResult.NotConfigured => null,
|
||||
LedgerHoldsResult.Unauthorized => "MaterialLedger rejected the API key",
|
||||
LedgerHoldsResult.Unreachable unreachable => $"MaterialLedger unreachable ({unreachable.Reason})",
|
||||
_ => "MaterialLedger did not answer",
|
||||
};
|
||||
|
||||
private static Choice Ask(IWin32Window owner, string action, IReadOnlyList<LedgerCodeConflict> conflicts)
|
||||
{
|
||||
// A fresh dialog on every attempt: consent never outlives this call.
|
||||
using var dialog = new LedgerCodeConflictForm(action, conflicts);
|
||||
dialog.ShowDialog(owner);
|
||||
return dialog.Choice;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the ledger codes on other open server nests, or why they could not be read.</summary>
|
||||
internal static class LedgerCodesOnOtherNests
|
||||
{
|
||||
/// <summary>
|
||||
/// The codes on every open server nest except <paramref name="excludeNestId"/>. File mode
|
||||
/// and an unusable server URL come back unknown, never as an exception.
|
||||
/// </summary>
|
||||
public static Task<LedgerCodesInUse> ReadAsync(Guid excludeNestId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!NestStorage.Settings.IsDatabaseMode)
|
||||
return LedgerCodesInUse.ReadAsync(null, excludeNestId, cancellationToken);
|
||||
Data.INestRepository repository;
|
||||
try
|
||||
{
|
||||
repository = NestStorage.CreateRepository();
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return Task.FromResult(LedgerCodesInUse.Unknown("the nest server address is not valid"));
|
||||
}
|
||||
return LedgerCodesInUse.ReadAsync(repository, excludeNestId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using System.Text.Json.Serialization;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Shapes;
|
||||
using OpenNest.Support;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
@@ -275,6 +276,7 @@ namespace OpenNest.Forms
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill };
|
||||
tabs.TabPages.Add(CreateTab("General", general));
|
||||
tabs.TabPages.Add(CreateTab("Clamps", BuildClampsTab(machine)));
|
||||
tabs.TabPages.Add(CreateTab("Support Bed", BuildSupportBedTab(machine)));
|
||||
|
||||
var host = new Panel { Dock = DockStyle.Fill, Height = 420 };
|
||||
host.Controls.Add(tabs);
|
||||
@@ -402,6 +404,111 @@ namespace OpenNest.Forms
|
||||
return panel;
|
||||
}
|
||||
|
||||
// The machine's support bed, which new nests assigned the machine receive. A machine without
|
||||
// one shows the legacy shared support-grid.json; the first edit stores the shown values.
|
||||
private Control BuildSupportBedTab(MachineConfig machine)
|
||||
{
|
||||
var shown = MachineSettings.LoadSupportGridSettings(machine)
|
||||
.ConvertedTo(MachineClamps.UnitsOf(machine.Units));
|
||||
string Suffix(object _) => shown.Units == Units.Millimeters ? "mm" : "in";
|
||||
|
||||
void Set(Action<SupportGridSettings> apply)
|
||||
{
|
||||
machine.SupportGrid ??= shown;
|
||||
apply(machine.SupportGrid);
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
var layout = CreateDetailLayout();
|
||||
var row = 0;
|
||||
var enabledBox = new CheckBox
|
||||
{
|
||||
Text = "Give new nests this support bed",
|
||||
AutoSize = true,
|
||||
Checked = shown.Enabled,
|
||||
};
|
||||
enabledBox.CheckedChanged += (s, e) => Set(g => g.Enabled = enabledBox.Checked);
|
||||
AddField(layout, ref row, "New nests:", enabledBox);
|
||||
|
||||
var kindCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = InputWidth };
|
||||
kindCombo.Items.AddRange(new object[] { "Grid of points", "Slats" });
|
||||
kindCombo.SelectedIndex = shown.UseSlats ? 1 : 0;
|
||||
AddField(layout, ref row, "Bed:", kindCombo);
|
||||
|
||||
var gridFields = new List<Control>
|
||||
{
|
||||
AddSupportField(layout, ref row, "Pitch along X:",
|
||||
CreateLabeledNumeric(shown.PitchX, v => Set(g => g.PitchX = v), 4, Suffix, 100000, 0.125m, 0.0001m)),
|
||||
AddSupportField(layout, ref row, "Pitch along Y:",
|
||||
CreateLabeledNumeric(shown.PitchY, v => Set(g => g.PitchY = v), 4, Suffix, 100000, 0.125m, 0.0001m)),
|
||||
AddSupportField(layout, ref row, "First point, X from the origin:",
|
||||
CreateLabeledNumeric(shown.OffsetX, v => Set(g => g.OffsetX = v), 4, Suffix, 100000, 0.125m, -100000m)),
|
||||
AddSupportField(layout, ref row, "First point, Y from the origin:",
|
||||
CreateLabeledNumeric(shown.OffsetY, v => Set(g => g.OffsetY = v), 4, Suffix, 100000, 0.125m, -100000m)),
|
||||
};
|
||||
|
||||
var axisCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = InputWidth };
|
||||
axisCombo.Items.AddRange(new object[] { "Along X (the full length)", "Along Y (the full width)" });
|
||||
axisCombo.SelectedIndex = shown.SlatAxis == SlatAxis.X ? 0 : 1;
|
||||
axisCombo.SelectedIndexChanged += (s, e) =>
|
||||
Set(g => g.SlatAxis = axisCombo.SelectedIndex == 0 ? SlatAxis.X : SlatAxis.Y);
|
||||
var slatFields = new List<Control>
|
||||
{
|
||||
AddSupportField(layout, ref row, "Slats run:", axisCombo),
|
||||
AddSupportField(layout, ref row, "Pitch between slats:",
|
||||
CreateLabeledNumeric(shown.SlatPitch, v => Set(g => g.SlatPitch = v), 4, Suffix, 100000, 0.125m, 0.0001m)),
|
||||
AddSupportField(layout, ref row, "Slat thickness:",
|
||||
CreateLabeledNumeric(shown.SlatThickness, v => Set(g => g.SlatThickness = v), 4, Suffix, 100000, 0.125m)),
|
||||
AddSupportField(layout, ref row, "First slat's center from the origin:",
|
||||
CreateLabeledNumeric(shown.SlatOffset, v => Set(g => g.SlatOffset = v), 4, Suffix, 100000, 0.125m, -100000m)),
|
||||
};
|
||||
|
||||
AddField(layout, ref row, "Registration tolerance:",
|
||||
CreateLabeledNumeric(shown.RegistrationTolerance, v => Set(g => g.RegistrationTolerance = v), 4, Suffix, 100000, 0.125m));
|
||||
AddField(layout, ref row, "Stability margin:",
|
||||
CreateLabeledNumeric(shown.StabilityMargin, v => Set(g => g.StabilityMargin = v), 4, Suffix, 100000, 0.125m));
|
||||
|
||||
void UpdateEnabled()
|
||||
{
|
||||
foreach (var field in gridFields)
|
||||
field.Enabled = kindCombo.SelectedIndex == 0;
|
||||
foreach (var field in slatFields)
|
||||
field.Enabled = kindCombo.SelectedIndex == 1;
|
||||
}
|
||||
|
||||
kindCombo.SelectedIndexChanged += (s, e) =>
|
||||
{
|
||||
Set(g => g.UseSlats = kindCombo.SelectedIndex == 1);
|
||||
UpdateEnabled();
|
||||
};
|
||||
UpdateEnabled();
|
||||
|
||||
var note = new Label
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(560, 0),
|
||||
ForeColor = SystemColors.GrayText,
|
||||
Padding = new Padding(4, 0, 0, 8),
|
||||
Text = "What cut parts rest on: a grid of contact points, one every pitch along X and Y, or "
|
||||
+ "slats running the full length or width of the table, one every pitch. New nests "
|
||||
+ "assigned this machine get this bed. Changing it here never changes the bed of "
|
||||
+ "existing nests; use Load Machine Defaults in Nest > Support Bed for that.",
|
||||
};
|
||||
|
||||
var panel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10), AutoScroll = true };
|
||||
panel.Controls.Add(layout);
|
||||
panel.Controls.Add(note);
|
||||
return panel;
|
||||
}
|
||||
|
||||
// Adds the row and returns its input, so the caller can enable it with the bed layout.
|
||||
private static Control AddSupportField(TableLayoutPanel layout, ref int row, string label, Control control)
|
||||
{
|
||||
AddField(layout, ref row, label, control);
|
||||
return control;
|
||||
}
|
||||
|
||||
private GroupBox BuildMotionGroup(MachineConfig machine)
|
||||
{
|
||||
var group = CreateGroup("Motion (cut time estimates)");
|
||||
@@ -893,16 +1000,17 @@ namespace OpenNest.Forms
|
||||
Action<double> setter,
|
||||
int decimals,
|
||||
decimal max,
|
||||
decimal increment
|
||||
decimal increment,
|
||||
decimal min = 0
|
||||
)
|
||||
{
|
||||
var numeric = new NumericUpDown
|
||||
{
|
||||
DecimalPlaces = decimals,
|
||||
Minimum = 0,
|
||||
Minimum = min,
|
||||
Maximum = max,
|
||||
Increment = increment,
|
||||
Value = System.Math.Clamp((decimal)value, 0, max),
|
||||
Value = System.Math.Clamp((decimal)value, min, max),
|
||||
Width = NumericWidth,
|
||||
};
|
||||
numeric.ValueChanged += (s, e) => setter((double)numeric.Value);
|
||||
@@ -915,7 +1023,8 @@ namespace OpenNest.Forms
|
||||
int decimals,
|
||||
Func<object, string> suffix,
|
||||
decimal max,
|
||||
decimal increment
|
||||
decimal increment,
|
||||
decimal min = 0
|
||||
)
|
||||
{
|
||||
var row = new TableLayoutPanel
|
||||
@@ -927,7 +1036,7 @@ namespace OpenNest.Forms
|
||||
row.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
row.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
|
||||
var numeric = CreateNumericBox(value, setter, decimals, max, increment);
|
||||
var numeric = CreateNumericBox(value, setter, decimals, max, increment, min);
|
||||
|
||||
var suffixLabel = new Label
|
||||
{
|
||||
|
||||
Generated
+3
@@ -21,7 +21,10 @@ namespace OpenNest.Forms
|
||||
components.Dispose();
|
||||
}
|
||||
if (disposing)
|
||||
{
|
||||
updaterStatus?.Dispose();
|
||||
statusStripToolTips?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
|
||||
@@ -306,15 +306,34 @@ namespace OpenNest.Forms
|
||||
editForm.OpenValidationInspection();
|
||||
}
|
||||
|
||||
private void PostProcessor_Click(object sender, EventArgs e)
|
||||
private bool checkingPostLedgerCodes;
|
||||
|
||||
private async void PostProcessor_Click(object sender, EventArgs e)
|
||||
{
|
||||
var menuItem = (ToolStripMenuItem)sender;
|
||||
var postProcessor = menuItem.Tag as IPostProcessor;
|
||||
|
||||
var editForm = activeForm;
|
||||
if (postProcessor == null || editForm == null)
|
||||
if (postProcessor == null || editForm == null || checkingPostLedgerCodes)
|
||||
return;
|
||||
|
||||
// Item codes another nest took since Auto Nest are asked about on every post.
|
||||
checkingPostLedgerCodes = true;
|
||||
try
|
||||
{
|
||||
var recheck = new LedgerCodeRecheck(text =>
|
||||
{
|
||||
if (!IsDisposed)
|
||||
statusLabel1.Text = text;
|
||||
});
|
||||
if (!await recheck.ConfirmAsync(this, editForm, "Post") || IsDisposed || editForm.IsDisposed)
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
checkingPostLedgerCodes = false;
|
||||
}
|
||||
|
||||
// Closed progress windows can still have a fill awaiting its final commit.
|
||||
// Keep the nest stable throughout verification and all owned modal dialogs.
|
||||
if (MdiChildren.OfType<EditNestForm>().Any(form => form.CuttingInspection != null))
|
||||
|
||||
@@ -111,6 +111,10 @@ namespace OpenNest.Forms
|
||||
|
||||
private void NestPlanCutting_Click(object sender, EventArgs e) => ShowCuttingPlan(allPlates: true);
|
||||
|
||||
// The toolbar opens with the scope last chosen in the panel; the menus keep their own.
|
||||
private void ToolbarPlanCutting_Click(object sender, EventArgs e) =>
|
||||
ShowCuttingPlan(Properties.Settings.Default.CuttingPlanAllPlates);
|
||||
|
||||
// Native tests capture this notice without opening a modal dialog in session zero.
|
||||
internal System.Action<string, string> CuttingPlanNotice { get; set; }
|
||||
|
||||
|
||||
@@ -109,13 +109,14 @@ namespace OpenNest.Forms
|
||||
storage = new NestStorageController(this, text => statusLabel1.Text = text);
|
||||
storage.StateChanged += Storage_StateChanged;
|
||||
InitializeComponent();
|
||||
Icon = Resources.app_icon;
|
||||
statusStrip1.Items.Insert(statusStrip1.Items.IndexOf(materialStatusLabel) + 1, ledgerStatusLabel);
|
||||
ledgerStatusLabel.Click += LedgerStatusLabel_Click;
|
||||
statusStrip1.ShowItemToolTips = true; // The hold list lives in the ledger label's tooltip.
|
||||
statusStripToolTips = new StatusStripToolTips(statusStrip1); // The hold list lives in the ledger label's tooltip.
|
||||
statusBar = new MainStatusBar(statusLabel1, locationStatusLabel, selectionStatusLabel,
|
||||
machineStatusLabel, materialStatusLabel, plateUtilStatusLabel, ledgerStatusLabel);
|
||||
toolStrip1.Items.Add(CreateActionButton("btnCutPlanner", "Plan Cutting",
|
||||
Resources.cut_planner, PlatePlanCutting_Click));
|
||||
Resources.cut_planner, ToolbarPlanCutting_Click));
|
||||
toolStrip1.Items.Add(CreateActionButton("btnSimulateCutting", "Simulate Cutting",
|
||||
Resources.cut_simulate, SimulateCutting_Click));
|
||||
toolStrip1.Items.Add(CreateActionButton("btnSequenceByPath", "Sequence by Path",
|
||||
@@ -167,6 +168,7 @@ namespace OpenNest.Forms
|
||||
}
|
||||
|
||||
private UpdaterStatus updaterStatus;
|
||||
private StatusStripToolTips statusStripToolTips;
|
||||
|
||||
private void InitializeHelpMenu()
|
||||
{
|
||||
@@ -174,8 +176,15 @@ namespace OpenNest.Forms
|
||||
contents.Click += (_, _) => HelpTopics.Show(this, HelpTopics.Home);
|
||||
var firstNest = new ToolStripMenuItem("&Your First Nest");
|
||||
firstNest.Click += (_, _) => HelpTopics.Show(this, HelpTopics.FirstNest);
|
||||
var checkForUpdates = new ToolStripMenuItem("Check for &Updates...");
|
||||
checkForUpdates.Click += (_, _) => updaterStatus?.CheckForUpdates();
|
||||
var about = new ToolStripMenuItem("&About OpenNest");
|
||||
about.Click += (_, _) => updaterStatus?.ShowAbout();
|
||||
var help = new ToolStripMenuItem("&Help");
|
||||
help.DropDownItems.AddRange(new ToolStripItem[] { contents, firstNest });
|
||||
help.DropDownItems.AddRange(new ToolStripItem[]
|
||||
{
|
||||
contents, firstNest, new ToolStripSeparator(), checkForUpdates, about,
|
||||
});
|
||||
menuStrip1.Items.Add(help);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ namespace OpenNest.Forms
|
||||
// Mouse-follow mode refreshes at most this often; each text change relayouts the strip.
|
||||
private const int LocationRefreshIntervalMs = 100;
|
||||
|
||||
// Once the mouse rests this long, the label shows its final point.
|
||||
private const int LocationSettleMs = 30;
|
||||
|
||||
private readonly ToolStripStatusLabel status;
|
||||
private readonly ToolStripStatusLabel location;
|
||||
private readonly ToolStripStatusLabel selection;
|
||||
@@ -31,11 +34,11 @@ namespace OpenNest.Forms
|
||||
private readonly ToolStripStatusLabel material;
|
||||
private readonly ToolStripStatusLabel plateUtil;
|
||||
private readonly ToolStripStatusLabel ledger;
|
||||
private readonly Timer locationTimer;
|
||||
private readonly Timer locationSettleTimer;
|
||||
|
||||
private EditNestForm form;
|
||||
private bool clickUpdatesLocation = !Settings.Default.StatusBarLocationFollowsMouse;
|
||||
private bool locationPending;
|
||||
private long lastLocationUpdate;
|
||||
|
||||
public MainStatusBar(ToolStripStatusLabel status, ToolStripStatusLabel location,
|
||||
ToolStripStatusLabel selection, ToolStripStatusLabel machine, ToolStripStatusLabel material,
|
||||
@@ -49,8 +52,8 @@ namespace OpenNest.Forms
|
||||
this.material = material;
|
||||
this.plateUtil = plateUtil;
|
||||
|
||||
locationTimer = new Timer { Interval = LocationRefreshIntervalMs };
|
||||
locationTimer.Tick += LocationTimer_Tick;
|
||||
locationSettleTimer = new Timer { Interval = LocationSettleMs };
|
||||
locationSettleTimer.Tick += LocationSettleTimer_Tick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -160,11 +163,13 @@ namespace OpenNest.Forms
|
||||
return;
|
||||
}
|
||||
|
||||
location.Text = string.Format(
|
||||
var text = string.Format(
|
||||
"Location: [{0}, {1}]",
|
||||
form.PlateView.CurrentPoint.X.ToString("n4"),
|
||||
form.PlateView.CurrentPoint.Y.ToString("n4")
|
||||
);
|
||||
if (location.Text != text)
|
||||
location.Text = text;
|
||||
}
|
||||
|
||||
public void UpdatePlate()
|
||||
@@ -269,39 +274,37 @@ namespace OpenNest.Forms
|
||||
private void PlateView_PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => UpdatePlate();
|
||||
|
||||
/// <summary>
|
||||
/// Throttles mouse-follow updates: the first move shows at once, later moves inside
|
||||
/// the interval collapse into one trailing update so the label settles on the last point.
|
||||
/// Throttles mouse-follow updates: moves refresh the label at most once per interval,
|
||||
/// and a settle timer restarted by each skipped move shows the last point once the
|
||||
/// mouse rests. Moves drive the throttle themselves because WM_TIMER is starved while
|
||||
/// input and repaints are queued.
|
||||
/// </summary>
|
||||
private void PlateView_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (locationTimer.Enabled)
|
||||
locationSettleTimer.Stop();
|
||||
if (Environment.TickCount64 - lastLocationUpdate >= LocationRefreshIntervalMs)
|
||||
{
|
||||
locationPending = true;
|
||||
UpdateThrottledLocation();
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateLocation();
|
||||
locationTimer.Start();
|
||||
locationSettleTimer.Start();
|
||||
}
|
||||
|
||||
private void LocationTimer_Tick(object sender, EventArgs e)
|
||||
private void LocationSettleTimer_Tick(object sender, EventArgs e)
|
||||
{
|
||||
if (!locationPending)
|
||||
{
|
||||
locationTimer.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
locationPending = false;
|
||||
UpdateLocation();
|
||||
locationSettleTimer.Stop();
|
||||
UpdateThrottledLocation();
|
||||
}
|
||||
|
||||
private void StopLocationThrottle()
|
||||
private void UpdateThrottledLocation()
|
||||
{
|
||||
locationTimer.Stop();
|
||||
locationPending = false;
|
||||
UpdateLocation();
|
||||
lastLocationUpdate = Environment.TickCount64;
|
||||
}
|
||||
|
||||
private void StopLocationThrottle() => locationSettleTimer.Stop();
|
||||
|
||||
private void PlateView_MouseClick(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button == MouseButtons.Left)
|
||||
|
||||
@@ -39,6 +39,15 @@ public sealed class NestHistoryForm : Form
|
||||
public bool Changed { get; private set; }
|
||||
public event EventHandler HistoryChanged;
|
||||
|
||||
/// <summary>Raised when the highlighted version changes on a loaded history page.</summary>
|
||||
public event EventHandler RevisionSelected;
|
||||
|
||||
/// <summary>The highlighted version when it is not the current one; otherwise null.</summary>
|
||||
public NestRecord SelectedHistoricalRevision =>
|
||||
session.Current != null && SelectedRevision is NestRecord record && record.Revision != session.Current.Revision
|
||||
? record
|
||||
: null;
|
||||
|
||||
public NestHistoryForm(INestRepository repository, string serverUrl, Guid id = default, bool embedded = false)
|
||||
{
|
||||
this.repository = repository;
|
||||
@@ -74,7 +83,12 @@ public sealed class NestHistoryForm : Form
|
||||
await ReadAsync(0);
|
||||
}
|
||||
};
|
||||
revisions.SelectionChanged += (_, _) => UpdateButtons();
|
||||
revisions.SelectionChanged += (_, _) =>
|
||||
{
|
||||
UpdateButtons();
|
||||
if (!populating && session.Current != null)
|
||||
RevisionSelected?.Invoke(this, EventArgs.Empty);
|
||||
};
|
||||
var refresh = new Button { Text = "Refresh", AutoSize = true };
|
||||
refresh.Click += async (_, _) => { if (recovery) await ReadDeletedAsync(); else await ReadAsync(0); };
|
||||
previous.Click += async (_, _) => await ReadAsync(System.Math.Max(0, session.Page.Offset - NestHistorySession.PageSize));
|
||||
@@ -191,19 +205,28 @@ public sealed class NestHistoryForm : Form
|
||||
try
|
||||
{
|
||||
if (!await task || IsDisposed || run != readGeneration) return;
|
||||
foreach (var record in session.Page.Items)
|
||||
populating = true;
|
||||
try
|
||||
{
|
||||
var row = revisions.Rows.Add(record.Revision,
|
||||
(record.Revision == session.Current.Revision ? "Current" : "Historical") + (record.Deleted ? " (deleted)" : ""),
|
||||
record.SavedAt.ToString("g"), record.Name, record.Status, record.Comments, record.RestoredFromRevision, record.FileSize);
|
||||
revisions.Rows[row].Tag = record;
|
||||
foreach (var record in session.Page.Items)
|
||||
{
|
||||
var row = revisions.Rows.Add(record.Revision,
|
||||
(record.Revision == session.Current.Revision ? "Current" : "Historical") + (record.Deleted ? " (deleted)" : ""),
|
||||
record.SavedAt.ToString("g"), record.Name, record.Status, record.Comments, record.RestoredFromRevision, record.FileSize);
|
||||
revisions.Rows[row].Tag = record;
|
||||
}
|
||||
}
|
||||
finally { populating = false; }
|
||||
RevisionSelected?.Invoke(this, EventArgs.Empty);
|
||||
status.Text = $"{serverUrl} — Nest {selectedId} — Current version {session.Current.Revision}; {session.Page.Total} versions. Displaying {offset + 1}–{offset + session.Page.Items.Count}.";
|
||||
}
|
||||
catch (Exception ex) { if (!IsDisposed && run == readGeneration) status.Text = ex.Message; }
|
||||
finally { if (!IsDisposed) UpdateButtons(); }
|
||||
}
|
||||
|
||||
/// <summary>Downloads one version on the displayed page; null when the page changed meanwhile.</summary>
|
||||
public Task<byte[]> DownloadRevisionAsync(int revision) => session.DownloadAsync(revision, serverUrl, lifetime.Token);
|
||||
|
||||
private async Task InspectAsync()
|
||||
{
|
||||
if (SelectedRevision is not NestRecord record || busy) return;
|
||||
|
||||
Generated
+16
-3
@@ -49,6 +49,7 @@
|
||||
this.updateGroupBox = new System.Windows.Forms.GroupBox();
|
||||
this.updateFeedUrlBox = new System.Windows.Forms.TextBox();
|
||||
this.setUpdateFeedButton = new System.Windows.Forms.Button();
|
||||
this.includeDevBuildsBox = new System.Windows.Forms.CheckBox();
|
||||
this.ledgerGroupBox = new System.Windows.Forms.GroupBox();
|
||||
this.ledgerUrlLabel = new System.Windows.Forms.Label();
|
||||
this.ledgerUrlBox = new System.Windows.Forms.TextBox();
|
||||
@@ -203,7 +204,7 @@
|
||||
// strategyGroupBox
|
||||
//
|
||||
this.strategyGroupBox.Controls.Add(this.strategyGrid);
|
||||
this.strategyGroupBox.Location = new System.Drawing.Point(12, 457);
|
||||
this.strategyGroupBox.Location = new System.Drawing.Point(12, 485);
|
||||
this.strategyGroupBox.Name = "strategyGroupBox";
|
||||
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
|
||||
this.strategyGroupBox.TabIndex = 2;
|
||||
@@ -262,9 +263,10 @@
|
||||
//
|
||||
this.updateGroupBox.Controls.Add(this.updateFeedUrlBox);
|
||||
this.updateGroupBox.Controls.Add(this.setUpdateFeedButton);
|
||||
this.updateGroupBox.Controls.Add(this.includeDevBuildsBox);
|
||||
this.updateGroupBox.Location = new System.Drawing.Point(12, 391);
|
||||
this.updateGroupBox.Name = "updateGroupBox";
|
||||
this.updateGroupBox.Size = new System.Drawing.Size(684, 60);
|
||||
this.updateGroupBox.Size = new System.Drawing.Size(684, 88);
|
||||
this.updateGroupBox.TabIndex = 3;
|
||||
this.updateGroupBox.TabStop = false;
|
||||
this.updateGroupBox.Text = "Auto-update feed URL (blank uses GitHub Releases; set per machine)";
|
||||
@@ -287,6 +289,16 @@
|
||||
this.setUpdateFeedButton.UseVisualStyleBackColor = true;
|
||||
this.setUpdateFeedButton.Click += new System.EventHandler(this.SetUpdateFeed_Click);
|
||||
//
|
||||
// includeDevBuildsBox
|
||||
//
|
||||
this.includeDevBuildsBox.AutoSize = true;
|
||||
this.includeDevBuildsBox.Location = new System.Drawing.Point(12, 58);
|
||||
this.includeDevBuildsBox.Name = "includeDevBuildsBox";
|
||||
this.includeDevBuildsBox.Size = new System.Drawing.Size(460, 20);
|
||||
this.includeDevBuildsBox.TabIndex = 2;
|
||||
this.includeDevBuildsBox.Text = "Include development builds (latest master; takes effect after restart)";
|
||||
this.includeDevBuildsBox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// ledgerGroupBox
|
||||
//
|
||||
this.ledgerGroupBox.Controls.Add(this.ledgerUrlLabel);
|
||||
@@ -362,7 +374,7 @@
|
||||
this.AcceptButton = this.saveButton;
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||
this.CancelButton = this.cancelButton;
|
||||
this.ClientSize = new System.Drawing.Size(708, 697);
|
||||
this.ClientSize = new System.Drawing.Size(708, 725);
|
||||
this.Controls.Add(this.strategyGroupBox);
|
||||
this.Controls.Add(this.updateGroupBox);
|
||||
this.Controls.Add(this.ledgerGroupBox);
|
||||
@@ -415,6 +427,7 @@
|
||||
private System.Windows.Forms.GroupBox updateGroupBox;
|
||||
private System.Windows.Forms.TextBox updateFeedUrlBox;
|
||||
private System.Windows.Forms.Button setUpdateFeedButton;
|
||||
private System.Windows.Forms.CheckBox includeDevBuildsBox;
|
||||
private System.Windows.Forms.GroupBox ledgerGroupBox;
|
||||
private System.Windows.Forms.Label ledgerUrlLabel;
|
||||
private System.Windows.Forms.TextBox ledgerUrlBox;
|
||||
|
||||
@@ -82,6 +82,7 @@ namespace OpenNest.Forms
|
||||
{
|
||||
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
|
||||
updateFeedUrlBox.Text = Settings.Default.UpdateFeedUrl;
|
||||
includeDevBuildsBox.Checked = Settings.Default.UpdateIncludeDevBuilds;
|
||||
ledgerUrlBox.Text = MaterialLedgerStore.Settings.BaseUrl;
|
||||
ledgerKeyBox.Text = MaterialLedgerStore.Settings.ApiKey;
|
||||
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
||||
@@ -105,6 +106,7 @@ namespace OpenNest.Forms
|
||||
private void SaveSettings()
|
||||
{
|
||||
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
|
||||
Settings.Default.UpdateIncludeDevBuilds = includeDevBuildsBox.Checked;
|
||||
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
|
||||
Settings.Default.CuttingPlanEffort = (double)planningEffortBox.Value;
|
||||
Settings.Default.ActiveColorScheme =
|
||||
|
||||
@@ -49,6 +49,11 @@ public sealed class SavedNestsForm : Form
|
||||
private readonly TabPage versionsTab;
|
||||
private NestHistoryForm historyView;
|
||||
private Guid? historyId;
|
||||
// Historical version shown in the details and preview instead of the current one.
|
||||
private int? previewRevision;
|
||||
private int revisionGeneration;
|
||||
private readonly Dictionary<int, NestDetails> revisionDetails = new();
|
||||
private readonly Timer revisionTimer;
|
||||
private readonly SplitContainer detailsSplit;
|
||||
private readonly PlateView platePreview;
|
||||
private readonly Button previousPlateButton;
|
||||
@@ -281,6 +286,12 @@ public sealed class SavedNestsForm : Form
|
||||
if (SelectedNestId() is Guid id)
|
||||
await ShowDetailsAsync(id);
|
||||
};
|
||||
revisionTimer = new Timer { Interval = 250 };
|
||||
revisionTimer.Tick += async (_, _) =>
|
||||
{
|
||||
revisionTimer.Stop();
|
||||
await ShowSelectedRevisionAsync();
|
||||
};
|
||||
|
||||
var refreshButton = new Button { Text = "Refresh", AutoSize = true };
|
||||
refreshButton.Click += async (_, _) => await RunAsync(session.RefreshAsync);
|
||||
@@ -526,12 +537,16 @@ public sealed class SavedNestsForm : Form
|
||||
detailsTimer.Stop();
|
||||
var id = SelectedNestId();
|
||||
if (historyId != id)
|
||||
{
|
||||
historyView?.Dispose();
|
||||
historyView = null;
|
||||
historyId = null;
|
||||
}
|
||||
DropHistoryView();
|
||||
UpdateHistoryView();
|
||||
if (historyView?.SelectedHistoricalRevision != null)
|
||||
{
|
||||
// The Versions tab has an earlier version highlighted: show that one, not the current.
|
||||
revisionTimer.Stop();
|
||||
revisionTimer.Start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
details.Clear();
|
||||
@@ -565,7 +580,7 @@ public sealed class SavedNestsForm : Form
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await details.LoadAsync(id) && !IsDisposed)
|
||||
if (await details.LoadAsync(id) && !IsDisposed && previewRevision == null)
|
||||
{
|
||||
PopulateDetails(details.Details);
|
||||
PrefetchNearby(id);
|
||||
@@ -713,9 +728,14 @@ public sealed class SavedNestsForm : Form
|
||||
{
|
||||
if (detailsTabs.SelectedTab != versionsTab || SelectedNestId() is not Guid id || historyId == id)
|
||||
return;
|
||||
historyView?.Dispose();
|
||||
DropHistoryView();
|
||||
historyView = new NestHistoryForm(repository, serverUrl, id, embedded: true);
|
||||
historyId = id;
|
||||
historyView.RevisionSelected += (_, _) =>
|
||||
{
|
||||
revisionTimer.Stop();
|
||||
revisionTimer.Start();
|
||||
};
|
||||
historyView.HistoryChanged += (_, _) =>
|
||||
{
|
||||
// Let the history operation finish before refreshing the browse selection.
|
||||
@@ -732,6 +752,75 @@ public sealed class SavedNestsForm : Form
|
||||
historyView.Show();
|
||||
}
|
||||
|
||||
/// <summary>Disposes the embedded history and returns the details to the current version.</summary>
|
||||
private void DropHistoryView()
|
||||
{
|
||||
historyView?.Dispose();
|
||||
historyView = null;
|
||||
historyId = null;
|
||||
revisionTimer.Stop();
|
||||
revisionGeneration++;
|
||||
previewRevision = null;
|
||||
revisionDetails.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the version highlighted in the Versions tab in the plates, drawings and preview.
|
||||
/// The current version comes from <see cref="NestDetailsSession"/>; earlier versions are
|
||||
/// downloaded once per embedded history view and kept, since saved versions never change.
|
||||
/// </summary>
|
||||
private async Task ShowSelectedRevisionAsync()
|
||||
{
|
||||
var view = historyView;
|
||||
var record = view?.SelectedHistoricalRevision;
|
||||
var run = ++revisionGeneration;
|
||||
if (record == null)
|
||||
{
|
||||
if (previewRevision != null)
|
||||
{
|
||||
previewRevision = null;
|
||||
ScheduleDetails();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
previewRevision = record.Revision;
|
||||
if (revisionDetails.TryGetValue(record.Revision, out var cached))
|
||||
{
|
||||
PopulateRevision(record, cached);
|
||||
return;
|
||||
}
|
||||
|
||||
ShowDetailsMessage($"Loading version {record.Revision}...");
|
||||
try
|
||||
{
|
||||
var bytes = await view.DownloadRevisionAsync(record.Revision);
|
||||
if (bytes == null || IsDisposed || run != revisionGeneration)
|
||||
return;
|
||||
var nestDetails = await Task.Run(() =>
|
||||
{
|
||||
using var stream = new MemoryStream(bytes);
|
||||
return NestDetails.FromNest(new NestReader(stream) { BestFitStore = BestFitCacheStore.Shared }.Read());
|
||||
});
|
||||
if (IsDisposed || run != revisionGeneration)
|
||||
return;
|
||||
revisionDetails[record.Revision] = nestDetails;
|
||||
PopulateRevision(record, nestDetails);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsDisposed && run == revisionGeneration)
|
||||
ShowDetailsMessage($"Could not load version {record.Revision}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateRevision(NestRecord record, NestDetails nestDetails)
|
||||
{
|
||||
PopulateDetails(nestDetails);
|
||||
detailsStatus.Text = $"Version {record.Revision} (historical) — {detailsStatus.Text}";
|
||||
}
|
||||
|
||||
private async Task ShowDeletedAsync()
|
||||
{
|
||||
using var dialog = new NestHistoryForm(repository, serverUrl);
|
||||
@@ -804,9 +893,7 @@ public sealed class SavedNestsForm : Form
|
||||
// Drop archive previews and embedded history, including prefetched snapshots.
|
||||
details.Dispose();
|
||||
details = new NestDetailsSession(LoadDetailsAsync);
|
||||
historyView?.Dispose();
|
||||
historyView = null;
|
||||
historyId = null;
|
||||
DropHistoryView();
|
||||
ShowDetailsMessage("");
|
||||
await RunAsync(session.RefreshAsync);
|
||||
foreach (DataGridViewRow row in nestGrid.Rows)
|
||||
@@ -887,6 +974,7 @@ public sealed class SavedNestsForm : Form
|
||||
{
|
||||
searchTimer.Dispose();
|
||||
detailsTimer.Dispose();
|
||||
revisionTimer.Dispose();
|
||||
session.Dispose();
|
||||
details.Dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows a status strip's item tooltips above the strip. The strip's own tooltips open
|
||||
/// below the cursor; at the bottom of the screen Windows pushes them back over the
|
||||
/// cursor, the strip sees a mouse leave and hides them, and they flash on and off.
|
||||
/// </summary>
|
||||
internal sealed class StatusStripToolTips : IDisposable
|
||||
{
|
||||
private const int Gap = 2;
|
||||
|
||||
private readonly ToolStrip strip;
|
||||
private readonly ToolTip toolTip = new();
|
||||
private readonly Timer hoverTimer = new() { Interval = SystemInformation.MouseHoverTime };
|
||||
private ToolStripItem hoverItem;
|
||||
private string shownText;
|
||||
|
||||
public StatusStripToolTips(ToolStrip strip)
|
||||
{
|
||||
this.strip = strip;
|
||||
strip.ShowItemToolTips = false;
|
||||
strip.MouseMove += Strip_MouseMove;
|
||||
strip.MouseLeave += Strip_MouseLeave;
|
||||
strip.MouseDown += Strip_MouseDown;
|
||||
hoverTimer.Tick += HoverTimer_Tick;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
hoverTimer.Dispose();
|
||||
toolTip.Dispose();
|
||||
}
|
||||
|
||||
private void Strip_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
var item = strip.GetItemAt(e.Location);
|
||||
if (item == hoverItem)
|
||||
return;
|
||||
|
||||
Hide();
|
||||
hoverItem = item;
|
||||
if (!string.IsNullOrEmpty(item?.ToolTipText))
|
||||
hoverTimer.Start();
|
||||
}
|
||||
|
||||
private void Strip_MouseLeave(object sender, EventArgs e)
|
||||
{
|
||||
Hide();
|
||||
hoverItem = null;
|
||||
}
|
||||
|
||||
// The next move re-arms the hover, so a click that changes the text (a hold refresh) shows the new tip.
|
||||
private void Strip_MouseDown(object sender, MouseEventArgs e)
|
||||
{
|
||||
Hide();
|
||||
hoverItem = null;
|
||||
}
|
||||
|
||||
private void HoverTimer_Tick(object sender, EventArgs e)
|
||||
{
|
||||
hoverTimer.Stop();
|
||||
Show();
|
||||
}
|
||||
|
||||
private void Show()
|
||||
{
|
||||
var text = hoverItem?.ToolTipText;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// The tooltip draws its text in the status font plus a few pixels of border and margin.
|
||||
var height = TextRenderer.MeasureText(text, SystemFonts.StatusFont).Height + 10;
|
||||
toolTip.Show(text, strip, hoverItem.Bounds.Left, -height - Gap);
|
||||
shownText = text;
|
||||
}
|
||||
|
||||
private void Hide()
|
||||
{
|
||||
hoverTimer.Stop();
|
||||
if (shownText == null)
|
||||
return;
|
||||
|
||||
toolTip.Hide(strip);
|
||||
shownText = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Help;
|
||||
using OpenNest.Support;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
@@ -68,20 +69,6 @@ public sealed class SupportGridForm : Form
|
||||
WrapContents = false,
|
||||
Padding = new Padding(10),
|
||||
};
|
||||
layout.Controls.Add(new Label
|
||||
{
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(430, 0),
|
||||
Padding = new Padding(0, 0, 0, 8),
|
||||
Text = "What cut parts rest on: a grid of contact points, one every pitch along X and Y, the "
|
||||
+ "first at the offset from the plate origin into the sheet; or slats, each a strip of "
|
||||
+ "the slat thickness running the full length or width of the table, one every pitch, "
|
||||
+ "the first centered at the offset. A part whose center of gravity is not inside its "
|
||||
+ "contacts by the stability margin can tip; with \"Only tab parts the support grid can't "
|
||||
+ "hold level\" in Cutting Settings, Plan Cutting tabs it where the tab steadies it. "
|
||||
+ "The registration tolerance allows for a sheet loaded off its place.",
|
||||
});
|
||||
|
||||
useGridBox = new CheckBox { Text = "This nest's sheets rest on a support bed", AutoSize = true, Checked = bed != null };
|
||||
layout.Controls.Add(useGridBox);
|
||||
|
||||
@@ -163,14 +150,34 @@ public sealed class SupportGridForm : Form
|
||||
defaultsGroup.Controls.Add(defaultsLayout);
|
||||
layout.Controls.Add(defaultsGroup);
|
||||
|
||||
var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Anchor = AnchorStyles.Right };
|
||||
// The help link sits left of OK/Cancel; the row stretches to the dialog's widest control.
|
||||
var footer = new TableLayoutPanel { AutoSize = true, ColumnCount = 2, Anchor = AnchorStyles.Left | AnchorStyles.Right };
|
||||
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
var helpLink = new LinkLabel
|
||||
{
|
||||
Text = "How the support bed works",
|
||||
AutoSize = true,
|
||||
Anchor = AnchorStyles.Left,
|
||||
};
|
||||
helpLink.LinkClicked += (_, _) => HelpTopics.Show(this, HelpTopics.SupportGrid);
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
WrapContents = false,
|
||||
Anchor = AnchorStyles.Right,
|
||||
};
|
||||
var cancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||||
var ok = new Button { Text = "OK", AutoSize = true };
|
||||
buttons.Controls.Add(cancel);
|
||||
buttons.Controls.Add(ok);
|
||||
layout.Controls.Add(buttons);
|
||||
footer.Controls.Add(helpLink, 0, 0);
|
||||
footer.Controls.Add(buttons, 1, 0);
|
||||
layout.Controls.Add(footer);
|
||||
|
||||
Controls.Add(layout);
|
||||
HelpTopics.Attach(this, HelpTopics.SupportGrid);
|
||||
AcceptButton = ok;
|
||||
CancelButton = cancel;
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// Background update checks (GitHub Releases, or the per-machine feed URL from the
|
||||
/// Options dialog) and the status-bar item that announces a downloaded update. Never
|
||||
/// Background and on-demand (Help > Check for Updates) update checks (GitHub Releases,
|
||||
/// or the per-machine feed URL from the Options dialog), the status-bar version label
|
||||
/// and the status-bar item that announces a downloaded update. Never
|
||||
/// restarts on its own: it restarts only when the user clicks the item and confirms.
|
||||
/// A download left unapplied installs at the next startup (Velopack's default
|
||||
/// auto-apply in <see cref="MainApp"/>).
|
||||
/// </summary>
|
||||
internal sealed class UpdaterStatus : IDisposable
|
||||
{
|
||||
private readonly Form owner;
|
||||
private readonly UpdaterService updater = new();
|
||||
private const string ReleasesUrl = "https://github.com/ajisaacs/OpenNest/releases";
|
||||
|
||||
private readonly ToolStripStatusLabel label;
|
||||
private bool checking;
|
||||
|
||||
public UpdaterStatus(Form owner, StatusStrip statusStrip, Font baseFont)
|
||||
{
|
||||
@@ -27,6 +34,13 @@ namespace OpenNest.Forms
|
||||
label.Click += Label_Click;
|
||||
statusStrip.Items.Add(label);
|
||||
|
||||
statusStrip.Items.Add(new ToolStripStatusLabel
|
||||
{
|
||||
Text = "v" + CurrentVersion,
|
||||
ToolTipText = "OpenNest version",
|
||||
ForeColor = SystemColors.GrayText,
|
||||
});
|
||||
|
||||
updater.UpdateReady += version =>
|
||||
{
|
||||
if (owner.IsHandleCreated)
|
||||
@@ -34,8 +48,63 @@ namespace OpenNest.Forms
|
||||
};
|
||||
}
|
||||
|
||||
public string CurrentVersion => updater.CurrentVersion;
|
||||
|
||||
public void Start() => updater.Start();
|
||||
|
||||
/// <summary>Checks for an update now and reports the outcome; offers a restart when one is ready.</summary>
|
||||
public async void CheckForUpdates()
|
||||
{
|
||||
if (checking)
|
||||
return;
|
||||
|
||||
checking = true;
|
||||
var previousCursor = owner.Cursor;
|
||||
owner.Cursor = Cursors.WaitCursor;
|
||||
UpdaterService.CheckResult result;
|
||||
try
|
||||
{
|
||||
result = await updater.CheckNowAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
owner.Cursor = previousCursor;
|
||||
checking = false;
|
||||
}
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case UpdaterService.CheckResult.UpdateReady:
|
||||
ShowUpdateReady(updater.PendingVersion);
|
||||
PromptRestart($"OpenNest {updater.PendingVersion} has been downloaded. Restart now to apply it? Any unsaved nest will be lost.");
|
||||
break;
|
||||
|
||||
case UpdaterService.CheckResult.UpToDate:
|
||||
MessageBox.Show(owner, $"OpenNest {CurrentVersion} is the latest version.",
|
||||
"Check for Updates", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
break;
|
||||
|
||||
case UpdaterService.CheckResult.Failed:
|
||||
MessageBox.Show(owner, "Could not reach the update source. Check the network connection and try again.",
|
||||
"Check for Updates", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
break;
|
||||
|
||||
case UpdaterService.CheckResult.NotInstalled:
|
||||
var open = MessageBox.Show(owner,
|
||||
$"This copy of OpenNest ({CurrentVersion}) was not installed with the OpenNest installer, so it cannot update itself.\n\nOpen the releases page to download the latest version?",
|
||||
"Check for Updates", MessageBoxButtons.YesNo, MessageBoxIcon.Information);
|
||||
if (open == DialogResult.Yes)
|
||||
OpenReleasesPage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowAbout()
|
||||
{
|
||||
MessageBox.Show(owner, $"OpenNest {CurrentVersion}\n\n{ReleasesUrl}",
|
||||
"About OpenNest", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
|
||||
public void Dispose() => updater.Dispose();
|
||||
|
||||
private void Label_Click(object sender, EventArgs e)
|
||||
@@ -43,17 +112,29 @@ namespace OpenNest.Forms
|
||||
if (!updater.HasPendingUpdate)
|
||||
return;
|
||||
|
||||
var result = MessageBox.Show(
|
||||
owner,
|
||||
"An OpenNest update has been downloaded. Restart now to apply it? Any unsaved nest will be lost.",
|
||||
"Update ready",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question
|
||||
);
|
||||
PromptRestart("An OpenNest update has been downloaded. Restart now to apply it? Any unsaved nest will be lost.");
|
||||
}
|
||||
|
||||
private void PromptRestart(string message)
|
||||
{
|
||||
var result = MessageBox.Show(owner, message, "Update ready", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||||
if (result == DialogResult.Yes)
|
||||
updater.ApplyAndRestart();
|
||||
}
|
||||
|
||||
private void OpenReleasesPage()
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(ReleasesUrl) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(owner, $"Could not open {ReleasesUrl}: {ex.Message}",
|
||||
"Check for Updates", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowUpdateReady(string version)
|
||||
{
|
||||
label.Text = $"Update {version} ready - click to restart";
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace OpenNest.Help
|
||||
{
|
||||
pendingPage = page;
|
||||
Text = "OpenNest Help";
|
||||
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
||||
Icon = Properties.Resources.app_icon;
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
|
||||
Size = new Size(System.Math.Min(1100, area.Width), System.Math.Min(900, area.Height));
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace OpenNest.Help
|
||||
public const string ImportDrawings = "import-drawings";
|
||||
public const string AutoNest = "auto-nest";
|
||||
public const string Post = "post";
|
||||
public const string SupportGrid = "support-grid";
|
||||
|
||||
/// <summary>Overrides the help folder, e.g. a local opennest-help <c>site/</c> build.</summary>
|
||||
public const string DirectoryVariable = "OPENNEST_HELP_DIR";
|
||||
|
||||
@@ -173,7 +173,7 @@ namespace OpenNest
|
||||
/// Draws the id over a disc of <paramref name="background"/>, so a line drawn through the
|
||||
/// label's point, such as a cut-off's own cut, does not strike through the number.
|
||||
/// </summary>
|
||||
internal void DrawLabel(Graphics g, string id, bool muted, Brush background)
|
||||
internal void DrawLabel(Graphics g, string id, Brush background, Brush text)
|
||||
{
|
||||
var size = MeasureLabel(g, id);
|
||||
if (!LabelFits(size))
|
||||
@@ -181,7 +181,7 @@ namespace OpenNest
|
||||
var diameter = LabelDiscDiameter(size);
|
||||
g.FillEllipse(background,
|
||||
_labelScreenPoint.X - diameter / 2, _labelScreenPoint.Y - diameter / 2, diameter, diameter);
|
||||
DrawLabel(g, id, muted);
|
||||
g.DrawString(id, ProgramIdFont, text, _labelScreenPoint.X, _labelScreenPoint.Y, LabelFormat);
|
||||
}
|
||||
|
||||
private SizeF MeasureLabel(Graphics g, string id)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<AssemblyName>OpenNest</AssemblyName>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationHighDpiMode>DpiUnaware</ApplicationHighDpiMode>
|
||||
<ApplicationIcon>Resources\OpenNest.ico</ApplicationIcon>
|
||||
<Version>0.4.0</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
@@ -28,6 +29,7 @@
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
|
||||
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" ReferenceOutputAssembly="false" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Configuration;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Properties
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores the desktop app's user-scoped <see cref="Settings"/> in
|
||||
/// %APPDATA%\OpenNest\user-settings.json instead of .NET's default user.config.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default provider writes to %LOCALAPPDATA%\OpenNest\<exe>_Url_<hash>\<version>\,
|
||||
/// which is inside the Velopack install folder (setup wipes it on reinstall) and
|
||||
/// starts empty at every assembly version. This file survives both. When it does
|
||||
/// not exist yet, values the default provider still holds for this copy are
|
||||
/// carried over once.
|
||||
/// </remarks>
|
||||
public sealed class AppDataSettingsProvider : SettingsProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
private readonly string _path;
|
||||
private readonly Func<SettingsProvider> _legacyProvider;
|
||||
|
||||
public AppDataSettingsProvider()
|
||||
: this(DefaultPath, () => new LocalFileSettingsProvider()) { }
|
||||
|
||||
/// <param name="path">The settings file.</param>
|
||||
/// <param name="legacyProvider">Supplies values to carry over when <paramref name="path"/> does not exist; null for none.</param>
|
||||
public AppDataSettingsProvider(string path, Func<SettingsProvider> legacyProvider)
|
||||
{
|
||||
_path = path;
|
||||
_legacyProvider = legacyProvider;
|
||||
}
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\user-settings.json.</summary>
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest", "user-settings.json");
|
||||
|
||||
public override string ApplicationName { get; set; } = "OpenNest";
|
||||
|
||||
public override void Initialize(string name, NameValueCollection config)
|
||||
=> base.Initialize(string.IsNullOrEmpty(name) ? nameof(AppDataSettingsProvider) : name, config);
|
||||
|
||||
public override SettingsPropertyValueCollection GetPropertyValues(SettingsContext context, SettingsPropertyCollection collection)
|
||||
{
|
||||
var stored = File.Exists(_path) ? Load() : Migrate(context, collection);
|
||||
var values = new SettingsPropertyValueCollection();
|
||||
|
||||
foreach (SettingsProperty property in collection)
|
||||
{
|
||||
var value = new SettingsPropertyValue(property);
|
||||
if (stored.TryGetValue(property.Name, out var serialized))
|
||||
{
|
||||
value.SerializedValue = serialized;
|
||||
value.IsDirty = false;
|
||||
}
|
||||
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
public override void SetPropertyValues(SettingsContext context, SettingsPropertyValueCollection collection)
|
||||
{
|
||||
// Keep entries this build does not know about, so older and newer copies share the file.
|
||||
var stored = Load();
|
||||
|
||||
foreach (SettingsPropertyValue value in collection)
|
||||
{
|
||||
// Untouched defaults stay out of the file so a later default change still applies.
|
||||
if (!value.IsDirty && (value.UsingDefaultValue || value.SerializedValue is null))
|
||||
continue;
|
||||
|
||||
if (value.SerializedValue is string serialized)
|
||||
stored[value.Property.Name] = serialized;
|
||||
else
|
||||
stored.Remove(value.Property.Name);
|
||||
|
||||
value.IsDirty = false;
|
||||
}
|
||||
|
||||
Write(stored);
|
||||
}
|
||||
|
||||
private Dictionary<string, string> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(_path)
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(_path)) ?? new()
|
||||
: new();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> Migrate(SettingsContext context, SettingsPropertyCollection collection)
|
||||
{
|
||||
var stored = new Dictionary<string, string>();
|
||||
if (_legacyProvider is null)
|
||||
return stored;
|
||||
|
||||
try
|
||||
{
|
||||
var legacy = _legacyProvider();
|
||||
legacy.Initialize(null, null);
|
||||
// user.config reports a missing setting as its default text; skip those.
|
||||
foreach (SettingsPropertyValue value in legacy.GetPropertyValues(context, collection))
|
||||
{
|
||||
if (value.SerializedValue is string serialized
|
||||
&& serialized != value.Property.DefaultValue as string)
|
||||
stored[value.Property.Name] = serialized;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ConfigurationException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// An unreadable user.config only means there is nothing to carry over.
|
||||
return new();
|
||||
}
|
||||
|
||||
if (stored.Count > 0)
|
||||
Write(stored);
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
private void Write(Dictionary<string, string> stored)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path)));
|
||||
var temp = _path + ".tmp";
|
||||
File.WriteAllText(temp, JsonSerializer.Serialize(stored, JsonOptions));
|
||||
File.Move(temp, _path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Settings are a convenience: keep running with the in-memory values.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -83,6 +83,13 @@ namespace OpenNest.Properties {
|
||||
/// <summary>
|
||||
/// Looks up a localized resource of type System.Drawing.Bitmap.
|
||||
/// </summary>
|
||||
internal static System.Drawing.Icon app_icon {
|
||||
get {
|
||||
object obj = ResourceManager.GetObject("app_icon", resourceCulture);
|
||||
return ((System.Drawing.Icon)(obj));
|
||||
}
|
||||
}
|
||||
|
||||
internal static System.Drawing.Bitmap cut_planner {
|
||||
get {
|
||||
object obj = ResourceManager.GetObject("cut_planner", resourceCulture);
|
||||
|
||||
@@ -205,4 +205,7 @@
|
||||
<data name="drawing_library" type="System.Resources.ResXFileRef, System.Windows.Forms">
|
||||
<value>..\Resources\drawing_library.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
|
||||
</data>
|
||||
<data name="app_icon" type="System.Resources.ResXFileRef, System.Windows.Forms">
|
||||
<value>..\Resources\OpenNest.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
|
||||
</data>
|
||||
</root>
|
||||
Generated
+26
@@ -307,6 +307,19 @@ namespace OpenNest.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("False")]
|
||||
// Also offer the rolling dev-build pre-release from GitHub Releases.
|
||||
public bool UpdateIncludeDevBuilds {
|
||||
get {
|
||||
return ((bool)(this["UpdateIncludeDevBuilds"]));
|
||||
}
|
||||
set {
|
||||
this["UpdateIncludeDevBuilds"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("Laser")]
|
||||
@@ -359,6 +372,19 @@ namespace OpenNest.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("True")]
|
||||
// The Plan Cutting toolbar button plans every plate; false plans only the plate on screen.
|
||||
public bool CuttingPlanAllPlates {
|
||||
get {
|
||||
return ((bool)(this["CuttingPlanAllPlates"]));
|
||||
}
|
||||
set {
|
||||
this["CuttingPlanAllPlates"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("False")]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Configuration;
|
||||
|
||||
namespace OpenNest.Properties
|
||||
{
|
||||
// Keeps user settings outside the Velopack install folder; see AppDataSettingsProvider.
|
||||
[SettingsProvider(typeof(AppDataSettingsProvider))]
|
||||
internal sealed partial class Settings
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,9 @@
|
||||
<Setting Name="UpdateFeedUrl" Type="System.String" Scope="User">
|
||||
<Value Profile="(Default)" />
|
||||
</Setting>
|
||||
<Setting Name="UpdateIncludeDevBuilds" Type="System.Boolean" Scope="User">
|
||||
<Value Profile="(Default)">False</Value>
|
||||
</Setting>
|
||||
<Setting Name="DrawingLibraryCutType" Type="System.String" Scope="User">
|
||||
<Value Profile="(Default)">Laser</Value>
|
||||
</Setting>
|
||||
@@ -86,6 +89,9 @@
|
||||
<Setting Name="CuttingPlanEffort" Type="System.Double" Scope="User">
|
||||
<Value Profile="(Default)">1</Value>
|
||||
</Setting>
|
||||
<Setting Name="CuttingPlanAllPlates" Type="System.Boolean" Scope="User">
|
||||
<Value Profile="(Default)">True</Value>
|
||||
</Setting>
|
||||
<Setting Name="CutOffLockHeadHeight" Type="System.Boolean" Scope="User">
|
||||
<Value Profile="(Default)">False</Value>
|
||||
</Setting>
|
||||
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user