feat(engine): pack round inserts into round cutouts as circles

Add CutoutCirclePacker, a circle-in-circle proposal for the cutout
router. When a cutout is one circle and every insert fills at least 0.8
of its enclosing circle, it packs the inserts largest first at centers
tangent to the wall or placed discs, trying a center-out pass (a single
insert sits concentric) and a wall-first pass (optimal counts for up to
nine equal discs). Every pose passes NestLayoutCheck.

The router runs it first and skips the lattice and NFP searches when it
places all demand; otherwise the higher count wins and existing
proposals keep ties. On synthetic disc jobs the prepass drops from
minutes to seconds and places as many or more inserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 12:49:36 -04:00
1 parent 2174416dcc
commit f3a6af7f09
4 files changed
+469 -3

No files matched your search

@@ -0,0 +1,190 @@
using OpenNest.Converters;
using OpenNest.Engine.CirclePacking;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutCirclePackerTests
{
private const double Spacing = 0.25;
[Fact]
public void SingleInsertSitsConcentric()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Part("disc", TestShapes.Disc(3), 1);
var pose = Assert.Single(CutoutCirclePacker.Fill(frame, 0, new[] { insert }, Spacing));
Assert.Equal(0, pose.X, 6);
Assert.Equal(0, pose.Y, 6);
AssertPhysical(frame, new[] { (insert, new Vector()) }, new[] { pose });
}
// Optimal container-to-circle radius ratios for n equal circles; all are rings, with a
// center circle for n = 7..9. Six is left out: its ratio already holds seven.
[Theory]
[InlineData(2, 2.0)]
[InlineData(3, 2.1547)]
[InlineData(4, 2.4143)]
[InlineData(5, 2.7014)]
[InlineData(7, 3.0)]
[InlineData(8, 3.3049)]
[InlineData(9, 3.6132)]
public void EqualDiscsReachTheKnownOptimalCount(int n, double ratio)
{
var insert = JobBuilder.Part("disc", TestShapes.Disc(1), n + 3);
var radius = Item.Create(JobPartGeometry.Read(insert.Geometry)).Radius;
var gap = Spacing + CutoutCirclePacker.Margin;
// Container and circles grown by half the gap, so the ratio applies to them directly.
var hole = ratio * (radius + gap / 2) - gap / 2 + gap + Item.FlattenAllowance + 0.001;
var frame = JobBuilder.Part("frame", TestShapes.Ring(2 * hole + 4, 2 * hole), 1);
var poses = CutoutCirclePacker.Fill(frame, 0, new[] { insert }, Spacing);
Assert.Equal(n, poses.Count);
Assert.Equal(Enumerable.Range(0, n), poses.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { (insert, new Vector()) }, poses);
}
[Fact]
public void MixedSizesFillTheGapsLargestFirst()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(40, 30), 1);
var large = JobBuilder.Part("large", TestShapes.Disc(4), 6);
var small = JobBuilder.Part("small", TestShapes.Disc(1), 20);
var poses = CutoutCirclePacker.Fill(frame, 0, new[] { small, large }, Spacing);
Assert.Equal(large.Quantity, poses.Count(p => p.PartId == large.Id));
Assert.True(poses.Count(p => p.PartId == small.Id) >= 10);
AssertPhysical(frame, new[] { (small, new Vector()), (large, new Vector()) }, poses);
}
[Fact]
public void RotatedOffCenterInsertMapsItsCenterIntoTheHole()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Part("disc", FromEntities(new Circle(50, 20, 2)), 2,
RotationPolicy.Fixed(System.Math.PI / 2));
var poses = CutoutCirclePacker.Fill(frame, 0, new[] { insert }, Spacing);
Assert.Equal(2, poses.Count);
Assert.All(poses, p => Assert.Equal(System.Math.PI / 2, p.Rotation, 9));
AssertPhysical(frame, new[] { (insert, new Vector(50, 20)) }, poses);
}
[Fact]
public void RouterTakesACompleteCirclePacking()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(30, 20), 1);
var insert = JobBuilder.Part("disc", TestShapes.Disc(3), 7);
var circles = CutoutCirclePacker.Fill(frame, 0, new[] { insert }, Spacing);
var origins = new List<bool>();
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, Spacing, (_, lattice) => origins.Add(lattice));
// Ratio 10 / 3.25 > 3: six around one in the center.
Assert.Equal(7, circles.Count);
Assert.Equal(circles, poses);
Assert.All(origins, Assert.False);
}
[Fact]
public void SquareHolesAndSquareInsertsAreNotRound()
{
var square = JobPartGeometry.Read(JobBuilder.Part("frame", Square(20, 10), 1).Geometry);
var ring = JobPartGeometry.Read(JobBuilder.Part("ring", TestShapes.Ring(20, 10), 1).Geometry);
var disc = JobBuilder.Part("disc", TestShapes.Disc(2), 1);
Assert.Null(CutoutCirclePacker.RoundHole(square.Cutouts[0]));
Assert.False(CutoutCirclePacker.Applies(square.Cutouts[0], new[] { disc }));
Assert.True(CutoutCirclePacker.Applies(ring.Cutouts[0], new[] { disc }));
Assert.False(CutoutCirclePacker.Applies(ring.Cutouts[0],
new[] { disc, JobBuilder.Rectangle("square", 2, 2, 1) }));
Assert.Empty(CutoutCirclePacker.Fill(JobBuilder.Part("frame", Square(20, 10), 1), 0,
new[] { disc }, Spacing));
}
[Fact]
public void InsertLargerThanTheHolePlacesNothing()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
Assert.Empty(CutoutCirclePacker.Fill(frame, 0,
new[] { JobBuilder.Part("disc", TestShapes.Disc(4.8), 1) }, Spacing));
}
[Fact]
public void CancellationIsNotNoFit()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutCirclePacker.Fill(
JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1), 0,
new[] { JobBuilder.Part("disc", TestShapes.Disc(1), 3) }, Spacing, cts.Token));
}
/// <summary>The layout check on a sheet, plus an independent analytic disc oracle.</summary>
private static void AssertPhysical(NestJobPart frame, (NestJobPart Part, Vector Center)[] inserts,
IReadOnlyList<NestJobPlacement> poses)
{
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
var bounds = frameGeometry.Bounds;
var stock = JobBuilder.Stock("sheet", bounds.Width + 2, bounds.Length + 2, Spacing);
var parts = new[] { frame }.Concat(inserts.Select(i => i.Part)).ToArray();
var job = JobBuilder.Job(parts, new[] { stock });
var dx = 1 - bounds.Left;
var dy = 1 - bounds.Bottom;
var placements = new[] { new NestJobPlacement(frame.Id, 0, dx, dy, 0) }
.Concat(poses.Select(p => p with { X = p.X + dx, Y = p.Y + dy })).ToArray();
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, placements) },
parts.Select(part =>
{
var placed = placements.Count(p => p.PartId == part.Id);
return new PartFulfillment(part.Id, part.Quantity, placed, part.Quantity - placed);
}).ToArray(),
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
var hole = CutoutCirclePacker.RoundHole(frameGeometry.Cutouts[0])!;
var discs = poses.Select(pose =>
{
var (part, local) = inserts.Single(i => i.Part.Id == pose.PartId);
var radius = JobPartGeometry.Read(part.Geometry).Bounds.Width / 2;
var cos = System.Math.Cos(pose.Rotation);
var sin = System.Math.Sin(pose.Rotation);
var center = new Vector(pose.X + local.X * cos - local.Y * sin, pose.Y + local.X * sin + local.Y * cos);
return (Center: center, Radius: radius);
}).ToArray();
foreach (var disc in discs)
Assert.True(disc.Center.DistanceTo(hole.Center) + disc.Radius <= hole.Radius - Spacing,
$"disc at {disc.Center} reaches the hole wall");
for (var i = 0; i < discs.Length; i++)
for (var j = i + 1; j < discs.Length; j++)
Assert.True(discs[i].Center.DistanceTo(discs[j].Center) >= discs[i].Radius + discs[j].Radius + Spacing,
$"discs at {discs[i].Center} and {discs[j].Center} are closer than spacing");
}
private static OpenNest.CNC.Program FromEntities(params Entity[] entities) =>
ConvertGeometry.ToProgram(entities.ToList());
private static OpenNest.CNC.Program Square(double outer, double inner)
{
var program = TestShapes.Rectangle(outer, outer);
var a = (outer - inner) / 2;
var b = a + inner;
program.MoveTo(a, a);
program.LineTo(a, b);
program.LineTo(b, b);
program.LineTo(b, a);
program.LineTo(a, a);
return program;
}
}
@@ -0,0 +1,244 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.CirclePacking;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Circle-in-circle proposal for a round cutout: every insert is packed by its enclosing
/// circle (<see cref="Item"/>), so the hole and inserts are discs and placement is analytic.
/// Never changes demand, stock or a live plate.</summary>
/// <remarks>Two greedy passes over the copies, largest first, choose among candidate centers
/// tangent to two of {hole wall, placed discs} plus the hole's center. One grows from the
/// center, so a single insert sits concentric and keeps its own bore free for a later level;
/// the other lays rings against the wall first, which reproduces the ring-shaped optimal
/// packings of up to nine equal circles. The higher count wins, ties keep the centered pass.
/// Every pose is certified with <see cref="NestLayoutCheck.Clears"/> before it is returned.</remarks>
internal static class CutoutCirclePacker
{
/// <summary>Inserts whose outline fills less of their enclosing circle stay with the lattice
/// and NFP proposals; a gear (about 0.85) packs by its tip circle, a square (0.64) does not.</summary>
internal const double MinFillRatio = 0.8;
/// <summary>Extra gap beyond the spacing on every pair and at the wall, covering the layout
/// check's flattening of the hole and of each insert.</summary>
internal const double Margin = 2 * NestTolerances.ValidationOutline;
// Greedy cost grows with the cube of the copy count; beyond this, a round hole's surplus
// copies stay ordinary demand.
private const int MaxCopies = 256;
private const double Epsilon = 1e-9;
private sealed record Disc(NestJobPart Part, Item Item, double Rotation, double Radius);
/// <summary>The hole's center and radius when every entity of the cutout lies on one circle;
/// otherwise null.</summary>
internal static Circle? RoundHole(Shape cutout)
{
ArgumentNullException.ThrowIfNull(cutout);
if (!cutout.IsClosed() || cutout.Entities.Count == 0)
return null;
Circle? first = null;
foreach (var entity in cutout.Entities)
{
var circle = entity switch
{
Circle c => new Circle(c.Center, c.Radius),
Arc a => new Circle(a.Center, a.Radius),
_ => null,
};
if (circle == null || !double.IsFinite(circle.Radius) || circle.Radius <= 0)
return null;
first ??= circle;
if (System.Math.Abs(circle.Radius - first.Radius) > Tolerance.Epsilon
|| circle.Center.DistanceTo(first.Center) > Tolerance.Epsilon)
return null;
}
return first;
}
/// <summary>True when the hole is round and every insert's enclosing circle is a fair model
/// of it, so this proposal is worth computing.</summary>
internal static bool Applies(Shape cutout, IReadOnlyList<NestJobPart> inserts) =>
RoundHole(cutout) != null && inserts.Count > 0
&& inserts.All(p => Item.TryCreate(p.Geometry) is { FillRatio: >= MinFillRatio });
/// <summary>Frame-local poses inside one round cutout, or none when the cutout is not round
/// or nothing fits. Instance indices count per requirement from zero.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(inserts);
token.ThrowIfCancellationRequested();
if (!double.IsFinite(spacing) || spacing < 0)
return Array.Empty<NestJobPlacement>();
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
if (frameGeometry == null)
return Array.Empty<NestJobPlacement>();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var hole = RoundHole(frameGeometry.Cutouts[cutoutIndex]);
if (hole == null)
return Array.Empty<NestJobPlacement>();
// The check flattens cutouts inscribed, so its hole may be an allowance smaller.
var wall = hole.Radius - Item.FlattenAllowance - spacing - Margin;
var discs = new List<Disc>();
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
foreach (var part in inserts)
{
token.ThrowIfCancellationRequested();
var geometry = JobPartGeometry.TryRead(part.Geometry);
if (geometry == null || part.Quantity <= 0)
continue;
var item = Item.Create(geometry);
var rotations = part.Rotation.EnumerateAngles(maxSamples: 1);
if (rotations.Count == 0 || item.Radius > wall)
continue;
geometries[part.Id] = geometry;
// No more copies than could cover the hole's area, so huge demand stays bounded.
var byArea = (int)System.Math.Min(part.Quantity,
System.Math.Floor(wall * wall / (item.Radius * item.Radius)) + 1);
for (var copy = 0; copy < byArea && discs.Count < MaxCopies; copy++)
discs.Add(new Disc(part, item, rotations[0], item.Radius));
}
if (discs.Count == 0)
return Array.Empty<NestJobPlacement>();
// Stable: equal radii keep caller requirement order.
discs = discs.OrderByDescending(d => d.Radius).ToList();
var gap = spacing + Margin;
var centered = Greedy(discs, wall, gap, fromCenter: true, token);
var walled = discs.Count > 1 ? Greedy(discs, wall, gap, fromCenter: false, token) : centered;
var chosen = walled.Count > centered.Count ? walled : centered;
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
var accepted = new List<(Disc Disc, Vector Center, NestJobPlacement Pose)>();
foreach (var (disc, offset) in chosen)
{
token.ThrowIfCancellationRequested();
var center = hole.Center + offset;
var origin = center + Rotate(disc.Item.PartOffset, disc.Rotation);
var pose = new NestJobPlacement(disc.Part.Id,
accepted.Count(p => p.Pose.PartId == disc.Part.Id), origin.X, origin.Y, disc.Rotation);
var geometry = geometries[disc.Part.Id];
if (!NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, spacing))
continue;
// Discs farther apart than their radii plus twice the spacing cannot touch the
// check's inflated outlines; the analytic gap already proves them clear.
if (accepted.Any(other => other.Center.DistanceTo(center)
< other.Disc.Radius + disc.Radius + 2 * spacing + Margin
&& !NestLayoutCheck.Clears(geometries[other.Disc.Part.Id], other.Pose, geometry, pose, spacing)))
continue;
accepted.Add((disc, center, pose));
}
token.ThrowIfCancellationRequested();
return accepted.Select(p => p.Pose).ToArray();
}
/// <summary>Places each disc in order at its best feasible candidate; a disc with no
/// feasible candidate is skipped so a smaller one may still fit. Centers are relative to
/// the hole's center; <paramref name="wall"/> bounds center distance plus radius.</summary>
private static List<(Disc Disc, Vector Center)> Greedy(IReadOnlyList<Disc> discs, double wall,
double gap, bool fromCenter, CancellationToken token)
{
var placed = new List<(Disc Disc, Vector Center)>();
foreach (var disc in discs)
{
token.ThrowIfCancellationRequested();
var r = disc.Radius;
var ring = wall - r;
Vector? best = null;
foreach (var candidate in Candidates(placed, ring, r, gap, fromCenter))
{
if (!Feasible(candidate) || (best is { } current && !Better(candidate, current)))
continue;
best = candidate;
}
if (best is { } chosen)
placed.Add((disc, chosen));
bool Feasible(Vector p)
{
if (p.DistanceTo(0, 0) > ring + Epsilon)
return false;
foreach (var (other, center) in placed)
if (center.DistanceTo(p) < other.Radius + r + gap - Epsilon)
return false;
return true;
}
}
return placed;
bool Better(Vector candidate, Vector current)
{
// Centered pass: nearest the center; wall pass: nearest the wall. Then the lowest
// angle counterclockwise from straight down, so rings close up in order.
var radial = candidate.DistanceTo(0, 0) - current.DistanceTo(0, 0);
if (System.Math.Abs(radial) > Epsilon)
return fromCenter ? radial < 0 : radial > 0;
return Turn(candidate) < Turn(current) - Epsilon;
}
}
private static IEnumerable<Vector> Candidates(List<(Disc Disc, Vector Center)> placed, double ring,
double r, double gap, bool fromCenter)
{
yield return new Vector(0, 0);
if (placed.Count == 0)
{
if (!fromCenter)
yield return new Vector(0, -ring);
yield break;
}
for (var i = 0; i < placed.Count; i++)
{
var (a, ca) = placed[i];
var da = a.Radius + r + gap;
foreach (var p in Intersections(new Vector(0, 0), ring, ca, da))
yield return p;
for (var j = i + 1; j < placed.Count; j++)
{
var (b, cb) = placed[j];
foreach (var p in Intersections(ca, da, cb, b.Radius + r + gap))
yield return p;
}
}
}
private static IEnumerable<Vector> Intersections(Vector c0, double r0, Vector c1, double r1)
{
var d = c0.DistanceTo(c1);
if (d < Epsilon || d > r0 + r1 || d < System.Math.Abs(r0 - r1))
yield break;
var along = (r0 * r0 - r1 * r1 + d * d) / (2 * d);
var across = System.Math.Sqrt(System.Math.Max(0, r0 * r0 - along * along));
var ux = (c1.X - c0.X) / d;
var uy = (c1.Y - c0.Y) / d;
var mx = c0.X + ux * along;
var my = c0.Y + uy * along;
yield return new Vector(mx - uy * across, my + ux * across);
if (across > Epsilon)
yield return new Vector(mx + uy * across, my - ux * across);
}
private static double Turn(Vector p)
{
if (p.DistanceTo(0, 0) < Epsilon)
return -1;
var angle = System.Math.Atan2(p.X, -p.Y);
return angle < 0 ? angle + 2 * System.Math.PI : angle;
}
private static Vector Rotate(Vector v, double angle)
{
var sine = System.Math.Sin(angle);
var cosine = System.Math.Cos(angle);
return new Vector(v.X * cosine - v.Y * sine, v.X * sine + v.Y * cosine);
}
}
+25 -3
View File
@@ -9,7 +9,9 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Internal frame-local cutout proposal. Never changes demand, stock or a live plate.</summary>
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
/// the large-insert path tries NFP first. All accepted poses are clearance-checked.
/// the large-insert path tries NFP first. A round hole whose inserts are all round also gets a
/// circle-in-circle proposal for the whole list, kept only when it places more copies.
/// All accepted poses are clearance-checked.
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
internal static class CutoutRouter
{
@@ -23,7 +25,7 @@ internal static class CutoutRouter
Fill(frame, cutoutIndex, inserts, spacing, null, token);
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
/// after indexing; true marks a retained lattice seed, false a residual NFP pose.</summary>
/// after indexing; true marks a retained lattice seed, false a residual NFP or circle-packer pose.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing,
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default)
@@ -48,7 +50,16 @@ internal static class CutoutRouter
throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.",
nameof(inserts));
// Nothing can place more than all demand, so a complete circle packing skips the
// lattice and NFP searches, which are slow on many round inserts.
var circles = CutoutCirclePacker.Applies(hole, inserts)
? CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, token)
: null;
if (circles != null && circles.Count == inserts.Sum(p => (long)System.Math.Max(0, p.Quantity)))
return Circles(circles);
var accepted = new List<NestJobPlacement>();
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
var holeArea = System.Math.Abs(hole.Area());
// Preserve caller requirement order: priorities are a whole-job concern, not inferred here.
@@ -97,7 +108,7 @@ internal static class CutoutRouter
var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) };
accepted.Add(indexed);
occupied.Add((geometry, indexed));
observeAccepted?.Invoke(indexed, localIndex++ < retainedLattice);
observed.Add((indexed, localIndex++ < retainedLattice));
}
IReadOnlyList<NestJobPlacement> Complete(IReadOnlyList<NestJobPlacement> seed)
@@ -127,7 +138,18 @@ internal static class CutoutRouter
}
}
token.ThrowIfCancellationRequested();
if (circles != null && circles.Count > accepted.Count)
return Circles(circles);
foreach (var (pose, lattice) in observed)
observeAccepted?.Invoke(pose, lattice);
return accepted.ToArray();
IReadOnlyList<NestJobPlacement> Circles(IReadOnlyList<NestJobPlacement> poses)
{
foreach (var pose in poses)
observeAccepted?.Invoke(pose, false);
return poses;
}
}
private static bool SupportedGeometry(NestJobPart part)
+10
View File
@@ -196,6 +196,16 @@ NFP first, then Fill if demand remains, taking the higher-count valid proposal.
An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole.
Six geometry-only, anonymized real-job probes and neutral ring fixtures informed
the search-order hints; these limited cases are not a global density guarantee.
When the cutout is one circle and every insert fills at least 0.8 of its enclosing circle
(discs, washers, gears by their tip circle), `CutoutCirclePacker` first packs the whole insert
list as discs: largest first, at centers tangent to two of the hole wall and the placed discs,
or at the hole's center. It tries a center-out pass, so a single insert sits concentric, and a
wall-first pass, which reaches the optimal counts for up to nine equal discs. Each gap is the
spacing plus twice `NestTolerances.ValidationOutline`, and every pose passes `NestLayoutCheck`.
If it places all demand, the router skips the lattice and NFP searches, which are slow on many
round inserts. Otherwise it keeps whichever proposal places more copies, and the existing
proposals win ties.
`CutoutPipelinePrepass` builds quantity-one proxy instances for eligible
closed-cutout frames. It considers legal frame rotations without outer-perimeter symmetry
reduction, shifts insert rotation policies into the local frame, and reserves certified router