feat: add _Template and New-Engine.ps1 for starting new engines
Each engine-building run started from a hand-copied scaffold (Qwen's), which carried that engine's name and stale notes. _Template holds the generic scaffold with a __NAME__ placeholder, and New-Engine.ps1 stamps out a named copy. The template's starter tests go through the benchmark's NestValidator, so a model gets a real pass/fail target instead of a plumbing-only check; they were verified to pass against Opus55. Directory.Build.props now also detects when it sits in an Engines/ folder inside an OpenNest checkout, so an engine stamped there with -IncludeBuildFiles builds without passing OpenNestRoot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,13 @@
|
|||||||
OpenNest.Engine.<Name>/ with an optional tests/ subproject; both import this.
|
OpenNest.Engine.<Name>/ with an optional tests/ subproject; both import this.
|
||||||
AssemblyName and RootNamespace default to the project file name.
|
AssemblyName and RootNamespace default to the project file name.
|
||||||
|
|
||||||
Engines build against an OpenNest checkout. By default that is a sibling clone
|
Engines build against an OpenNest checkout, found in this order: /p:OpenNestRoot=<path>
|
||||||
(../OpenNest next to this repo); override with /p:OpenNestRoot=<path> or an
|
or an OpenNestRoot environment variable; the parent folder, when these build files sit
|
||||||
OpenNestRoot environment variable.
|
in an Engines/ folder inside an OpenNest checkout; otherwise a sibling clone
|
||||||
|
(../OpenNest next to this repo).
|
||||||
-->
|
-->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<OpenNestRoot Condition="'$(OpenNestRoot)' == '' and Exists('$(MSBuildThisFileDirectory)../OpenNest.Engine/OpenNest.Engine.csproj')">$(MSBuildThisFileDirectory)../</OpenNestRoot>
|
||||||
<OpenNestRoot Condition="'$(OpenNestRoot)' == ''">$(MSBuildThisFileDirectory)../OpenNest/</OpenNestRoot>
|
<OpenNestRoot Condition="'$(OpenNestRoot)' == ''">$(MSBuildThisFileDirectory)../OpenNest/</OpenNestRoot>
|
||||||
<OpenNestRoot>$([MSBuild]::EnsureTrailingSlash('$(OpenNestRoot)'))</OpenNestRoot>
|
<OpenNestRoot>$([MSBuild]::EnsureTrailingSlash('$(OpenNestRoot)'))</OpenNestRoot>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Creates a new engine project from _Template/.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Copies _Template/ to <Destination>/OpenNest.Engine.<Name>/ and replaces the __NAME__
|
||||||
|
placeholder in file names and contents. The new engine builds against OpenNest via
|
||||||
|
Directory.Build.props, which must sit in <Destination> or a parent folder.
|
||||||
|
|
||||||
|
With -IncludeBuildFiles, Directory.Build.props/.targets are copied into <Destination>
|
||||||
|
too, so the engine can live outside this repo, e.g. in an Engines/ folder inside an
|
||||||
|
OpenNest checkout (the props detect that layout on their own).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./New-Engine.ps1 -Name Nova
|
||||||
|
./New-Engine.ps1 -Name Nova -Destination C:/bench/run1/OpenNest/Engines -IncludeBuildFiles
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[ValidatePattern('^[A-Z][A-Za-z0-9]*$')]
|
||||||
|
[string]$Name,
|
||||||
|
[string]$Destination = $PSScriptRoot,
|
||||||
|
[switch]$IncludeBuildFiles
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$template = Join-Path $PSScriptRoot '_Template'
|
||||||
|
$target = Join-Path $Destination "OpenNest.Engine.$Name"
|
||||||
|
if (Test-Path $target) { throw "'$target' already exists." }
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force $Destination | Out-Null
|
||||||
|
Copy-Item $template $target -Recurse
|
||||||
|
|
||||||
|
Get-ChildItem $target -Recurse -File | ForEach-Object {
|
||||||
|
$text = [IO.File]::ReadAllText($_.FullName)
|
||||||
|
[IO.File]::WriteAllText($_.FullName, $text.Replace('__NAME__', $Name))
|
||||||
|
}
|
||||||
|
# Deepest paths first so renaming a folder never invalidates a pending child path.
|
||||||
|
Get-ChildItem $target -Recurse | Where-Object Name -like '*__NAME__*' |
|
||||||
|
Sort-Object { $_.FullName.Length } -Descending |
|
||||||
|
ForEach-Object { Rename-Item $_.FullName $_.Name.Replace('__NAME__', $Name) }
|
||||||
|
|
||||||
|
if ($IncludeBuildFiles) {
|
||||||
|
foreach ($file in 'Directory.Build.props', 'Directory.Build.targets') {
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot $file) $Destination -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Created $target"
|
||||||
@@ -32,6 +32,23 @@ dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj
|
|||||||
|
|
||||||
Each engine's README covers its algorithm and benchmark results.
|
Each engine's README covers its algorithm and benchmark results.
|
||||||
|
|
||||||
|
## Writing a new engine
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./New-Engine.ps1 -Name Nova
|
||||||
|
```
|
||||||
|
|
||||||
|
This copies `_Template/` to `OpenNest.Engine.Nova/`: an `INestingEngine` stub, a README
|
||||||
|
spelling out what counts as an independent engine, and starter acceptance tests checked by
|
||||||
|
the benchmark's own `NestValidator` (they fail until `Solve()` is implemented).
|
||||||
|
|
||||||
|
To work inside an OpenNest checkout instead, stamp it into an `Engines/` folder there and
|
||||||
|
bring the shared build files along; they detect that layout automatically:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./New-Engine.ps1 -Name Nova -Destination <OpenNest>/Engines -IncludeBuildFiles
|
||||||
|
```
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Each engine lives in `OpenNest.Engine.<Name>/` with an optional `tests/` subproject.
|
Each engine lives in `OpenNest.Engine.<Name>/` with an optional `tests/` subproject.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<!-- Shared settings and the OpenNest.Engine reference come from Directory.Build.props. -->
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# OpenNest.Engine.__NAME__
|
||||||
|
|
||||||
|
An independent `INestingEngine` implementation. It must not be a wrapper, ensemble, or
|
||||||
|
selector over OpenNest's built-in engines. `Solve()` must not call, instantiate, or
|
||||||
|
delegate to any existing `INestingEngine` (`StockLadderNestingEngine`,
|
||||||
|
`FixedStrategyNestingEngine`), `NestingEngineRegistry`, `NestJobRunner`, or the whole-plate
|
||||||
|
nesters/fillers behind `PlateNesterFactory` (`DefaultPlateNester`, `StripPlateNester`,
|
||||||
|
`RemnantPlateNester`, `PlateFillService`, `DefaultPlateFiller`, ...). It must also never run
|
||||||
|
several of them and keep the best result.
|
||||||
|
|
||||||
|
The decisions that make it an engine must be yours: which sheet(s) to use, which parts go
|
||||||
|
where and in what order, which pattern/strategy to apply to which region, and when to stop.
|
||||||
|
|
||||||
|
## Allowed building blocks
|
||||||
|
|
||||||
|
Reuse is encouraged. These are tools you drive, composed by your own decision logic:
|
||||||
|
|
||||||
|
- `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, `ConvexHull`,
|
||||||
|
`ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`,
|
||||||
|
`SpatialQuery`.
|
||||||
|
- Fill and pattern components in `OpenNest.Engine.Fill`: `FillLinear`, `FillExtents`,
|
||||||
|
`PairFiller`, `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor`, `FillScore`,
|
||||||
|
`Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`,
|
||||||
|
`BestCombination`.
|
||||||
|
- `OpenNest.Engine.BestFit` (`BestFitFinder`, `PairEvaluator`, ...), `RectanglePacking`,
|
||||||
|
`CirclePacking`.
|
||||||
|
|
||||||
|
If you find a faster or better way to do something a shared component already does (for
|
||||||
|
example linear patterning), implement it inside this engine's own project and leave the
|
||||||
|
shared code untouched. Do not edit `OpenNest.Core`, `OpenNest.Engine`, or
|
||||||
|
`OpenNest.Benchmark`. Call it out in your report (what it replaces, why it is better,
|
||||||
|
measured numbers) so it can be generalized and upstreamed for every engine later.
|
||||||
|
|
||||||
|
## What to fill in
|
||||||
|
|
||||||
|
- `__NAME__NestingEngine.cs` — implement `Solve()`. Pick and document an actual placement
|
||||||
|
strategy (NFP-based sliding placement, skyline/shelf packer, simulated-annealing/genetic
|
||||||
|
layout search, guillotine-cut packer, physics/gravity-settling, etc). It's fine to be
|
||||||
|
simpler or worse than the built-in engines to start; it must not be the same algorithm
|
||||||
|
re-derived through indirection.
|
||||||
|
- This README — replace this section with a description of the algorithm, its trade-offs,
|
||||||
|
and benchmark results.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/` holds starter acceptance tests. Every layout is checked by the benchmark's own
|
||||||
|
`NestValidator` (bounds, spacing, quantities, stock, rotation), so a passing test means the
|
||||||
|
benchmark will accept the layout. They fail until `Solve()` is implemented. Keep them and
|
||||||
|
add engine-specific tests next to them.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test OpenNest.Engine.__NAME__/tests/OpenNest.Engine.__NAME__.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build and benchmark
|
||||||
|
|
||||||
|
The project is a plugin outside `OpenNest.sln`. `OpenNest.Benchmark` loads plugin engines
|
||||||
|
from an `Engines/` folder next to its own build output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build OpenNest.Engine.__NAME__/OpenNest.Engine.__NAME__.csproj -c Release
|
||||||
|
dotnet build <OpenNest>/OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
|
||||||
|
|
||||||
|
mkdir -p <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines
|
||||||
|
cp OpenNest.Engine.__NAME__/bin/Release/net8.0/OpenNest.Engine.__NAME__.dll <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines/
|
||||||
|
|
||||||
|
dotnet <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-folder> --parallel 1
|
||||||
|
```
|
||||||
|
|
||||||
|
`<OpenNest>` is the OpenNest checkout root. Your engine shows up in the report under its
|
||||||
|
CLR type name (`__NAME__NestingEngine`), competing on equal footing against the built-in
|
||||||
|
engines.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.__NAME__;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TODO: name and describe the actual placement strategy here (e.g. "skyline packer with
|
||||||
|
/// greedy shelf assignment", "NFP-based sliding placement with simulated-annealing order
|
||||||
|
/// search", etc). This must be an independently designed algorithm — see README.md.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class __NAME__NestingEngine : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(
|
||||||
|
NestJob job,
|
||||||
|
IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(job);
|
||||||
|
|
||||||
|
// TODO: implement independent placement logic here.
|
||||||
|
//
|
||||||
|
// Do NOT call NestingEngineRegistry.Create(...), PlateNesterFactory, PlateFillService,
|
||||||
|
// or any built-in INestingEngine, and do not run several and keep the best. The
|
||||||
|
// Fill/ and pattern components (FillLinear, PairFiller, PatternTiler, Compactor, ...)
|
||||||
|
// and OpenNest.Core geometry ARE fair game as tools; the decisions are yours.
|
||||||
|
//
|
||||||
|
// job.Parts -> requested parts (PartGeometrySnapshot geometry, quantity, priority, rotation policy)
|
||||||
|
// job.Plates -> candidate stock sheets (size, spacing, edge spacing, quadrant, quantity)
|
||||||
|
// job.Options -> job-wide options
|
||||||
|
//
|
||||||
|
// Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding
|
||||||
|
// ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id),
|
||||||
|
// and StockUsage (sheets used per stock id).
|
||||||
|
|
||||||
|
throw new NotImplementedException("__NAME__ nesting engine placement logic not yet implemented.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<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" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
<ProjectReference Include="../OpenNest.Engine.__NAME__.csproj" />
|
||||||
|
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
|
||||||
|
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Benchmark;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.__NAME__.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starter acceptance tests. Every layout is checked by the same NestValidator the benchmark
|
||||||
|
/// scores with, so a passing test means the benchmark will accept the layout. They fail until
|
||||||
|
/// Solve() is implemented; add engine-specific tests alongside them.
|
||||||
|
/// </summary>
|
||||||
|
public class __NAME__NestingEngineTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HasPublicParameterlessConstructorForPluginDiscovery()
|
||||||
|
{
|
||||||
|
var engine = Activator.CreateInstance(typeof(__NAME__NestingEngine));
|
||||||
|
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RectanglesFitOnOneSheetWithSpacing()
|
||||||
|
{
|
||||||
|
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
||||||
|
|
||||||
|
var result = new __NAME__NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Single(result.Plates);
|
||||||
|
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
|
||||||
|
{
|
||||||
|
var job = Job(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
Part("disc", Disc(3), 10),
|
||||||
|
Part("ell", LShape(12, 8, 4), 10),
|
||||||
|
Part("tri", Triangle(9, 6), 10),
|
||||||
|
},
|
||||||
|
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new __NAME__NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OverflowSpillsOntoAdditionalSheets()
|
||||||
|
{
|
||||||
|
var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) });
|
||||||
|
|
||||||
|
var result = new __NAME__NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.True(result.Plates.Count > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartTooBigForAnySheetIsReportedUnplaced()
|
||||||
|
{
|
||||||
|
var job = Job(
|
||||||
|
new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) },
|
||||||
|
new[] { Stock("sheet", 20, 20, spacing: 0.25) }
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new __NAME__NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
|
||||||
|
Assert.Equal(1, huge.Unplaced);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static void AssertValid(NestJob job, NestJobResult result)
|
||||||
|
{
|
||||||
|
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||||
|
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
|
||||||
|
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
|
||||||
|
p => materialized.DrawingsByPartId[p.Id],
|
||||||
|
p => (p.Id, p.Quantity),
|
||||||
|
ReferenceEqualityComparer.Instance
|
||||||
|
);
|
||||||
|
var validation = NestValidator.Validate(runs, requirements);
|
||||||
|
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||||
|
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
|
||||||
|
|
||||||
|
foreach (var f in result.Fulfillment)
|
||||||
|
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
||||||
|
new(parts, stock, options);
|
||||||
|
|
||||||
|
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
|
||||||
|
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
|
||||||
|
|
||||||
|
/// <param name="width">Y extent.</param>
|
||||||
|
/// <param name="length">X extent.</param>
|
||||||
|
private static NestPlateStock Stock(
|
||||||
|
string id,
|
||||||
|
double width,
|
||||||
|
double length,
|
||||||
|
double spacing = 0,
|
||||||
|
Spacing edge = default,
|
||||||
|
int quadrant = 1,
|
||||||
|
int? quantity = null
|
||||||
|
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
||||||
|
|
||||||
|
private static Program Polyline(params (double X, double Y)[] points)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||||
|
foreach (var (x, y) in points.Skip(1))
|
||||||
|
program.Codes.Add(new LinearMove(x, y));
|
||||||
|
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
||||||
|
|
||||||
|
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
||||||
|
|
||||||
|
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
||||||
|
|
||||||
|
private static Program Disc(double r)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.Codes.Add(new RapidMove(r, 0));
|
||||||
|
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
||||||
|
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user