mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 17:21:34 -04:00
perf(engine): replay cutout pre-pass clearance checks and trim hole subtraction
Profiling the cutout pre-pass on real round-part jobs put ~97% of its time in the frame-wall NestLayoutCheck.Clears inside CutoutCirclePacker.Fill. Ear-clipped round frames make every perimeter/insert triangle pair overlap, so a clear pair runs hole subtraction for every pair. The same question repeats for every frame angle and repeated frame instance, because Automatic inserts get identical frame-local poses. - CutoutMemo, one per Prepare, replays geometry reads, circle items and Clears answers. Keys hold geometry by reference, pose bit patterns, argument order and spacing. It is threaded through CutoutCirclePacker.Fill and the router's round-only path. - TriangulatedRegion.SubtractAllHoles measures each piece's bounds once, skips holes and hole triangles that no piece reaches, and ping-pongs the remainder between scratch buffers instead of copying it into pooled ones. Clip arithmetic and order are unchanged. Bundles are byte-identical on 14 real and 10 synthetic ring jobs. Pre-pass time over the real set drops from 99.6 s to 10.1 s (N0414-02 35.8 s -> 0.6 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f01b2cec67
commit
dd2c00aed4
6 files changed
+170
-50
No files matched your search
@@ -264,6 +264,9 @@ namespace OpenNest.Geometry
|
||||
[ThreadStatic]
|
||||
private static double[]? s_tmpB;
|
||||
|
||||
[ThreadStatic]
|
||||
private static double[]? s_tmpC;
|
||||
|
||||
private static double[] AcquireBuffer()
|
||||
{
|
||||
var pool = s_pool ??= new List<double[]>();
|
||||
@@ -282,11 +285,12 @@ namespace OpenNest.Geometry
|
||||
pool.Add(buf);
|
||||
}
|
||||
|
||||
private static (double[] Tmp, double[] Inside) ScratchPair()
|
||||
private static (double[] Tmp, double[] Inside, double[] Spare) Scratch()
|
||||
{
|
||||
s_tmpA ??= new double[MaxClipVertices * 2];
|
||||
s_tmpB ??= new double[MaxClipVertices * 2];
|
||||
return (s_tmpA, s_tmpB);
|
||||
s_tmpC ??= new double[MaxClipVertices * 2];
|
||||
return (s_tmpA, s_tmpB, s_tmpC);
|
||||
}
|
||||
|
||||
private (bool hasHoles, bool undecided, bool survived) SubtractAllHoles(
|
||||
@@ -309,9 +313,21 @@ namespace OpenNest.Geometry
|
||||
return (false, false, false);
|
||||
|
||||
// pieces[0] is the caller's own buffer - never release it back to the pool.
|
||||
var pieces = new List<(double[] Buf, int Count)> { (piece, count) };
|
||||
// A piece's buffer never changes once listed, so its bbox is measured once.
|
||||
var pieces = new List<Piece> { Measure(piece, count) };
|
||||
var spareList = new List<Piece>();
|
||||
var owned = new HashSet<double[]>();
|
||||
|
||||
// Built-in uses <=: touching skips subtraction, and a hole triangle no piece
|
||||
// reaches passes every piece on unchanged, so it is skipped without rebuilding.
|
||||
bool AnyReaches(double hMinX, double hMinY, double hMaxX, double hMaxY)
|
||||
{
|
||||
foreach (var p in pieces)
|
||||
if (!(p.MaxX <= hMinX || hMaxX <= p.MinX || p.MaxY <= hMinY || hMaxY <= p.MinY))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SubtractOwner(TriangulatedRegion owner, double odx, double ody)
|
||||
{
|
||||
if (owner.Holes == null)
|
||||
@@ -321,41 +337,32 @@ namespace OpenNest.Geometry
|
||||
var hole = owner.Holes[h];
|
||||
if (hole == null)
|
||||
continue; // untriangulatable hole: nothing to subtract
|
||||
// Every triangle's bbox lies within the hole's, translated alike.
|
||||
if (!AnyReaches(hole.MinX + odx, hole.MinY + ody, hole.MaxX + odx, hole.MaxY + ody))
|
||||
continue;
|
||||
for (var t = 0; t < hole._ia.Length && pieces.Count > 0; t++)
|
||||
{
|
||||
var hMinX = hole._tMinX[t] + odx;
|
||||
var hMaxX = hole._tMaxX[t] + odx;
|
||||
var hMinY = hole._tMinY[t] + ody;
|
||||
var hMaxY = hole._tMaxY[t] + ody;
|
||||
if (!AnyReaches(hMinX, hMinY, hMaxX, hMaxY))
|
||||
continue;
|
||||
|
||||
var next = new List<(double[], int)>();
|
||||
var next = spareList;
|
||||
next.Clear();
|
||||
for (var p = 0; p < pieces.Count; p++)
|
||||
{
|
||||
var (buf, pc) = pieces[p];
|
||||
var current = pieces[p];
|
||||
var buf = current.Buf;
|
||||
var pc = current.Count;
|
||||
|
||||
// Piece bbox (built-in uses <=: touching skips subtraction).
|
||||
var pMinX = double.MaxValue;
|
||||
var pMinY = double.MaxValue;
|
||||
var pMaxX = double.MinValue;
|
||||
var pMaxY = double.MinValue;
|
||||
for (var v = 0; v < pc; v++)
|
||||
{
|
||||
var px = buf[v * 2];
|
||||
var py = buf[v * 2 + 1];
|
||||
if (px < pMinX)
|
||||
pMinX = px;
|
||||
if (px > pMaxX)
|
||||
pMaxX = px;
|
||||
if (py < pMinY)
|
||||
pMinY = py;
|
||||
if (py > pMaxY)
|
||||
pMaxY = py;
|
||||
}
|
||||
if (pMaxX <= hMinX || hMaxX <= pMinX || pMaxY <= hMinY || hMaxY <= pMinY)
|
||||
if (current.MaxX <= hMinX || hMaxX <= current.MinX
|
||||
|| current.MaxY <= hMinY || hMaxY <= current.MinY)
|
||||
{
|
||||
if (next.Count >= MaxPieces)
|
||||
return false;
|
||||
next.Add((buf, pc));
|
||||
next.Add(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -363,11 +370,11 @@ namespace OpenNest.Geometry
|
||||
// outside of each edge survives as its own piece; the inside
|
||||
// remainder continues into the next edge. The remainder inside
|
||||
// all three edges is consumed (the hole ate it).
|
||||
var rem = AcquireBuffer();
|
||||
owned.Add(rem);
|
||||
Array.Copy(buf, rem, pc * 2);
|
||||
// The remainder reads the piece itself first, then alternates
|
||||
// between two scratch buffers; it is never kept.
|
||||
var rem = buf;
|
||||
var remCount = pc;
|
||||
var (tmp, insideBuf) = ScratchPair();
|
||||
var (tmp, insideBuf, spareBuf) = Scratch();
|
||||
for (var e = 0; e < 3 && remCount >= 3; e++)
|
||||
{
|
||||
var ei = e == 0 ? hole._ia[t] : e == 1 ? hole._ib[t] : hole._ic[t];
|
||||
@@ -388,20 +395,20 @@ namespace OpenNest.Geometry
|
||||
var keep = AcquireBuffer();
|
||||
owned.Add(keep);
|
||||
Array.Copy(tmp, keep, outCount * 2);
|
||||
next.Add((keep, outCount));
|
||||
next.Add(Measure(keep, outCount));
|
||||
}
|
||||
remCount =
|
||||
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, true, insideBuf);
|
||||
if (remCount >= MaxClipVertices)
|
||||
return false; // undecided
|
||||
Array.Copy(insideBuf, rem, remCount * 2);
|
||||
rem = insideBuf;
|
||||
(insideBuf, spareBuf) = (spareBuf, insideBuf);
|
||||
}
|
||||
// The inside-all-edges remainder is consumed by the hole: drop it.
|
||||
owned.Remove(rem);
|
||||
ReleaseBuffer(rem);
|
||||
if (owned.Remove(buf))
|
||||
ReleaseBuffer(buf);
|
||||
}
|
||||
spareList = pieces;
|
||||
pieces = next;
|
||||
}
|
||||
}
|
||||
@@ -413,8 +420,8 @@ namespace OpenNest.Geometry
|
||||
if (!SubtractOwner(this, adx, ady) || !SubtractOwner(other, bdx, bdy))
|
||||
return (true, true, false);
|
||||
|
||||
foreach (var (buf, pc) in pieces)
|
||||
if (pc >= 3 && TwiceArea(buf, pc) > areaFloor)
|
||||
foreach (var p in pieces)
|
||||
if (p.Count >= 3 && TwiceArea(p.Buf, p.Count) > areaFloor)
|
||||
return (true, false, true);
|
||||
return (true, false, false);
|
||||
}
|
||||
@@ -425,6 +432,31 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct Piece(double[] Buf, int Count, double MinX, double MinY,
|
||||
double MaxX, double MaxY);
|
||||
|
||||
private static Piece Measure(double[] buf, int count)
|
||||
{
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
for (var v = 0; v < count; v++)
|
||||
{
|
||||
var px = buf[v * 2];
|
||||
var py = buf[v * 2 + 1];
|
||||
if (px < minX)
|
||||
minX = px;
|
||||
if (px > maxX)
|
||||
maxX = px;
|
||||
if (py < minY)
|
||||
minY = py;
|
||||
if (py > maxY)
|
||||
maxY = py;
|
||||
}
|
||||
return new Piece(buf, count, minX, minY, maxX, maxY);
|
||||
}
|
||||
|
||||
/// <summary>Clip this' triangle against other's triangle; returns count into piece.</summary>
|
||||
private int ClipTriangle(
|
||||
int ta,
|
||||
|
||||
@@ -70,14 +70,21 @@ internal static class CutoutCirclePacker
|
||||
/// <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)
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
|
||||
Fill(frame, cutoutIndex, inserts, spacing, null, token);
|
||||
|
||||
/// <summary>Same proposal; a pre-pass passes its <paramref name="memo"/> to replay geometry
|
||||
/// reads and clearance checks it has already made.</summary>
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing, CutoutMemo? memo,
|
||||
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);
|
||||
var frameGeometry = Read(frame.Geometry);
|
||||
if (frameGeometry == null)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||
@@ -93,10 +100,10 @@ internal static class CutoutCirclePacker
|
||||
foreach (var part in inserts)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||
var geometry = Read(part.Geometry);
|
||||
if (geometry == null || part.Quantity <= 0)
|
||||
continue;
|
||||
var item = Item.Create(geometry);
|
||||
var item = memo?.ItemOf(geometry) ?? Item.Create(geometry);
|
||||
var rotations = part.Rotation.EnumerateAngles(maxSamples: 1);
|
||||
if (rotations.Count == 0 || item.Radius > wall)
|
||||
continue;
|
||||
@@ -127,18 +134,24 @@ internal static class CutoutCirclePacker
|
||||
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))
|
||||
if (!Clears(frameGeometry, framePose, geometry, pose))
|
||||
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)))
|
||||
&& !Clears(geometries[other.Disc.Part.Id], other.Pose, geometry, pose)))
|
||||
continue;
|
||||
accepted.Add((disc, center, pose));
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return accepted.Select(p => p.Pose).ToArray();
|
||||
|
||||
JobPartGeometry? Read(PartGeometrySnapshot snapshot) =>
|
||||
memo != null ? memo.Read(snapshot) : JobPartGeometry.TryRead(snapshot);
|
||||
|
||||
bool Clears(JobPartGeometry a, NestJobPlacement pa, JobPartGeometry b, NestJobPlacement pb) =>
|
||||
memo?.Clears(a, pa, b, pb, spacing) ?? NestLayoutCheck.Clears(a, pa, b, pb, spacing);
|
||||
}
|
||||
|
||||
/// <summary>Places each disc in order at its best feasible candidate; a disc with no
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.CirclePacking;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>Exact replays of pure cutout computations within one pre-pass, which asks the same
|
||||
/// questions for every frame instance and frame angle. A clear frame and insert cost
|
||||
/// <see cref="NestLayoutCheck.Clears"/> its worst case: every perimeter triangle pair, then every
|
||||
/// hole triangle. Not thread-safe; one instance per call.</summary>
|
||||
/// <remarks>Clearance keys hold both geometries by reference, their poses bit for bit, the
|
||||
/// argument order and the spacing, so a replay is the answer the check would give. Geometry is
|
||||
/// read once per snapshot; shared readers must clone before transforming, as they already do.</remarks>
|
||||
internal sealed class CutoutMemo
|
||||
{
|
||||
private readonly record struct Key(JobPartGeometry A, long AX, long AY, long ARotation,
|
||||
JobPartGeometry B, long BX, long BY, long BRotation, long Spacing);
|
||||
|
||||
private readonly Dictionary<Key, bool> clearances = new();
|
||||
private readonly Dictionary<PartGeometrySnapshot, JobPartGeometry?> geometries =
|
||||
new(ReferenceEqualityComparer.Instance);
|
||||
private readonly Dictionary<JobPartGeometry, Item> items = new();
|
||||
|
||||
internal JobPartGeometry? Read(PartGeometrySnapshot snapshot)
|
||||
{
|
||||
if (!geometries.TryGetValue(snapshot, out var geometry))
|
||||
{
|
||||
geometry = JobPartGeometry.TryRead(snapshot);
|
||||
geometries.Add(snapshot, geometry);
|
||||
}
|
||||
return geometry;
|
||||
}
|
||||
|
||||
internal Item ItemOf(JobPartGeometry geometry)
|
||||
{
|
||||
if (!items.TryGetValue(geometry, out var item))
|
||||
{
|
||||
item = Item.Create(geometry);
|
||||
items.Add(geometry, item);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
internal bool Clears(JobPartGeometry a, NestJobPlacement pa,
|
||||
JobPartGeometry b, NestJobPlacement pb, double spacing)
|
||||
{
|
||||
var key = new Key(a, Bits(pa.X), Bits(pa.Y), Bits(pa.Rotation),
|
||||
b, Bits(pb.X), Bits(pb.Y), Bits(pb.Rotation), Bits(spacing));
|
||||
if (!clearances.TryGetValue(key, out var clears))
|
||||
{
|
||||
clears = NestLayoutCheck.Clears(a, pa, b, pb, spacing);
|
||||
clearances.Add(key, clears);
|
||||
}
|
||||
return clears;
|
||||
}
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
}
|
||||
@@ -46,13 +46,14 @@ internal sealed class CutoutPipelinePrepass
|
||||
// One reservation vector works for every offered stock. Current jobs cannot
|
||||
// represent stock-conditional insert quantities; use the largest spacing.
|
||||
var spacing = original.Plates.Max(p => p.PartSpacing);
|
||||
var memo = new CutoutMemo();
|
||||
var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity);
|
||||
var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry));
|
||||
var geometry = original.Parts.ToDictionary(p => p.Id, p => memo.Read(p.Geometry));
|
||||
// Proxies join these as they are built, so a larger frame can take one as an insert.
|
||||
var offers = original.Parts.ToList();
|
||||
var requirements = original.Parts.ToDictionary(p => p.Id);
|
||||
var round = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
|
||||
&& Item.Create(g).FillRatio >= CutoutCirclePacker.MinFillRatio);
|
||||
&& memo.ItemOf(g).FillRatio >= CutoutCirclePacker.MinFillRatio);
|
||||
// An insert's outline cannot fit a hole of smaller area; bolt holes skip the searches.
|
||||
var outline = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
|
||||
? System.Math.Abs(g.Perimeter.Area()) : double.PositiveInfinity);
|
||||
@@ -109,7 +110,7 @@ internal sealed class CutoutPipelinePrepass
|
||||
token.ThrowIfCancellationRequested();
|
||||
var source = geometry[pose.PartId]!;
|
||||
if (!requirements[pose.PartId].Rotation.Allows(angle + pose.Rotation)
|
||||
|| local.Any(other => !NestLayoutCheck.Clears(
|
||||
|| local.Any(other => !memo.Clears(
|
||||
geometry[other.PartId]!, other, source, pose, spacing)))
|
||||
continue;
|
||||
local.Add(pose);
|
||||
@@ -164,13 +165,13 @@ internal sealed class CutoutPipelinePrepass
|
||||
IReadOnlyList<NestJobPlacement> poses = Array.Empty<NestJobPlacement>();
|
||||
if (nested.Length > 0)
|
||||
poses = CutoutCirclePacker.Fill(frame, hole,
|
||||
flat.Where(p => round[p.Id]).Concat(nested).ToArray(), spacing, token);
|
||||
flat.Where(p => round[p.Id]).Concat(nested).ToArray(), spacing, memo, token);
|
||||
// Seating every flat copy as a disc leaves the slower proposals nothing to beat.
|
||||
var flatIds = flat.Select(p => p.Id).ToHashSet(StringComparer.Ordinal);
|
||||
if (flat.Length == 0
|
||||
|| poses.Count(p => flatIds.Contains(p.PartId)) == flat.Sum(p => (long)p.Quantity))
|
||||
return poses;
|
||||
var router = CutoutRouter.Fill(frame, hole, flat, spacing, token);
|
||||
var router = CutoutRouter.Fill(frame, hole, flat, spacing, memo, token);
|
||||
return Weight(router) >= Weight(poses) ? router : poses;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,13 +22,25 @@ internal static class CutoutRouter
|
||||
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
|
||||
Fill(frame, cutoutIndex, inserts, spacing, null, token);
|
||||
Fill(frame, cutoutIndex, inserts, spacing, null, null, token);
|
||||
|
||||
/// <summary>Same proposal; a pre-pass passes its <paramref name="memo"/> so circle-packed
|
||||
/// holes replay geometry reads and clearance checks it has already made.</summary>
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing, CutoutMemo memo,
|
||||
CancellationToken token = default) =>
|
||||
Fill(frame, cutoutIndex, inserts, spacing, null, memo, token);
|
||||
|
||||
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
|
||||
/// after indexing; true marks a retained lattice seed, false a residual NFP or circle-packer pose.</summary>
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default)
|
||||
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default) =>
|
||||
Fill(frame, cutoutIndex, inserts, spacing, observeAccepted, null, token);
|
||||
|
||||
private static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||
Action<NestJobPlacement, bool>? observeAccepted, CutoutMemo? memo, CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(inserts);
|
||||
@@ -54,7 +66,7 @@ internal static class CutoutRouter
|
||||
// NFP searches never placed more there, and they are slow on round inserts, most of all
|
||||
// when demand exceeds what fits (every surplus copy is searched for and not found).
|
||||
if (CutoutCirclePacker.Applies(hole, inserts))
|
||||
return Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, token));
|
||||
return Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
|
||||
|
||||
var accepted = new List<NestJobPlacement>();
|
||||
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||
|
||||
@@ -276,7 +276,10 @@ plates even with invalid-result consent. Candidate evaluation progress is forwar
|
||||
with unverified proxy commitment counts zeroed; transformed proxy commits are hidden. Physical
|
||||
commits are reported only after physical validation. Preparation is bounded to 32 bundles
|
||||
and 16 base sweep-angle samples per frame (automatic quarter-turns are all retained);
|
||||
remaining copies stay ordinary requirements. Fixed legal proxy orientations are a
|
||||
remaining copies stay ordinary requirements. Within one preparation, geometry reads and
|
||||
`NestLayoutCheck.Clears` answers are replayed (`CutoutMemo`: same geometry, bit-identical poses),
|
||||
so repeated frame instances and frame angles do not repeat that check, its main cost; layouts are
|
||||
the same as without the replay. Fixed legal proxy orientations are a
|
||||
conservative search choice, not full engine choice among all legal orientations; unsupported
|
||||
bundles remain ordinary independent requirements.
|
||||
|
||||
|
||||
Reference in new issue
Block a user