using System.Runtime.CompilerServices;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Galaxy;
/// <summary>An aligned Galactic cube, reused across pans, rotations and camera distances.</summary>
public readonly record struct GalaxyQueryTile(int Level, long X, long Y, long Z)
{
public double Size => Math.ScaleB(1d, Level);
public Coordinates Centre => new((X + .5) * Size, (Y + .5) * Size, (Z + .5) * Size);
public double Radius => Size * .86602540378443864676;
public bool Contains(Coordinates p) => p.X >= X * Size && p.X < (X + 1) * Size
&& p.Y >= Y * Size && p.Y < (Y + 1) * Size && p.Z >= Z * Size && p.Z < (Z + 1) * Size;
public IEnumerable<GalaxyQueryTile> Children()
{
for (var x = 0; x < 2; x++) for (var y = 0; y < 2; y++) for (var z = 0; z < 2; z++)
yield return new(Level - 1, X * 2 + x, Y * 2 + y, Z * 2 + z);
}
}
public sealed record GalaxyStreamingOptions
{
public int MaximumCachedTiles { get; init; } = 2048;
public int MaximumCachedSystems { get; init; } = 200000;
public int MaximumQueuedTiles { get; init; } = 8192;
public int MinimumTileLevel { get; init; } = -4;
public double ViewMargin { get; init; } = 1.12;
public int UnverifiedCacheMilliseconds { get; init; } = 10000;
}
public sealed record GalaxyStreamUpdate(IReadOnlyList<GalacticSystem> Systems, int QueriedTiles, int CoveredTiles,
int PendingTiles, int UnverifiedTiles, int RefinedTiles, bool Finished, bool CoverageComplete,
bool FromCache, Coordinates QueryCentre, double QueryRadius);
/// <summary>Lazy near-to-far frustum coverage. Large nodes split before reaching the native 700 ly sphere API.</summary>
public sealed class GalaxyQueryPlan
{
private readonly GalaxyViewport viewport;
private readonly GalaxyStreamingOptions options;
private readonly PriorityQueue<GalaxyQueryTile, (double Priority, long Sequence)> queue = new();
private long sequence;
private GalaxyQueryTile? focusSeed;
private bool seedTaken;
public int OmittedTiles { get; private set; }
public int PendingTiles => queue.Count;
public GalaxyQueryPlan(GalaxyViewport viewport, GalaxyStreamingOptions? options = null)
{
this.viewport = viewport;
this.options = options ?? new();
if (this.options.MaximumQueuedTiles < 8 || this.options.MinimumTileLevel is < -4 or > 4
|| !double.IsFinite(this.options.ViewMargin) || this.options.ViewMargin < 1)
throw new ArgumentException("星图流式查询配置无效。");
var (min, max) = Bounds(viewport, this.options.ViewMargin);
var span = Math.Max(max.X - min.X, Math.Max(max.Y - min.Y, max.Z - min.Z));
if (span <= 0 || !double.IsFinite(span)) return;
// A small real all-mass sample around the crosshair appears first,
// while the surrounding frustum progressively fills and refines.
var seedLevel = Math.Clamp((int)Math.Floor(Math.Log2(Math.Max(.0625, Math.Min(viewport.HalfWidth, viewport.HalfHeight)))),
this.options.MinimumTileLevel, Math.Max(3,this.options.MinimumTileLevel));
var seedSize = Math.ScaleB(1d, seedLevel);
focusSeed = new(seedLevel, (long)Math.Floor(viewport.Focus.X / seedSize), (long)Math.Floor(viewport.Focus.Y / seedSize), (long)Math.Floor(viewport.Focus.Z / seedSize));
var level = Math.Clamp((int)Math.Ceiling(Math.Log2(span)), this.options.MinimumTileLevel, 19);
var size = Math.ScaleB(1d, level);
for (var x = (long)Math.Floor(min.X / size); x <= (long)Math.Floor(max.X / size); x++)
for (var y = (long)Math.Floor(min.Y / size); y <= (long)Math.Floor(max.Y / size); y++)
for (var z = (long)Math.Floor(min.Z / size); z <= (long)Math.Floor(max.Z / size); z++) Enqueue(new(level, x, y, z));
}
public bool TryTake(out GalaxyQueryTile tile)
{
if (!seedTaken && focusSeed is { } seed)
{
seedTaken = true;
tile = seed;
return true;
}
while (queue.TryDequeue(out tile, out _))
{
if (focusSeed == tile) continue;
if (tile.Radius <= 700) return true;
Subdivide(tile);
}
tile = default;
return false;
}
public bool Subdivide(GalaxyQueryTile tile)
{
if (tile.Level <= options.MinimumTileLevel) return false;
foreach (var child in tile.Children()) Enqueue(child);
return true;
}
private void Enqueue(GalaxyQueryTile tile)
{
// The aligned octree gives each node exactly one parent; no historical
// visited set is needed as a long-running view progressively refines.
if (!Intersects(viewport, tile, options.ViewMargin)) return;
if (queue.Count >= options.MaximumQueuedTiles) { OmittedTiles++; return; }
queue.Enqueue(tile, (Priority(tile), sequence++));
}
private double Priority(GalaxyQueryTile tile)
{
var c = tile.Centre;
if (viewport.Camera is not { } camera) return c.DistanceTo(viewport.Focus) / Math.Max(1, viewport.Radius) + (9 - tile.Level) * .02;
var delta = new Vector3d(c.X - camera.Eye.X, c.Y - camera.Eye.Y, c.Z - camera.Eye.Z);
var depth = Vector3d.Dot(delta, camera.Forward);
var bucket = Math.Floor(Math.Max(0, Math.Abs(depth - camera.Distance) - tile.Radius) / Math.Max(1e-9, camera.Distance * .5));
var side = Math.Sqrt(Math.Pow(Vector3d.Dot(delta, camera.Right), 2) + Math.Pow(Vector3d.Dot(delta, camera.Up), 2));
return bucket * 100 + (9 - tile.Level) * .05 + Math.Max(0, side - tile.Radius) / Math.Max(camera.Distance, tile.Radius);
}
public static bool Intersects(GalaxyViewport viewport, GalaxyQueryTile tile, double margin = 1)
{
var c = tile.Centre; var half = tile.Size / 2;
static double Support(Vector3d n, double h) => (Math.Abs(n.X) + Math.Abs(n.Y) + Math.Abs(n.Z)) * h;
if (viewport.Camera is { } camera)
{
var delta = new Vector3d(c.X - camera.Eye.X, c.Y - camera.Eye.Y, c.Z - camera.Eye.Z);
var depth = Vector3d.Dot(delta, camera.Forward); var reach = Support(camera.Forward, half);
if (depth + reach < camera.Near || depth - reach > camera.QueryFar * margin) return false;
var tx = camera.Width / (2 * camera.FocalLength) * margin;
var ty = camera.Height / (2 * camera.FocalLength) * margin;
foreach (var normal in new[] { camera.Forward * tx + camera.Right, camera.Forward * tx - camera.Right,
camera.Forward * ty + camera.Up, camera.Forward * ty - camera.Up })
if (Vector3d.Dot(delta, normal) + Support(normal, half) < 0) return false;
return true;
}
var offset = new Vector3d(c.X - viewport.Focus.X, c.Y - viewport.Focus.Y, c.Z - viewport.Focus.Z);
return Math.Abs(Vector3d.Dot(offset, viewport.Right)) <= viewport.HalfWidth * margin + Support(viewport.Right, half)
&& Math.Abs(Vector3d.Dot(offset, viewport.Up)) <= viewport.HalfHeight * margin + Support(viewport.Up, half)
&& Math.Abs(Vector3d.Dot(offset, viewport.Depth)) <= viewport.HalfDepth * margin + Support(viewport.Depth, half);
}
private static (Coordinates Min, Coordinates Max) Bounds(GalaxyViewport viewport, double margin)
{
var points = new List<Coordinates>(8);
if (viewport.Camera is { } camera)
{
foreach (var depth in new[] { camera.Near, camera.QueryFar * margin })
foreach (var x in new[] { -1, 1 }) foreach (var y in new[] { -1, 1 })
{
var offset = camera.Forward * depth + camera.Right * (x * camera.Width / (2 * camera.FocalLength) * depth * margin)
+ camera.Up * (y * camera.Height / (2 * camera.FocalLength) * depth * margin);
points.Add(new(camera.Eye.X + offset.X, camera.Eye.Y + offset.Y, camera.Eye.Z + offset.Z));
}
}
else foreach (var x in new[] { -1, 1 }) foreach (var y in new[] { -1, 1 }) foreach (var z in new[] { -1, 1 })
{
var offset = viewport.Right * (x * viewport.HalfWidth * margin) + viewport.Up * (y * viewport.HalfHeight * margin) + viewport.Depth * (z * viewport.HalfDepth * margin);
points.Add(new(viewport.Focus.X + offset.X, viewport.Focus.Y + offset.Y, viewport.Focus.Z + offset.Z));
}
return (new(Math.Max(-200000, points.Min(p => p.X)), Math.Max(-200000, points.Min(p => p.Y)), Math.Max(-200000, points.Min(p => p.Z))),
new(Math.Min(200000, points.Max(p => p.X)), Math.Min(200000, points.Max(p => p.Y)), Math.Min(200000, points.Max(p => p.Z))));
}
}
/// <summary>One latest-view worker with reusable exact tiles and progressive partial-to-dense refinement.</summary>
public sealed class GalaxyStreamingSession : IDisposable
{
private sealed record CacheEntry(IReadOnlyList<GalacticSystem> Systems, bool Complete, bool Refine, long Used,long ReceivedAt);
private sealed record QueryWork(GalaxyQueryTile Tile, CancellationTokenSource Stop);
private readonly GalaxyStreamingOptions options;
private readonly Dictionary<GalaxyQueryTile, CacheEntry> cache = [];
private readonly SemaphoreSlim gate = new(1, 1);
private readonly object cacheLock = new();
private readonly object queryLock = new();
private CancellationTokenSource? active;
private QueryWork? currentQuery;
private long generation, clock, cacheEpoch;
public int CachedTiles { get { lock (cacheLock) return cache.Count; } }
public GalaxyStreamingSession(GalaxyStreamingOptions? options = null)
{
this.options = options ?? new();
if (this.options.MaximumCachedTiles < 1 || this.options.MaximumCachedSystems < 1 || this.options.UnverifiedCacheMilliseconds < 0) throw new ArgumentException("星图缓存配置无效。");
}
public async IAsyncEnumerable<GalaxyStreamUpdate> StreamAsync(GalaxyViewport viewport,
Func<Coordinates, double, CancellationToken, Task<GalaxyRegionResult>> query,
[EnumeratorCancellation] CancellationToken token = default)
{
var version = Interlocked.Increment(ref generation);
using var stop = CancellationTokenSource.CreateLinkedTokenSource(token);
lock (queryLock)
if (currentQuery is { } running && !GalaxyQueryPlan.Intersects(viewport, running.Tile, options.ViewMargin)) Cancel(running.Stop);
Cancel(Interlocked.Exchange(ref active, stop));
await gate.WaitAsync(stop.Token);
try
{
var plan = new GalaxyQueryPlan(viewport, options);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var queried = 0; var covered = 0; var unverified = 0; var refined = 0;
while (plan.TryTake(out var tile))
{
stop.Token.ThrowIfCancellationRequested();
if (version != Volatile.Read(ref generation)) throw new OperationCanceledException(stop.Token);
CacheEntry? entry;
lock (cacheLock)
{
cache.TryGetValue(tile, out entry);
if(entry is {Complete:false,Refine:true}&&tile.Level<=options.MinimumTileLevel)
{
// A bounded terminal failure is not durable coverage.
// A subsequent view/retry must be able to ask again.
cache.Remove(tile);entry=null;
}
// A legacy reply may have observed an unloaded cell. Do not
// freeze unverified emptiness for the entire page session.
if(entry is {Complete:false,Refine:false}&&Environment.TickCount64-entry.ReceivedAt>=options.UnverifiedCacheMilliseconds)
{cache.Remove(tile);entry=null;}
if (entry is not null) cache[tile] = entry = entry with { Used = ++clock };
}
var cached = entry is not null;
if (entry is null)
{
var epoch = Volatile.Read(ref cacheEpoch);
using var queryStop = new CancellationTokenSource();
var work = new QueryWork(tile, queryStop);
lock (queryLock) currentQuery = work;
// A new overlapping view can use the same accepted tile.
// Page cancellation still retires it; superseded camera
// enumerations cannot publish it into an obsolete view.
using var cancellation = stop.Token.Register(() =>
{ if (version == Volatile.Read(ref generation)) Cancel(queryStop); });
GalaxyRegionResult result;
try { result = await query(tile.Centre, tile.Radius, queryStop.Token); }
finally { lock (queryLock) if (ReferenceEquals(currentQuery, work)) currentQuery = null; }
queryStop.Token.ThrowIfCancellationRequested();
queried++;
var systems = result.Systems.Where(s => s.Position.IsValid && tile.Contains(s.Position)).ToArray();
// Legacy replies cannot prove failed-cell coverage. Small, uncapped
// all-mass replies may be displayed/reused but remain unverified.
var legacyTerminal = !result.Complete && !result.NeedsSubdivision && result.FailedCells == 0 && tile.Radius <= 20;
entry = new(systems, result.IsFullyCovered, !result.IsFullyCovered && !legacyTerminal, ++clock,Environment.TickCount64);
if (epoch == Volatile.Read(ref cacheEpoch)) Remember(tile, entry);
stop.Token.ThrowIfCancellationRequested();
if (version != Volatile.Read(ref generation)) throw new OperationCanceledException(stop.Token);
}
if (entry.Complete) covered++;
else if (entry.Refine && plan.Subdivide(tile)) refined++;
else unverified++;
var visible = entry.Systems.Where(s => viewport.Contains(s.Position, options.ViewMargin))
.Where(s => seen.Add(s.SystemAddress is > 0 ? "#" + s.SystemAddress : s.Name)).ToArray();
yield return new(visible, queried, covered, plan.PendingTiles + plan.OmittedTiles, unverified, refined,
false, false, cached, tile.Centre, tile.Radius);
if (cached) await Task.Yield();
}
yield return new([], queried, covered, plan.OmittedTiles, unverified, refined, true,
plan.OmittedTiles == 0 && unverified == 0, false, viewport.Focus, 0);
}
finally
{
Interlocked.CompareExchange(ref active, null, stop);
gate.Release();
}
}
private void Remember(GalaxyQueryTile tile, CacheEntry entry)
{
lock (cacheLock)
{
cache[tile] = entry;
var rows = cache.Values.Sum(e => (long)e.Systems.Count);
while (cache.Count > options.MaximumCachedTiles || rows > options.MaximumCachedSystems)
{
var oldest = cache.MinBy(e => e.Value.Used);
rows -= oldest.Value.Systems.Count;
cache.Remove(oldest.Key);
}
}
}
private static void Cancel(CancellationTokenSource? source) { try { source?.Cancel(); } catch (ObjectDisposedException) { } }
public void Clear()
{
Interlocked.Increment(ref generation);
Interlocked.Increment(ref cacheEpoch);
Cancel(Interlocked.Exchange(ref active, null));
lock (queryLock) if (currentQuery is { } running) Cancel(running.Stop);
lock (cacheLock) cache.Clear();
}
public void Dispose() => Clear();
}
using System.Runtime.CompilerServices;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Galaxy;
/// <summary>An aligned Galactic cube, reused across pans, rotations and camera distances.</summary>
public readonly record struct GalaxyQueryTile(int Level, long X, long Y, long Z)
{
public double Size => Math.ScaleB(1d, Level);
public Coordinates Centre => new((X + .5) * Size, (Y + .5) * Size, (Z + .5) * Size);
public double Radius => Size * .86602540378443864676;
public bool Contains(Coordinates p) => p.X >= X * Size && p.X < (X + 1) * Size
&& p.Y >= Y * Size && p.Y < (Y + 1) * Size && p.Z >= Z * Size && p.Z < (Z + 1) * Size;
public IEnumerable<GalaxyQueryTile> Children()
{
for (var x = 0; x < 2; x++) for (var y = 0; y < 2; y++) for (var z = 0; z < 2; z++)
yield return new(Level - 1, X * 2 + x, Y * 2 + y, Z * 2 + z);
}
}
public sealed record GalaxyStreamingOptions
{
public int MaximumCachedTiles { get; init; } = 2048;
public int MaximumCachedSystems { get; init; } = 200000;
public int MaximumQueuedTiles { get; init; } = 8192;
public int MinimumTileLevel { get; init; } = -4;
public double ViewMargin { get; init; } = 1.12;
public int UnverifiedCacheMilliseconds { get; init; } = 10000;
}
public sealed record GalaxyStreamUpdate(IReadOnlyList<GalacticSystem> Systems, int QueriedTiles, int CoveredTiles,
int PendingTiles, int UnverifiedTiles, int RefinedTiles, bool Finished, bool CoverageComplete,
bool FromCache, Coordinates QueryCentre, double QueryRadius);
/// <summary>Lazy near-to-far frustum coverage. Large nodes split before reaching the native 700 ly sphere API.</summary>
public sealed class GalaxyQueryPlan
{
private readonly GalaxyViewport viewport;
private readonly GalaxyStreamingOptions options;
private readonly PriorityQueue<GalaxyQueryTile, (double Priority, long Sequence)> queue = new();
private long sequence;
private GalaxyQueryTile? focusSeed;
private bool seedTaken;
public int OmittedTiles { get; private set; }
public int PendingTiles => queue.Count;
public GalaxyQueryPlan(GalaxyViewport viewport, GalaxyStreamingOptions? options = null)
{
this.viewport = viewport;
this.options = options ?? new();
if (this.options.MaximumQueuedTiles < 8 || this.options.MinimumTileLevel is < -4 or > 4
|| !double.IsFinite(this.options.ViewMargin) || this.options.ViewMargin < 1)
throw new ArgumentException("星图流式查询配置无效。");
var (min, max) = Bounds(viewport, this.options.ViewMargin);
var span = Math.Max(max.X - min.X, Math.Max(max.Y - min.Y, max.Z - min.Z));
if (span <= 0 || !double.IsFinite(span)) return;
// A small real all-mass sample around the crosshair appears first,
// while the surrounding frustum progressively fills and refines.
var seedLevel = Math.Clamp((int)Math.Floor(Math.Log2(Math.Max(.0625, Math.Min(viewport.HalfWidth, viewport.HalfHeight)))),
this.options.MinimumTileLevel, Math.Max(3,this.options.MinimumTileLevel));
var seedSize = Math.ScaleB(1d, seedLevel);
focusSeed = new(seedLevel, (long)Math.Floor(viewport.Focus.X / seedSize), (long)Math.Floor(viewport.Focus.Y / seedSize), (long)Math.Floor(viewport.Focus.Z / seedSize));
var level = Math.Clamp((int)Math.Ceiling(Math.Log2(span)), this.options.MinimumTileLevel, 19);
var size = Math.ScaleB(1d, level);
for (var x = (long)Math.Floor(min.X / size); x <= (long)Math.Floor(max.X / size); x++)
for (var y = (long)Math.Floor(min.Y / size); y <= (long)Math.Floor(max.Y / size); y++)
for (var z = (long)Math.Floor(min.Z / size); z <= (long)Math.Floor(max.Z / size); z++) Enqueue(new(level, x, y, z));
}
public bool TryTake(out GalaxyQueryTile tile)
{
if (!seedTaken && focusSeed is { } seed)
{
seedTaken = true;
tile = seed;
return true;
}
while (queue.TryDequeue(out tile, out _))
{
if (focusSeed == tile) continue;
if (tile.Radius <= 700) return true;
Subdivide(tile);
}
tile = default;
return false;
}
public bool Subdivide(GalaxyQueryTile tile)
{
if (tile.Level <= options.MinimumTileLevel) return false;
foreach (var child in tile.Children()) Enqueue(child);
return true;
}
private void Enqueue(GalaxyQueryTile tile)
{
// The aligned octree gives each node exactly one parent; no historical
// visited set is needed as a long-running view progressively refines.
if (!Intersects(viewport, tile, options.ViewMargin)) return;
if (queue.Count >= options.MaximumQueuedTiles) { OmittedTiles++; return; }
queue.Enqueue(tile, (Priority(tile), sequence++));
}
private double Priority(GalaxyQueryTile tile)
{
var c = tile.Centre;
if (viewport.Camera is not { } camera) return c.DistanceTo(viewport.Focus) / Math.Max(1, viewport.Radius) + (9 - tile.Level) * .02;
var delta = new Vector3d(c.X - camera.Eye.X, c.Y - camera.Eye.Y, c.Z - camera.Eye.Z);
var depth = Vector3d.Dot(delta, camera.Forward);
var bucket = Math.Floor(Math.Max(0, Math.Abs(depth - camera.Distance) - tile.Radius) / Math.Max(1e-9, camera.Distance * .5));
var side = Math.Sqrt(Math.Pow(Vector3d.Dot(delta, camera.Right), 2) + Math.Pow(Vector3d.Dot(delta, camera.Up), 2));
return bucket * 100 + (9 - tile.Level) * .05 + Math.Max(0, side - tile.Radius) / Math.Max(camera.Distance, tile.Radius);
}
public static bool Intersects(GalaxyViewport viewport, GalaxyQueryTile tile, double margin = 1)
{
var c = tile.Centre; var half = tile.Size / 2;
static double Support(Vector3d n, double h) => (Math.Abs(n.X) + Math.Abs(n.Y) + Math.Abs(n.Z)) * h;
if (viewport.Camera is { } camera)
{
var delta = new Vector3d(c.X - camera.Eye.X, c.Y - camera.Eye.Y, c.Z - camera.Eye.Z);
var depth = Vector3d.Dot(delta, camera.Forward); var reach = Support(camera.Forward, half);
if (depth + reach < camera.Near || depth - reach > camera.QueryFar * margin) return false;
var tx = camera.Width / (2 * camera.FocalLength) * margin;
var ty = camera.Height / (2 * camera.FocalLength) * margin;
foreach (var normal in new[] { camera.Forward * tx + camera.Right, camera.Forward * tx - camera.Right,
camera.Forward * ty + camera.Up, camera.Forward * ty - camera.Up })
if (Vector3d.Dot(delta, normal) + Support(normal, half) < 0) return false;
return true;
}
var offset = new Vector3d(c.X - viewport.Focus.X, c.Y - viewport.Focus.Y, c.Z - viewport.Focus.Z);
return Math.Abs(Vector3d.Dot(offset, viewport.Right)) <= viewport.HalfWidth * margin + Support(viewport.Right, half)
&& Math.Abs(Vector3d.Dot(offset, viewport.Up)) <= viewport.HalfHeight * margin + Support(viewport.Up, half)
&& Math.Abs(Vector3d.Dot(offset, viewport.Depth)) <= viewport.HalfDepth * margin + Support(viewport.Depth, half);
}
private static (Coordinates Min, Coordinates Max) Bounds(GalaxyViewport viewport, double margin)
{
var points = new List<Coordinates>(8);
if (viewport.Camera is { } camera)
{
foreach (var depth in new[] { camera.Near, camera.QueryFar * margin })
foreach (var x in new[] { -1, 1 }) foreach (var y in new[] { -1, 1 })
{
var offset = camera.Forward * depth + camera.Right * (x * camera.Width / (2 * camera.FocalLength) * depth * margin)
+ camera.Up * (y * camera.Height / (2 * camera.FocalLength) * depth * margin);
points.Add(new(camera.Eye.X + offset.X, camera.Eye.Y + offset.Y, camera.Eye.Z + offset.Z));
}
}
else foreach (var x in new[] { -1, 1 }) foreach (var y in new[] { -1, 1 }) foreach (var z in new[] { -1, 1 })
{
var offset = viewport.Right * (x * viewport.HalfWidth * margin) + viewport.Up * (y * viewport.HalfHeight * margin) + viewport.Depth * (z * viewport.HalfDepth * margin);
points.Add(new(viewport.Focus.X + offset.X, viewport.Focus.Y + offset.Y, viewport.Focus.Z + offset.Z));
}
return (new(Math.Max(-200000, points.Min(p => p.X)), Math.Max(-200000, points.Min(p => p.Y)), Math.Max(-200000, points.Min(p => p.Z))),
new(Math.Min(200000, points.Max(p => p.X)), Math.Min(200000, points.Max(p => p.Y)), Math.Min(200000, points.Max(p => p.Z))));
}
}
/// <summary>One latest-view worker with reusable exact tiles and progressive partial-to-dense refinement.</summary>
public sealed class GalaxyStreamingSession : IDisposable
{
private sealed record CacheEntry(IReadOnlyList<GalacticSystem> Systems, bool Complete, bool Refine, long Used,long ReceivedAt);
private sealed record QueryWork(GalaxyQueryTile Tile, CancellationTokenSource Stop);
private readonly GalaxyStreamingOptions options;
private readonly Dictionary<GalaxyQueryTile, CacheEntry> cache = [];
private readonly SemaphoreSlim gate = new(1, 1);
private readonly object cacheLock = new();
private readonly object queryLock = new();
private CancellationTokenSource? active;
private QueryWork? currentQuery;
private long generation, clock, cacheEpoch;
public int CachedTiles { get { lock (cacheLock) return cache.Count; } }
public GalaxyStreamingSession(GalaxyStreamingOptions? options = null)
{
this.options = options ?? new();
if (this.options.MaximumCachedTiles < 1 || this.options.MaximumCachedSystems < 1 || this.options.UnverifiedCacheMilliseconds < 0) throw new ArgumentException("星图缓存配置无效。");
}
public async IAsyncEnumerable<GalaxyStreamUpdate> StreamAsync(GalaxyViewport viewport,
Func<Coordinates, double, CancellationToken, Task<GalaxyRegionResult>> query,
[EnumeratorCancellation] CancellationToken token = default)
{
var version = Interlocked.Increment(ref generation);
using var stop = CancellationTokenSource.CreateLinkedTokenSource(token);
lock (queryLock)
if (currentQuery is { } running && !GalaxyQueryPlan.Intersects(viewport, running.Tile, options.ViewMargin)) Cancel(running.Stop);
Cancel(Interlocked.Exchange(ref active, stop));
await gate.WaitAsync(stop.Token);
try
{
var plan = new GalaxyQueryPlan(viewport, options);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var queried = 0; var covered = 0; var unverified = 0; var refined = 0;
while (plan.TryTake(out var tile))
{
stop.Token.ThrowIfCancellationRequested();
if (version != Volatile.Read(ref generation)) throw new OperationCanceledException(stop.Token);
CacheEntry? entry;
lock (cacheLock)
{
cache.TryGetValue(tile, out entry);
if(entry is {Complete:false,Refine:true}&&tile.Level<=options.MinimumTileLevel)
{
// A bounded terminal failure is not durable coverage.
// A subsequent view/retry must be able to ask again.
cache.Remove(tile);entry=null;
}
// A legacy reply may have observed an unloaded cell. Do not
// freeze unverified emptiness for the entire page session.
if(entry is {Complete:false,Refine:false}&&Environment.TickCount64-entry.ReceivedAt>=options.UnverifiedCacheMilliseconds)
{cache.Remove(tile);entry=null;}
if (entry is not null) cache[tile] = entry = entry with { Used = ++clock };
}
var cached = entry is not null;
if (entry is null)
{
var epoch = Volatile.Read(ref cacheEpoch);
using var queryStop = new CancellationTokenSource();
var work = new QueryWork(tile, queryStop);
lock (queryLock) currentQuery = work;
// A new overlapping view can use the same accepted tile.
// Page cancellation still retires it; superseded camera
// enumerations cannot publish it into an obsolete view.
using var cancellation = stop.Token.Register(() =>
{ if (version == Volatile.Read(ref generation)) Cancel(queryStop); });
GalaxyRegionResult result;
try { result = await query(tile.Centre, tile.Radius, queryStop.Token); }
finally { lock (queryLock) if (ReferenceEquals(currentQuery, work)) currentQuery = null; }
queryStop.Token.ThrowIfCancellationRequested();
queried++;
var systems = result.Systems.Where(s => s.Position.IsValid && tile.Contains(s.Position)).ToArray();
// Legacy replies cannot prove failed-cell coverage. Small, uncapped
// all-mass replies may be displayed/reused but remain unverified.
var legacyTerminal = !result.Complete && !result.NeedsSubdivision && result.FailedCells == 0 && tile.Radius <= 20;
entry = new(systems, result.IsFullyCovered, !result.IsFullyCovered && !legacyTerminal, ++clock,Environment.TickCount64);
if (epoch == Volatile.Read(ref cacheEpoch)) Remember(tile, entry);
stop.Token.ThrowIfCancellationRequested();
if (version != Volatile.Read(ref generation)) throw new OperationCanceledException(stop.Token);
}
if (entry.Complete) covered++;
else if (entry.Refine && plan.Subdivide(tile)) refined++;
else unverified++;
var visible = entry.Systems.Where(s => viewport.Contains(s.Position, options.ViewMargin))
.Where(s => seen.Add(s.SystemAddress is > 0 ? "#" + s.SystemAddress : s.Name)).ToArray();
yield return new(visible, queried, covered, plan.PendingTiles + plan.OmittedTiles, unverified, refined,
false, false, cached, tile.Centre, tile.Radius);
if (cached) await Task.Yield();
}
yield return new([], queried, covered, plan.OmittedTiles, unverified, refined, true,
plan.OmittedTiles == 0 && unverified == 0, false, viewport.Focus, 0);
}
finally
{
Interlocked.CompareExchange(ref active, null, stop);
gate.Release();
}
}
private void Remember(GalaxyQueryTile tile, CacheEntry entry)
{
lock (cacheLock)
{
cache[tile] = entry;
var rows = cache.Values.Sum(e => (long)e.Systems.Count);
while (cache.Count > options.MaximumCachedTiles || rows > options.MaximumCachedSystems)
{
var oldest = cache.MinBy(e => e.Value.Used);
rows -= oldest.Value.Systems.Count;
cache.Remove(oldest.Key);
}
}
}
private static void Cancel(CancellationTokenSource? source) { try { source?.Cancel(); } catch (ObjectDisposedException) { } }
public void Clear()
{
Interlocked.Increment(ref generation);
Interlocked.Increment(ref cacheEpoch);
Cancel(Interlocked.Exchange(ref active, null));
lock (queryLock) if (currentQuery is { } running) Cancel(running.Stop);
lock (cacheLock) cache.Clear();
}
public void Dispose() => Clear();
}