Compare commits

...

34 Commits

Author SHA1 Message Date
cake
43d2b31eda Merge branch 'just-experimenting' into test-abel 2026-01-05 20:36:47 +01:00
17dd8a307b fix access violation 2026-01-06 00:42:44 +09:00
24d0c38f59 animated emotes and fix clipper in chat window 2026-01-05 22:43:50 +09:00
cake
5920622b9a Merge 2.0.3 into branch 2026-01-05 01:44:38 +01:00
cake
d357e37c65 Merge abel stuff 2026-01-05 01:41:03 +01:00
4da2548e03 just misc 2026-01-05 07:48:14 +09:00
cake
2d526bcfbf Merge branch 'cake-attempts-2.0.3' into test-abel 2026-01-04 00:32:13 +01:00
defnotken
4664071eb3 Merge branch 'cake-attempts-2.0.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into cake-attempts-2.0.3 2026-01-03 17:28:25 -06:00
defnotken
11099c05ff Throwing analysis in background task so dalamud can breath 2026-01-03 17:28:05 -06:00
cake
57a076ae77 test-abel-cake-changes 2026-01-03 22:45:55 +01:00
cake
6af61451dc Fixed naming of setting 2026-01-03 16:32:39 +01:00
cake
02d091eefa Added more loose matching options, fixed some race issues 2026-01-03 15:59:10 +01:00
cake
e41a7149c5 Refactored many parts, added settings for detection 2026-01-03 14:58:54 +01:00
b6b9c81a57 tighten the check 2026-01-03 13:53:55 +09:00
a824d94ffe slight adjustments and fixes 2026-01-03 10:20:07 +09:00
cake
e16ddb0a1d change log to trace 2026-01-02 19:29:50 +01:00
4b13dfe8d4 skip decimation for direct pairs and make it a toggle in settings 2026-01-03 03:19:10 +09:00
cake
ba26edc33c Merge branch 'cake-attempts-2.0.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into cake-attempts-2.0.3 2026-01-02 18:31:14 +01:00
cake
14c4c1d669 Added caching in the playerdata factory, refactored 2026-01-02 18:30:37 +01:00
defnotken
e8c157d8ac Creating temp havok file to not crash the analyzer 2026-01-02 10:23:32 -06:00
defnotken
2af0b5774b Merge branch 'cake-attempts-2.0.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into cake-attempts-2.0.3 2026-01-02 09:29:22 -06:00
defnotken
bb365442cf Maybe? 2026-01-02 09:29:04 -06:00
cake
277d368f83 Adjustments in PAP handling. 2026-01-02 07:43:30 +01:00
defnotken
3487891185 Testing asyncing the transient task 2026-01-01 22:23:46 -06:00
defnotken
96f8d33cde Fixing dls ui and fixed cancel cache validation breaking menu. 2026-01-01 21:57:48 -06:00
cake
a033d4d4d8 Merge conflict 2026-01-02 04:01:44 +01:00
defnotken
7d2a914c84 Queue File compacting to let workers download as priority, Offload decompression task 2026-01-01 20:57:37 -06:00
cake
d6fe09ba8e Testing PAP handling changes. 2026-01-02 03:56:59 +01:00
979443d9bb *atomize* download status! 2026-01-02 11:30:36 +09:00
92cb861710 readjust cache clean up and add keep originals setting for models 2026-01-02 11:04:15 +09:00
aeed8503c2 highly experimental runtime model decimation + file cache adjustment to clean up processed file copies 2026-01-02 09:54:34 +09:00
44bb53023e improvements to data analysis ui 2026-01-01 13:04:52 +09:00
05b91ed243 Merge remote-tracking branch 'origin/2.0.3' into just-experimenting 2026-01-01 10:04:38 +09:00
cfc5c1e0f3 "improving" pair handler clean up and some other stuff 2026-01-01 00:33:24 +09:00
70 changed files with 14188 additions and 967 deletions

View File

@@ -103,6 +103,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
}
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime);
private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
@@ -441,116 +442,40 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder);
}
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
.Select(f => new FileInfo(f))
.OrderBy(f => f.LastAccessTime)
.ToList();
var cacheFolder = _configService.Current.CacheFolder;
var candidates = new List<CacheEvictionCandidate>();
long totalSize = 0;
foreach (var f in files)
{
token.ThrowIfCancellationRequested();
try
{
long size = 0;
if (!isWine)
{
try
{
size = _fileCompactor.GetFileSizeOnDisk(f);
}
catch (Exception ex)
{
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName);
size = f.Length;
}
}
else
{
size = f.Length;
}
totalSize += size;
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
}
}
totalSize += AddFolderCandidates(cacheFolder, candidates, token, isWine);
totalSize += AddFolderCandidates(Path.Combine(cacheFolder, "downscaled"), candidates, token, isWine);
totalSize += AddFolderCandidates(Path.Combine(cacheFolder, "decimated"), candidates, token, isWine);
FileCacheSize = totalSize;
if (Directory.Exists(_configService.Current.CacheFolder + "/downscaled"))
{
var filesDownscaled = Directory.EnumerateFiles(_configService.Current.CacheFolder + "/downscaled").Select(f => new FileInfo(f)).OrderBy(f => f.LastAccessTime).ToList();
long totalSizeDownscaled = 0;
foreach (var f in filesDownscaled)
{
token.ThrowIfCancellationRequested();
try
{
long size = 0;
if (!isWine)
{
try
{
size = _fileCompactor.GetFileSizeOnDisk(f);
}
catch (Exception ex)
{
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName);
size = f.Length;
}
}
else
{
size = f.Length;
}
totalSizeDownscaled += size;
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
}
}
FileCacheSize = (totalSize + totalSizeDownscaled);
}
else
{
FileCacheSize = totalSize;
}
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
if (FileCacheSize < maxCacheInBytes)
return;
var maxCacheBuffer = maxCacheInBytes * 0.05d;
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0)
candidates.Sort(static (a, b) => a.LastAccessTime.CompareTo(b.LastAccessTime));
var evictionTarget = maxCacheInBytes - (long)maxCacheBuffer;
var index = 0;
while (FileCacheSize > evictionTarget && index < candidates.Count)
{
var oldestFile = files[0];
var oldestFile = candidates[index];
try
{
long fileSize = oldestFile.Length;
File.Delete(oldestFile.FullName);
FileCacheSize -= fileSize;
EvictCacheCandidate(oldestFile, cacheFolder);
FileCacheSize -= oldestFile.Size;
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullName);
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullPath);
}
files.RemoveAt(0);
index++;
}
}
@@ -559,6 +484,114 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
HaltScanLocks.Clear();
}
private long AddFolderCandidates(string directory, List<CacheEvictionCandidate> candidates, CancellationToken token, bool isWine)
{
if (!Directory.Exists(directory))
{
return 0;
}
long totalSize = 0;
foreach (var path in Directory.EnumerateFiles(directory))
{
token.ThrowIfCancellationRequested();
try
{
var file = new FileInfo(path);
var size = GetFileSizeOnDisk(file, isWine);
totalSize += size;
candidates.Add(new CacheEvictionCandidate(file.FullName, size, file.LastAccessTime));
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Error getting size for {file}", path);
}
}
return totalSize;
}
private long GetFileSizeOnDisk(FileInfo file, bool isWine)
{
if (isWine)
{
return file.Length;
}
try
{
return _fileCompactor.GetFileSizeOnDisk(file);
}
catch (Exception ex)
{
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", file.FullName);
return file.Length;
}
}
private void EvictCacheCandidate(CacheEvictionCandidate candidate, string cacheFolder)
{
if (TryGetCacheHashAndPrefixedPath(candidate.FullPath, cacheFolder, out var hash, out var prefixedPath))
{
_fileDbManager.RemoveHashedFile(hash, prefixedPath);
}
try
{
if (File.Exists(candidate.FullPath))
{
File.Delete(candidate.FullPath);
}
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Failed to delete old file {file}", candidate.FullPath);
}
}
private static bool TryGetCacheHashAndPrefixedPath(string filePath, string cacheFolder, out string hash, out string prefixedPath)
{
hash = string.Empty;
prefixedPath = string.Empty;
if (string.IsNullOrEmpty(cacheFolder))
{
return false;
}
var fileName = Path.GetFileNameWithoutExtension(filePath);
if (string.IsNullOrEmpty(fileName) || !IsSha1Hash(fileName))
{
return false;
}
var relative = Path.GetRelativePath(cacheFolder, filePath)
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
prefixedPath = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
hash = fileName;
return true;
}
private static bool IsSha1Hash(string value)
{
if (value.Length != 40)
{
return false;
}
foreach (var ch in value)
{
if (!Uri.IsHexDigit(ch))
{
return false;
}
}
return true;
}
public void ResumeScan(string source)
{
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;

View File

@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
private readonly SemaphoreSlim _evictSemaphore = new(1, 1);
private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger;
@@ -226,13 +227,23 @@ public sealed class FileCacheManager : IHostedService
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
var tmpPath = compressedPath + ".tmp";
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
File.Move(tmpPath, compressedPath, overwrite: true);
try
{
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
File.Move(tmpPath, compressedPath, overwrite: true);
}
finally
{
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ }
}
var compressedSize = compressed.LongLength;
var compressedSize = new FileInfo(compressedPath).Length;
SetSizeInfo(hash, originalSize, compressedSize);
UpdateEntitiesSizes(hash, originalSize, compressedSize);
var maxBytes = GiBToBytes(_configService.Current.MaxLocalCacheInGiB);
await EnforceCacheLimitAsync(maxBytes, token).ConfigureAwait(false);
return compressed;
}
finally
@@ -280,6 +291,26 @@ public sealed class FileCacheManager : IHostedService
return CreateFileEntity(cacheFolder, CachePrefix, fi);
}
public FileCacheEntity? CreateCacheEntryWithKnownHash(string path, string hash)
{
if (string.IsNullOrWhiteSpace(hash))
{
return CreateCacheEntry(path);
}
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating cache entry for {path} using provided hash", path);
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder)) return null;
if (!TryBuildPrefixedPath(fi.FullName, cacheFolder, CachePrefix, out var prefixedPath, out _))
{
return null;
}
return CreateFileCacheEntity(fi, prefixedPath, hash);
}
public FileCacheEntity? CreateFileEntry(string path)
{
FileInfo fi = new(path);
@@ -562,9 +593,10 @@ public sealed class FileCacheManager : IHostedService
}
}
public void RemoveHashedFile(string hash, string prefixedFilePath)
public void RemoveHashedFile(string hash, string prefixedFilePath, bool removeDerivedFiles = true)
{
var normalizedPath = NormalizePrefixedPathKey(prefixedFilePath);
var removedHash = false;
if (_fileCaches.TryGetValue(hash, out var caches))
{
@@ -577,11 +609,16 @@ public sealed class FileCacheManager : IHostedService
if (caches.IsEmpty)
{
_fileCaches.TryRemove(hash, out _);
removedHash = _fileCaches.TryRemove(hash, out _);
}
}
_fileCachesByPrefixedPath.TryRemove(normalizedPath, out _);
if (removeDerivedFiles && removedHash)
{
RemoveDerivedCacheFiles(hash);
}
}
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
@@ -597,7 +634,8 @@ public sealed class FileCacheManager : IHostedService
fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1);
fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
}
RemoveHashedFile(oldHash, prefixedPath);
var removeDerivedFiles = !string.Equals(oldHash, fileCache.Hash, StringComparison.OrdinalIgnoreCase);
RemoveHashedFile(oldHash, prefixedPath, removeDerivedFiles);
AddHashedFile(fileCache);
}
@@ -747,7 +785,7 @@ public sealed class FileCacheManager : IHostedService
{
try
{
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath, removeDerivedFiles: false);
var extensionPath = fileCache.ResolvedFilepath.ToUpper(CultureInfo.InvariantCulture) + "." + ext;
File.Move(fileCache.ResolvedFilepath, extensionPath, overwrite: true);
var newHashedEntity = new FileCacheEntity(fileCache.Hash, fileCache.PrefixedFilePath + "." + ext, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture));
@@ -764,6 +802,33 @@ public sealed class FileCacheManager : IHostedService
}
}
private void RemoveDerivedCacheFiles(string hash)
{
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrWhiteSpace(cacheFolder))
{
return;
}
TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "downscaled", $"{hash}.tex"));
TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "decimated", $"{hash}.mdl"));
}
private void TryDeleteDerivedCacheFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to delete derived cache file {path}", path);
}
}
private void AddHashedFile(FileCacheEntity fileCache)
{
var normalizedPath = NormalizePrefixedPathKey(fileCache.PrefixedFilePath);
@@ -877,6 +942,83 @@ public sealed class FileCacheManager : IHostedService
}, token).ConfigureAwait(false);
}
private async Task EnforceCacheLimitAsync(long maxBytes, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(CacheFolder) || maxBytes <= 0) return;
await _evictSemaphore.WaitAsync(token).ConfigureAwait(false);
try
{
Directory.CreateDirectory(CacheFolder);
foreach (var tmp in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension + ".tmp"))
{
try { File.Delete(tmp); } catch { /* ignore */ }
}
var files = Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension, SearchOption.TopDirectoryOnly)
.Select(p => new FileInfo(p))
.Where(fi => fi.Exists)
.OrderBy(fi => fi.LastWriteTimeUtc)
.ToList();
long total = files.Sum(f => f.Length);
if (total <= maxBytes) return;
foreach (var fi in files)
{
token.ThrowIfCancellationRequested();
if (total <= maxBytes) break;
var hash = Path.GetFileNameWithoutExtension(fi.Name);
try
{
var len = fi.Length;
fi.Delete();
total -= len;
_sizeCache.TryRemove(hash, out _);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to evict cache file {file}", fi.FullName);
}
}
}
finally
{
_evictSemaphore.Release();
}
}
private static long GiBToBytes(double gib)
{
if (double.IsNaN(gib) || double.IsInfinity(gib) || gib <= 0)
return 0;
var bytes = gib * 1024d * 1024d * 1024d;
if (bytes >= long.MaxValue) return long.MaxValue;
return (long)Math.Round(bytes, MidpointRounding.AwayFromZero);
}
private void CleanupOrphanCompressedCache()
{
if (string.IsNullOrWhiteSpace(CacheFolder) || !Directory.Exists(CacheFolder))
return;
foreach (var path in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension))
{
var hash = Path.GetFileNameWithoutExtension(path);
if (!_fileCaches.ContainsKey(hash))
{
try { File.Delete(path); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed deleting orphan {file}", path); }
}
}
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting FileCacheManager");
@@ -1060,6 +1202,8 @@ public sealed class FileCacheManager : IHostedService
{
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
}
CleanupOrphanCompressedCache();
}
_logger.LogInformation("Started FileCacheManager");

View File

@@ -297,7 +297,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void DalamudUtil_FrameworkUpdate()
{
RefreshPlayerRelatedAddressMap();
_ = Task.Run(() => RefreshPlayerRelatedAddressMap());
lock (_cacheAdditionLock)
{
@@ -306,20 +306,64 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (_lastClassJobId != _dalamudUtil.ClassJobId)
{
_lastClassJobId = _dalamudUtil.ClassJobId;
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
{
value?.Clear();
}
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
UpdateClassJobCache();
}
CleanupAbsentObjects();
}
private void RefreshPlayerRelatedAddressMap()
{
var tempMap = new ConcurrentDictionary<nint, GameObjectHandler>();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{
foreach (var handler in _playerRelatedPointers)
{
var address = (nint)handler.Address;
if (address != nint.Zero)
{
tempMap[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
}
_playerRelatedByAddress.Clear();
foreach (var kvp in tempMap)
{
_playerRelatedByAddress[kvp.Key] = kvp.Value;
}
_cachedFrameAddresses.Clear();
foreach (var kvp in updatedFrameAddresses)
{
_cachedFrameAddresses[kvp.Key] = kvp.Value;
}
}
private void UpdateClassJobCache()
{
_lastClassJobId = _dalamudUtil.ClassJobId;
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
{
value?.Clear();
}
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache
.Concat(jobSpecificData ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
}
private void CleanupAbsentObjects()
{
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
{
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
@@ -349,26 +393,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_semiTransientResources = null;
}
private void RefreshPlayerRelatedAddressMap()
{
_playerRelatedByAddress.Clear();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{
foreach (var handler in _playerRelatedPointers)
{
var address = (nint)handler.Address;
if (address != nint.Zero)
{
_playerRelatedByAddress[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
}
_cachedFrameAddresses = updatedFrameAddresses;
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (descriptor.IsInGpose)

View File

@@ -1,4 +1,5 @@
using Dalamud.Plugin.Services;
using Dalamud.Game.ClientState.Objects.SubKinds;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using Microsoft.Extensions.Logging;
@@ -11,24 +12,35 @@ public unsafe class BlockedCharacterHandler
private readonly Dictionary<CharaData, bool> _blockedCharacterCache = new();
private readonly ILogger<BlockedCharacterHandler> _logger;
private readonly IObjectTable _objectTable;
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider)
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider, IObjectTable objectTable)
{
gameInteropProvider.InitializeFromAttributes(this);
_logger = logger;
_objectTable = objectTable;
}
private static CharaData GetIdsFromPlayerPointer(nint ptr)
private CharaData? TryGetIdsFromPlayerPointer(nint ptr, ushort objectIndex)
{
if (ptr == nint.Zero) return new(0, 0);
var castChar = ((BattleChara*)ptr);
if (ptr == nint.Zero || objectIndex >= 200)
return null;
var obj = _objectTable[objectIndex];
if (obj is not IPlayerCharacter player || player.Address != ptr)
return null;
var castChar = (BattleChara*)player.Address;
return new(castChar->Character.AccountId, castChar->Character.ContentId);
}
public bool IsCharacterBlocked(nint ptr, out bool firstTime)
public bool IsCharacterBlocked(nint ptr, ushort objectIndex, out bool firstTime)
{
firstTime = false;
var combined = GetIdsFromPlayerPointer(ptr);
var combined = TryGetIdsFromPlayerPointer(ptr, objectIndex);
if (combined == null)
return false;
if (_blockedCharacterCache.TryGetValue(combined, out var isBlocked))
return isBlocked;

View File

@@ -5,6 +5,8 @@ namespace LightlessSync.Interop.Ipc;
public sealed partial class IpcManager : DisposableMediatorSubscriberBase
{
private bool _wasInitialized;
public IpcManager(ILogger<IpcManager> logger, LightlessMediator mediator,
IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc,
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio,
@@ -20,7 +22,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
Brio = ipcCallerBrio;
Lifestream = ipcCallerLifestream;
if (Initialized)
_wasInitialized = Initialized;
if (_wasInitialized)
{
Mediator.Publish(new PenumbraInitializedMessage());
}
@@ -60,6 +63,14 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
Moodles.CheckAPI();
PetNames.CheckAPI();
Brio.CheckAPI();
var initialized = Initialized;
if (initialized && !_wasInitialized)
{
Mediator.Publish(new PenumbraInitializedMessage());
}
_wasInitialized = initialized;
Lifestream.CheckAPI();
}
}

View File

@@ -11,6 +11,7 @@ public sealed class ChatConfig : ILightlessConfiguration
public bool ShowRulesOverlayOnOpen { get; set; } = true;
public bool ShowMessageTimestamps { get; set; } = true;
public bool ShowNotesInSyncshellChat { get; set; } = true;
public bool EnableAnimatedEmotes { get; set; } = true;
public float ChatWindowOpacity { get; set; } = .97f;
public bool FadeWhenUnfocused { get; set; } = false;
public float UnfocusedWindowOpacity { get; set; } = 0.6f;

View File

@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
using LightlessSync.PlayerData.Factories;
namespace LightlessSync.LightlessConfiguration.Configurations;
@@ -51,6 +52,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool PreferNotesOverNamesForVisible { get; set; } = false;
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Alphabetical;
public OnlinePairSortMode OnlinePairSortMode { get; set; } = OnlinePairSortMode.Alphabetical;
public TextureFormatSortMode TextureFormatSortMode { get; set; } = TextureFormatSortMode.None;
public float ProfileDelay { get; set; } = 1.5f;
public bool ProfilePopoutRight { get; set; } = false;
public bool ProfilesAllowNsfw { get; set; } = false;
@@ -157,4 +159,8 @@ public class LightlessConfig : ILightlessConfiguration
public string LastSeenVersion { get; set; } = string.Empty;
public bool EnableParticleEffects { get; set; } = true;
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe;
public bool AnimationAllowOneBasedShift { get; set; } = true;
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
}

View File

@@ -22,4 +22,15 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
public int TextureDownscaleMaxDimension { get; set; } = 2048;
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
public bool KeepOriginalTextureFiles { get; set; } = false;
public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true;
public bool EnableModelDecimation { get; set; } = false;
public int ModelDecimationTriangleThreshold { get; set; } = 20_000;
public double ModelDecimationTargetRatio { get; set; } = 0.8;
public bool KeepOriginalModelFiles { get; set; } = true;
public bool SkipModelDecimationForPreferredPairs { get; set; } = true;
public bool ModelDecimationAllowBody { get; set; } = false;
public bool ModelDecimationAllowFaceHead { get; set; } = false;
public bool ModelDecimationAllowTail { get; set; } = false;
public bool ModelDecimationAllowClothing { get; set; } = true;
public bool ModelDecimationAllowAccessories { get; set; } = true;
}

View File

@@ -5,6 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations;
public class XivDataStorageConfig : ILightlessConfiguration
{
public ConcurrentDictionary<string, long> TriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, long> EffectiveTriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Dictionary<string, List<ushort>>> BonesDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int Version { get; set; } = 0;
}

View File

@@ -74,6 +74,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessConfigService _lightlessConfigService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly PairHandlerRegistry _pairHandlerRegistry;
private readonly IServiceScopeFactory _serviceScopeFactory;
private IServiceScope? _runtimeServiceScope;
private Task? _launchTask = null;
@@ -81,11 +82,13 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
public LightlessPlugin(ILogger<LightlessPlugin> logger, LightlessConfigService lightlessConfigService,
ServerConfigurationManager serverConfigurationManager,
DalamudUtilService dalamudUtil,
PairHandlerRegistry pairHandlerRegistry,
IServiceScopeFactory serviceScopeFactory, LightlessMediator mediator) : base(logger, mediator)
{
_lightlessConfigService = lightlessConfigService;
_serverConfigurationManager = serverConfigurationManager;
_dalamudUtil = dalamudUtil;
_pairHandlerRegistry = pairHandlerRegistry;
_serviceScopeFactory = serviceScopeFactory;
}
@@ -108,12 +111,20 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
public Task StopAsync(CancellationToken cancellationToken)
{
Logger.LogDebug("Halting LightlessPlugin");
try
{
_pairHandlerRegistry.ResetAllHandlers();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to reset pair handlers on shutdown");
}
UnsubscribeAll();
DalamudUtilOnLogOut();
Logger.LogDebug("Halting LightlessPlugin");
return Task.CompletedTask;
}

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.PlayerData.Factories
{
public enum AnimationValidationMode
{
Unsafe = 0,
Safe = 1,
Safest = 2,
}
}

View File

@@ -1,6 +1,7 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -16,6 +17,7 @@ public class FileDownloadManagerFactory
private readonly FileCompactor _fileCompactor;
private readonly LightlessConfigService _configService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService;
private readonly TextureMetadataHelper _textureMetadataHelper;
public FileDownloadManagerFactory(
@@ -26,6 +28,7 @@ public class FileDownloadManagerFactory
FileCompactor fileCompactor,
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService,
TextureMetadataHelper textureMetadataHelper)
{
_loggerFactory = loggerFactory;
@@ -35,6 +38,7 @@ public class FileDownloadManagerFactory
_fileCompactor = fileCompactor;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_textureMetadataHelper = textureMetadataHelper;
}
@@ -48,6 +52,7 @@ public class FileDownloadManagerFactory
_fileCompactor,
_configService,
_textureDownscaleService,
_modelDecimationService,
_textureMetadataHelper);
}
}

View File

@@ -2,12 +2,15 @@
using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
namespace LightlessSync.PlayerData.Factories;
@@ -18,13 +21,34 @@ public class PlayerDataFactory
private readonly IpcManager _ipcManager;
private readonly ILogger<PlayerDataFactory> _logger;
private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly LightlessMediator _lightlessMediator;
private readonly TransientResourceManager _transientResourceManager;
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator)
// Transient resolved entries threshold
private const int _maxTransientResolvedEntries = 1000;
// Character build caches
private readonly ConcurrentDictionary<nint, Task<CharacterDataFragment>> _characterBuildInflight = new();
private readonly ConcurrentDictionary<nint, CacheEntry> _characterBuildCache = new();
// Time out thresholds
private static readonly TimeSpan _characterCacheTtl = TimeSpan.FromMilliseconds(750);
private static readonly TimeSpan _softReturnIfBusyAfter = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan _hardBuildTimeout = TimeSpan.FromSeconds(30);
public PlayerDataFactory(
ILogger<PlayerDataFactory> logger,
DalamudUtilService dalamudUtil,
IpcManager ipcManager,
TransientResourceManager transientResourceManager,
FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector,
XivDataAnalyzer modelAnalyzer,
LightlessMediator lightlessMediator,
LightlessConfigService configService)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
@@ -34,15 +58,15 @@ public class PlayerDataFactory
_performanceCollector = performanceCollector;
_modelAnalyzer = modelAnalyzer;
_lightlessMediator = lightlessMediator;
_configService = configService;
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
}
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
{
if (!_ipcManager.Initialized)
{
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
}
if (playerRelatedObject == null) return null;
@@ -67,16 +91,17 @@ public class PlayerDataFactory
if (pointerIsZero)
{
_logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind);
_logger.LogTrace("Pointer was zero for {objectKind}; couldn't build character", playerRelatedObject.ObjectKind);
return null;
}
try
{
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
{
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false);
}).ConfigureAwait(true);
return await _performanceCollector.LogPerformance(
this,
$"CreateCharacterData>{playerRelatedObject.ObjectKind}",
async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -92,17 +117,14 @@ public class PlayerDataFactory
}
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
{
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
}
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
{
if (playerPointer == IntPtr.Zero)
return true;
var character = (Character*)playerPointer;
if (character == null)
return true;
@@ -113,93 +135,177 @@ public class PlayerDataFactory
return gameObject->DrawObject == null;
}
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
private static bool IsCacheFresh(CacheEntry entry)
=> (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;
private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
=> CreateCharacterDataCoalesced(playerRelatedObject, ct);
private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
{
var objectKind = playerRelatedObject.ObjectKind;
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
var key = obj.Address;
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
var logDebug = _logger.IsEnabled(LogLevel.Debug);
if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
return cached.Fragment;
// wait until chara is not drawing and present so nothing spontaneously explodes
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
int totalWaitTime = 10000;
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key));
if (_characterBuildCache.TryGetValue(key, out cached))
{
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
totalWaitTime -= 50;
var completed = await Task.WhenAny(buildTask, Task.Delay(_softReturnIfBusyAfter, ct)).ConfigureAwait(false);
if (completed != buildTask && (DateTime.UtcNow - cached.CreatedUtc) <= TimeSpan.FromSeconds(5))
{
return cached.Fragment;
}
}
ct.ThrowIfCancellationRequested();
return await WithCancellation(buildTask, ct).ConfigureAwait(false);
}
DateTime start = DateTime.UtcNow;
// penumbra call, it's currently broken
Dictionary<string, HashSet<string>>? resolvedPaths;
resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false));
if (resolvedPaths == null) throw new InvalidOperationException("Penumbra returned null data");
ct.ThrowIfCancellationRequested();
private async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
{
try
{
using var cts = new CancellationTokenSource(_hardBuildTimeout);
var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
fragment.FileReplacements =
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
.Where(p => p.HasFileReplacement).ToHashSet();
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
var allowedExtensions = CacheMonitor.AllowedFileExtensions;
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !allowedExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
PruneCharacterCacheIfNeeded();
return fragment;
}
finally
{
_characterBuildInflight.TryRemove(key, out _);
}
}
private void PruneCharacterCacheIfNeeded()
{
if (_characterBuildCache.Count < 2048) return;
var cutoff = DateTime.UtcNow - TimeSpan.FromSeconds(10);
foreach (var kv in _characterBuildCache)
{
if (kv.Value.CreatedUtc < cutoff)
_characterBuildCache.TryRemove(kv.Key, out _);
}
}
private static async Task<T> WithCancellation<T>(Task<T> task, CancellationToken ct)
=> await task.WaitAsync(ct).ConfigureAwait(false);
private async Task<CharacterDataFragment> CreateCharacterDataInternal(GameObjectHandler playerRelatedObject, CancellationToken ct)
{
var objectKind = playerRelatedObject.ObjectKind;
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
var logDebug = _logger.IsEnabled(LogLevel.Debug);
var sw = Stopwatch.StartNew();
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
.ConfigureAwait(false);
// get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = transientPaths.Count == 0
? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly()
: await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
throw new InvalidOperationException("DrawObject became null during build (actor despawned)");
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string?>? getMoodlesData = null;
Task<string>? getHeelsOffset = null;
Task<string>? getHonorificTitle = null;
if (objectKind == ObjectKind.Player)
{
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
getHonorificTitle = _ipcManager.Honorific.GetTitle();
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
}
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character");
ct.ThrowIfCancellationRequested();
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);
if (logDebug)
{
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
foreach (var replacement in fragment.FileReplacements
.Where(i => i.HasFileReplacement)
.OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("=> {repl}", replacement);
ct.ThrowIfCancellationRequested();
}
}
else
var staticReplacements = new HashSet<FileReplacement>(fragment.FileReplacements, FileReplacementComparer.Instance);
var transientTask = ResolveTransientReplacementsAsync(
playerRelatedObject,
objectKind,
staticReplacements,
waitRecordingTask,
ct);
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
if (objectKind == ObjectKind.Player)
{
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement))
{
ct.ThrowIfCancellationRequested();
}
}
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
// if it's pet then it's summoner, if it's summoner we actually want to keep all filereplacements alive at all times
// or we get into redraw city for every change and nothing works properly
if (objectKind == ObjectKind.Pet)
{
foreach (var item in fragment.FileReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
if (_transientResourceManager.AddTransientResource(objectKind, item))
{
_logger.LogDebug("Marking static {item} for Pet as transient", item);
}
}
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
fragment.FileReplacements.Clear();
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
}
ct.ThrowIfCancellationRequested();
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
_transientResourceManager.ClearTransientPaths(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList());
// get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
if (clearedForPet != null)
fragment.FileReplacements.Clear();
if (logDebug)
{
_logger.LogDebug("== Transient Replacements ==");
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
foreach (var replacement in resolvedTransientPaths
.Select(c => new FileReplacement([.. c.Value], c.Key))
.OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
{
_logger.LogDebug("=> {repl}", replacement);
fragment.FileReplacements.Add(replacement);
@@ -208,85 +314,64 @@ public class PlayerDataFactory
else
{
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
{
fragment.FileReplacements.Add(replacement);
}
}
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
ct.ThrowIfCancellationRequested();
// make sure we only return data that actually has file replacements
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
// gather up data from ipc
Task<string> getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string> getHonorificTitle = _ipcManager.Honorific.GetTitle();
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
if (objectKind == ObjectKind.Player)
{
var playerFragment = (fragment as CharacterDataFragmentPlayer)!;
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment!.HonorificData = await getHonorificTitle.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
playerFragment!.HeelsData = await getHeelsOffset.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment!.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
playerFragment!.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
}
fragment.FileReplacements = new HashSet<FileReplacement>(
fragment.FileReplacements
.Where(v => v.HasFileReplacement)
.OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
FileReplacementComparer.Instance);
ct.ThrowIfCancellationRequested();
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
_logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);
var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray());
foreach (var file in toCompute)
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
foreach (var file in toCompute)
{
ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
}, ct).ConfigureAwait(false);
var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
if (removed > 0)
{
_logger.LogDebug("Removed {amount} of invalid files", removed);
}
ct.ThrowIfCancellationRequested();
Dictionary<string, List<ushort>>? boneIndices = null;
var hasPapFiles = false;
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
}
}
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
.ConfigureAwait(false);
}
try
{
#if DEBUG
if (hasPapFiles && boneIndices != null)
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
#endif
if (hasPapFiles)
{
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
.ConfigureAwait(false);
}
}
catch (OperationCanceledException e)
@@ -300,105 +385,277 @@ public class PlayerDataFactory
}
}
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds);
_logger.LogInformation("Building character data for {obj} took {time}ms",
objectKind, sw.Elapsed.TotalMilliseconds);
return fragment;
}
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct)
private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
{
if (boneIndices == null) return;
if (_logger.IsEnabled(LogLevel.Debug))
{
foreach (var kvp in boneIndices)
{
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
}
}
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
if (maxPlayerBoneIndex <= 0) return;
int noValidationFailed = 0;
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
var remaining = 10000;
while (remaining > 0)
{
ct.ThrowIfCancellationRequested();
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
bool validationFailed = false;
if (skeletonIndices != null)
var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
return;
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
remaining -= 50;
}
}
private static HashSet<FileReplacement> BuildStaticReplacements(Dictionary<string, HashSet<string>> resolvedPaths)
{
var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);
foreach (var kvp in resolvedPaths)
{
var fr = new FileReplacement([.. kvp.Value], kvp.Key);
if (!fr.HasFileReplacement) continue;
var allAllowed = fr.GamePaths.All(g =>
CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)));
if (!allAllowed) continue;
set.Add(fr);
}
return set;
}
private async Task<(IReadOnlyDictionary<string, string[]> ResolvedPaths, HashSet<FileReplacement>? ClearedReplacements)>
ResolveTransientReplacementsAsync(
GameObjectHandler obj,
ObjectKind objectKind,
HashSet<FileReplacement> staticReplacements,
Task waitRecordingTask,
CancellationToken ct)
{
await waitRecordingTask.ConfigureAwait(false);
HashSet<FileReplacement>? clearedReplacements = null;
if (objectKind == ObjectKind.Pet)
{
foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
// 105 is the maximum vanilla skellington spoopy bone index
if (skeletonIndices.All(k => k.Value.Max() <= 105))
{
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
continue;
}
if (_transientResourceManager.AddTransientResource(objectKind, item))
_logger.LogDebug("Marking static {item} for Pet as transient", item);
}
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
_logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
clearedReplacements = staticReplacements;
}
foreach (var boneCount in skeletonIndices)
{
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
if (maxAnimationIndex > maxPlayerBoneIndex)
ct.ThrowIfCancellationRequested();
_transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);
var transientPaths = ManageSemiTransientData(objectKind);
if (transientPaths.Count == 0)
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet<string>(StringComparer.Ordinal))
.ConfigureAwait(false);
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
{
_logger.LogWarning("Transient entries ({resolved}) are above the threshold {max}; Please consider disable some mods (VFX have heavy load) to reduce transient load",
resolved.Count,
_maxTransientResolvedEntries);
}
return (resolved, clearedReplacements);
}
private async Task VerifyPlayerAnimationBones(
Dictionary<string, List<ushort>>? playerBoneIndices,
CharacterDataFragmentPlayer fragment,
CancellationToken ct)
{
var mode = _configService.Current.AnimationValidationMode;
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
if (mode == AnimationValidationMode.Unsafe)
return;
if (playerBoneIndices == null || playerBoneIndices.Count == 0)
return;
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawLocalKey, indices) in playerBoneIndices)
{
if (indices is not { Count: > 0 })
continue;
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
if (string.IsNullOrEmpty(key))
continue;
if (!localBoneSets.TryGetValue(key, out var set))
localBoneSets[key] = set = [];
foreach (var idx in indices)
set.Add(idx);
}
if (localBoneSets.Count == 0)
return;
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("SEND local buckets: {b}",
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0;
var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0;
_logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}",
kvp.Key, kvp.Value.Count, min, max);
}
}
var papGroups = fragment.FileReplacements
.Where(f => !f.IsFileSwap
&& !string.IsNullOrEmpty(f.Hash)
&& f.GamePaths is { Count: > 0 }
&& f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase)
.ToList();
int noValidationFailed = 0;
foreach (var g in papGroups)
{
ct.ThrowIfCancellationRequested();
var hash = g.Key;
Dictionary<string, List<ushort>>? papIndices = null;
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
try
{
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
.ConfigureAwait(false);
}
finally
{
_papParseLimiter.Release();
}
if (papIndices == null || papIndices.Count == 0)
continue;
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
continue;
if (_logger.IsEnabled(LogLevel.Debug))
{
var papBuckets = papIndices
.Select(kvp => new
{
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
validationFailed = true;
break;
}
}
Raw = kvp.Key,
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
Indices = kvp.Value
})
.Where(x => x.Indices is { Count: > 0 })
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
.Select(grp =>
{
var all = grp.SelectMany(v => v.Indices).ToList();
var min = all.Count > 0 ? all.Min() : 0;
var max = all.Count > 0 ? all.Max() : 0;
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
})
.ToList();
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
hash,
string.Join(" | ", papBuckets));
}
if (validationFailed)
{
noValidationFailed++;
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
fragment.FileReplacements.Remove(file);
foreach (var gamePath in file.GamePaths)
{
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
}
}
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
continue;
noValidationFailed++;
_logger.LogWarning(
"Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}",
hash,
reason);
var removedGamePaths = fragment.FileReplacements
.Where(fr => !fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
fragment.FileReplacements.RemoveWhere(fr =>
!fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
foreach (var gp in removedGamePaths)
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
}
if (noValidationFailed > 0)
{
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup",
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " +
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).",
NotificationType.Warning, TimeSpan.FromSeconds(10)));
_lightlessMediator.Publish(new NotificationMessage(
"Invalid Skeleton Setup",
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
"Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
"Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
NotificationType.Warning,
TimeSpan.FromSeconds(10)));
}
}
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet<string> forwardResolve, HashSet<string> reverseResolve)
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
GameObjectHandler handler,
HashSet<string> forwardResolve,
HashSet<string> reverseResolve)
{
var forwardPaths = forwardResolve.ToArray();
var reversePaths = reverseResolve.ToArray();
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
if (forwardPaths.Length == 0 && reversePaths.Length == 0)
{
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
}
var forwardPathsLower = forwardPaths.Length == 0 ? Array.Empty<string>() : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
if (handler.ObjectKind != ObjectKind.Player)
{
var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var idx = handler.GetGameObject()?.ObjectIndex;
if (!idx.HasValue)
{
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
}
var resolvedForward = new string[forwardPaths.Length];
for (int i = 0; i < forwardPaths.Length; i++)
{
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
}
var resolvedReverse = new string[reversePaths.Length][];
for (int i = 0; i < reversePaths.Length; i++)
{
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
}
return (idx, resolvedForward, resolvedReverse);
}).ConfigureAwait(false);
@@ -409,14 +666,10 @@ public class PlayerDataFactory
{
var filePath = forwardResolved[i]?.ToLowerInvariant();
if (string.IsNullOrEmpty(filePath))
{
continue;
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.Add(forwardPaths[i].ToLowerInvariant());
}
else
{
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
@@ -425,15 +678,16 @@ public class PlayerDataFactory
for (int i = 0; i < reversePaths.Length; i++)
{
var filePath = reversePaths[i].ToLowerInvariant();
var filePath = reversePathsLower[i];
var reverseResolvedLower = new string[reverseResolved[i].Length];
for (var j = 0; j < reverseResolvedLower.Length; j++)
{
reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant();
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
}
else
{
resolvedPaths[filePath] = new List<string>(reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList());
}
resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()];
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
@@ -441,30 +695,28 @@ public class PlayerDataFactory
}
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
for (int i = 0; i < forwardPaths.Length; i++)
{
var filePath = forward[i].ToLowerInvariant();
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.Add(forwardPaths[i].ToLowerInvariant());
}
else
{
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
}
}
for (int i = 0; i < reversePaths.Length; i++)
{
var filePath = reversePaths[i].ToLowerInvariant();
var filePath = reversePathsLower[i];
var reverseResolvedLower = new string[reverse[i].Length];
for (var j = 0; j < reverseResolvedLower.Length; j++)
{
reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant();
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
}
else
{
resolvedPaths[filePath] = new List<string>(reverse[i].Select(c => c.ToLowerInvariant()).ToList());
}
resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
@@ -475,11 +727,29 @@ public class PlayerDataFactory
_transientResourceManager.PersistTransientResources(objectKind);
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path)))
int scanned = 0, skippedEmpty = 0, skippedVfx = 0;
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind))
{
scanned++;
if (string.IsNullOrEmpty(path))
{
skippedEmpty++;
continue;
}
pathsToResolve.Add(path);
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(
"ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}",
objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx);
}
return pathsToResolve;
}
}
}

View File

@@ -16,4 +16,5 @@ public interface IPairPerformanceSubject
long LastAppliedApproximateVRAMBytes { get; set; }
long LastAppliedApproximateEffectiveVRAMBytes { get; set; }
long LastAppliedDataTris { get; set; }
long LastAppliedApproximateEffectiveTris { get; set; }
}

View File

@@ -69,6 +69,7 @@ public class Pair
public string? PlayerName => TryGetHandler()?.PlayerName ?? UserPair.User.AliasOrUID;
public long LastAppliedDataBytes => TryGetHandler()?.LastAppliedDataBytes ?? -1;
public long LastAppliedDataTris => TryGetHandler()?.LastAppliedDataTris ?? -1;
public long LastAppliedApproximateEffectiveTris => TryGetHandler()?.LastAppliedApproximateEffectiveTris ?? -1;
public long LastAppliedApproximateVRAMBytes => TryGetHandler()?.LastAppliedApproximateVRAMBytes ?? -1;
public long LastAppliedApproximateEffectiveVRAMBytes => TryGetHandler()?.LastAppliedApproximateEffectiveVRAMBytes ?? -1;
public string Ident => TryGetHandler()?.Ident ?? TryGetConnection()?.Ident ?? string.Empty;

View File

@@ -125,6 +125,7 @@ public sealed partial class PairCoordinator
}
}
_mediator.Publish(new PairOnlineMessage(new PairUniqueIdentifier(dto.User.UID)));
PublishPairDataChanged();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -25,13 +28,18 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
private readonly IServiceProvider _serviceProvider;
private readonly IHostApplicationLifetime _lifetime;
private readonly FileCacheManager _fileCacheManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService;
private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly IFramework _framework;
public PairHandlerAdapterFactory(
ILoggerFactory loggerFactory,
@@ -42,15 +50,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
FileDownloadManagerFactory fileDownloadManagerFactory,
PluginWarningNotificationService pluginWarningNotificationManager,
IServiceProvider serviceProvider,
IFramework framework,
IHostApplicationLifetime lifetime,
FileCacheManager fileCacheManager,
PlayerPerformanceConfigService playerPerformanceConfigService,
PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager,
TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService,
PairStateCache pairStateCache,
PairPerformanceMetricsCache pairPerformanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor)
PenumbraTempCollectionJanitor tempCollectionJanitor,
XivDataAnalyzer modelAnalyzer,
LightlessConfigService configService)
{
_loggerFactory = loggerFactory;
_mediator = mediator;
@@ -60,15 +73,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
_fileDownloadManagerFactory = fileDownloadManagerFactory;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_serviceProvider = serviceProvider;
_framework = framework;
_lifetime = lifetime;
_fileCacheManager = fileCacheManager;
_playerPerformanceConfigService = playerPerformanceConfigService;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_pairStateCache = pairStateCache;
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
_tempCollectionJanitor = tempCollectionJanitor;
_modelAnalyzer = modelAnalyzer;
_configService = configService;
}
public IPairHandlerAdapter Create(string ident)
@@ -86,15 +104,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
downloadManager,
_pluginWarningNotificationManager,
dalamudUtilService,
_framework,
actorObjectService,
_lifetime,
_fileCacheManager,
_playerPerformanceConfigService,
_playerPerformanceService,
_pairProcessingLimiter,
_serverConfigManager,
_textureDownscaleService,
_modelDecimationService,
_pairStateCache,
_pairPerformanceMetricsCache,
_tempCollectionJanitor);
_tempCollectionJanitor,
_modelAnalyzer,
_configService);
}
}

View File

@@ -89,7 +89,7 @@ public sealed class PairHandlerRegistry : IDisposable
}
if (handler.LastReceivedCharacterData is not null &&
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0))
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0 || handler.LastAppliedApproximateEffectiveTris < 0))
{
handler.ApplyLastReceivedData(forced: true);
}

View File

@@ -258,7 +258,8 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase
if (handler.LastAppliedApproximateVRAMBytes >= 0
&& handler.LastAppliedDataTris >= 0
&& handler.LastAppliedApproximateEffectiveVRAMBytes >= 0)
&& handler.LastAppliedApproximateEffectiveVRAMBytes >= 0
&& handler.LastAppliedApproximateEffectiveTris >= 0)
{
continue;
}

View File

@@ -5,7 +5,8 @@ namespace LightlessSync.PlayerData.Pairs;
public readonly record struct PairPerformanceMetrics(
long TriangleCount,
long ApproximateVramBytes,
long ApproximateEffectiveVramBytes);
long ApproximateEffectiveVramBytes,
long ApproximateEffectiveTris);
/// <summary>
/// caches performance metrics keyed by pair ident

View File

@@ -50,6 +50,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
});
Mediator.Subscribe<ConnectedMessage>(this, (_) => PushToAllVisibleUsers());
Mediator.Subscribe<PairOnlineMessage>(this, (msg) => HandlePairOnline(msg.PairIdent));
Mediator.Subscribe<DisconnectedMessage>(this, (_) =>
{
_fileTransferManager.CancelUpload();
@@ -111,6 +112,20 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
_ = PushCharacterDataAsync(forced);
}
private void HandlePairOnline(PairUniqueIdentifier pairIdent)
{
if (!_apiController.IsConnected || !_pairLedger.IsPairVisible(pairIdent))
{
return;
}
if (_pairLedger.GetHandler(pairIdent)?.UserData is { } user)
{
_usersToPushDataTo.Add(user);
PushCharacterData(forced: true);
}
}
private async Task PushCharacterDataAsync(bool forced = false)
{
await _pushLock.WaitAsync(_runtimeCts.Token).ConfigureAwait(false);
@@ -152,5 +167,6 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
}
}
private List<UserData> GetVisibleUsers() => [.. _pairLedger.GetVisiblePairs().Select(connection => connection.User)];
private List<UserData> GetVisibleUsers()
=> [.. _pairLedger.GetVisiblePairs().Where(connection => connection.IsOnline).Select(connection => connection.User)];
}

View File

@@ -40,6 +40,7 @@ using System.Reflection;
using OtterTex;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.UI.Models;
namespace LightlessSync;
@@ -105,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton(new WindowSystem("LightlessSync"));
services.AddSingleton<FileDialogManager>();
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
services.AddSingleton(framework);
services.AddSingleton(gameGui);
services.AddSingleton(gameInteropProvider);
services.AddSingleton(addonLifecycle);
@@ -121,10 +123,12 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<HubFactory>();
services.AddSingleton<FileUploadManager>();
services.AddSingleton<FileTransferOrchestrator>();
services.AddSingleton<FileDownloadDeduplicator>();
services.AddSingleton<LightlessPlugin>();
services.AddSingleton<LightlessProfileManager>();
services.AddSingleton<TextureCompressionService>();
services.AddSingleton<TextureDownscaleService>();
services.AddSingleton<ModelDecimationService>();
services.AddSingleton<GameObjectHandlerFactory>();
services.AddSingleton<FileDownloadManagerFactory>();
services.AddSingleton<PairProcessingLimiter>();
@@ -177,7 +181,8 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton(sp => new BlockedCharacterHandler(
sp.GetRequiredService<ILogger<BlockedCharacterHandler>>(),
gameInteropProvider));
gameInteropProvider,
objectTable));
services.AddSingleton(sp => new IpcProvider(
sp.GetRequiredService<ILogger<IpcProvider>>(),

View File

@@ -6,6 +6,7 @@ using FFXIVClientStructs.Interop;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -16,7 +17,7 @@ using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.Services.ActorTracking;
public sealed class ActorObjectService : IHostedService, IDisposable
public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorSubscriber
{
public readonly record struct ActorDescriptor(
string Name,
@@ -36,6 +37,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
private readonly IClientState _clientState;
private readonly ICondition _condition;
private readonly LightlessMediator _mediator;
private readonly object _playerRelatedHandlerLock = new();
private readonly HashSet<GameObjectHandler> _playerRelatedHandlers = [];
private readonly ConcurrentDictionary<nint, ActorDescriptor> _activePlayers = new();
private readonly ConcurrentDictionary<nint, ActorDescriptor> _gposePlayers = new();
@@ -71,6 +74,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable
_clientState = clientState;
_condition = condition;
_mediator = mediator;
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Add(msg.GameObjectHandler);
}
RefreshTrackedActors(force: true);
});
_mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Remove(msg.GameObjectHandler);
}
RefreshTrackedActors(force: true);
});
}
private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
@@ -84,6 +106,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
public IReadOnlyList<ActorDescriptor> PlayerDescriptors => Snapshot.PlayerDescriptors;
public IReadOnlyList<ActorDescriptor> OwnedDescriptors => Snapshot.OwnedDescriptors;
public IReadOnlyList<ActorDescriptor> GposeDescriptors => CurrentGposeSnapshot.GposeDescriptors;
public LightlessMediator Mediator => _mediator;
public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor);
public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor)
@@ -213,18 +236,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return false;
}
public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default)
public async Task<bool> WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default, int timeOutMs = 30000)
{
if (address == nint.Zero)
throw new ArgumentException("Address cannot be zero.", nameof(address));
var timeoutAt = timeOutMs > 0 ? Environment.TickCount64 + timeOutMs : long.MaxValue;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false);
if (!IsZoning && isLoaded)
return;
var loadState = await _framework.RunOnFrameworkThread(() => GetObjectLoadState(address)).ConfigureAwait(false);
if (!loadState.IsValid)
return false;
if (!IsZoning && loadState.IsLoaded)
return true;
if (Environment.TickCount64 >= timeoutAt)
return false;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
@@ -317,6 +347,11 @@ public sealed class ActorObjectService : IHostedService, IDisposable
_actorsByHash.Clear();
_actorsByName.Clear();
_pendingHashResolutions.Clear();
_mediator.UnsubscribeAll(this);
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Clear();
}
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
return Task.CompletedTask;
@@ -493,7 +528,9 @@ public sealed class ActorObjectService : IHostedService, IDisposable
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount)
if (expectedMinionOrMount != nint.Zero
&& (nint)gameObject == expectedMinionOrMount
&& IsPlayerRelatedOwnedAddress(expectedMinionOrMount, LightlessObjectKind.MinionOrMount))
{
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
@@ -507,16 +544,37 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return (null, ownerId);
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
if (expectedPet != nint.Zero
&& (nint)gameObject == expectedPet
&& IsPlayerRelatedOwnedAddress(expectedPet, LightlessObjectKind.Pet))
return (LightlessObjectKind.Pet, ownerId);
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
if (expectedCompanion != nint.Zero
&& (nint)gameObject == expectedCompanion
&& IsPlayerRelatedOwnedAddress(expectedCompanion, LightlessObjectKind.Companion))
return (LightlessObjectKind.Companion, ownerId);
return (null, ownerId);
}
private bool IsPlayerRelatedOwnedAddress(nint address, LightlessObjectKind expectedKind)
{
if (address == nint.Zero)
return false;
lock (_playerRelatedHandlerLock)
{
foreach (var handler in _playerRelatedHandlers)
{
if (handler.Address == address && handler.ObjectKind == expectedKind)
return true;
}
}
return false;
}
private unsafe nint GetMinionOrMountAddress(nint localPlayerAddress, uint ownerEntityId)
{
if (localPlayerAddress == nint.Zero)
@@ -524,20 +582,20 @@ public sealed class ActorObjectService : IHostedService, IDisposable
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (ownerEntityId == 0)
return nint.Zero;
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
if (ownerEntityId == 0 || ResolveOwnerId(candidate) == ownerEntityId)
if (ResolveOwnerId(candidate) == ownerEntityId)
return candidateAddress;
}
}
if (ownerEntityId == 0)
return candidateAddress;
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
@@ -551,7 +609,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return obj.Address;
}
return candidateAddress;
return nint.Zero;
}
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
@@ -1022,6 +1080,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
public void Dispose()
{
DisposeHooks();
_mediator.UnsubscribeAll(this);
GC.SuppressFinalize(this);
}
@@ -1143,6 +1202,18 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return results;
}
private LoadState GetObjectLoadState(nint address)
{
if (address == nint.Zero)
return LoadState.Invalid;
var obj = _objectTable.CreateObjectReference(address);
if (obj is null || obj.Address != address)
return LoadState.Invalid;
return new LoadState(true, IsObjectFullyLoaded(address));
}
private static unsafe bool IsObjectFullyLoaded(nint address)
{
if (address == nint.Zero)
@@ -1169,6 +1240,11 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return true;
}
private readonly record struct LoadState(bool IsValid, bool IsLoaded)
{
public static LoadState Invalid => new(false, false);
}
private sealed record OwnedObjectSnapshot(
IReadOnlyList<nint> RenderedPlayers,
IReadOnlyList<nint> RenderedCompanions,

View File

@@ -28,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
var token = _baseAnalysisCts.Token;
_ = BaseAnalysis(msg.CharacterData, token);
_ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
});
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = modelAnalyzer;

View File

@@ -1,29 +1,41 @@
using Dalamud.Interface.Textures.TextureWraps;
using LightlessSync.LightlessConfiguration;
using LightlessSync.UI;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Metadata;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace LightlessSync.Services.Chat;
public sealed class ChatEmoteService : IDisposable
{
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
private const int DefaultFrameDelayMs = 100;
private const int MinFrameDelayMs = 20;
private readonly ILogger<ChatEmoteService> _logger;
private readonly HttpClient _httpClient;
private readonly UiSharedService _uiSharedService;
private readonly ChatConfigService _chatConfigService;
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _downloadGate = new(3, 3);
private readonly object _loadLock = new();
private Task? _loadTask;
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService)
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService, ChatConfigService chatConfigService)
{
_logger = logger;
_httpClient = httpClient;
_uiSharedService = uiSharedService;
_chatConfigService = chatConfigService;
}
public void EnsureGlobalEmotesLoaded()
@@ -62,13 +74,17 @@ public sealed class ChatEmoteService : IDisposable
return false;
}
if (entry.Texture is not null)
var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes;
if (entry.TryGetTexture(allowAnimation, out texture))
{
texture = entry.Texture;
if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation)
{
entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true);
}
return true;
}
entry.EnsureLoading(QueueEmoteDownload);
entry.EnsureLoading(allowAnimation, QueueEmoteDownload);
return true;
}
@@ -76,7 +92,7 @@ public sealed class ChatEmoteService : IDisposable
{
foreach (var entry in _emotes.Values)
{
entry.Texture?.Dispose();
entry.Dispose();
}
_downloadGate.Dispose();
@@ -108,13 +124,13 @@ public sealed class ChatEmoteService : IDisposable
continue;
}
var url = TryBuildEmoteUrl(emoteElement);
if (string.IsNullOrWhiteSpace(url))
var source = TryBuildEmoteSource(emoteElement);
if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation))
{
continue;
}
_emotes.TryAdd(name, new EmoteEntry(url));
_emotes.TryAdd(name, new EmoteEntry(name, source.Value));
}
}
catch (Exception ex)
@@ -123,7 +139,7 @@ public sealed class ChatEmoteService : IDisposable
}
}
private static string? TryBuildEmoteUrl(JsonElement emoteElement)
private static EmoteSource? TryBuildEmoteSource(JsonElement emoteElement)
{
if (!emoteElement.TryGetProperty("data", out var dataElement))
{
@@ -156,29 +172,38 @@ public sealed class ChatEmoteService : IDisposable
return null;
}
var fileName = PickBestStaticFile(filesElement);
if (string.IsNullOrWhiteSpace(fileName))
var files = ReadEmoteFiles(filesElement);
if (files.Count == 0)
{
return null;
}
return baseUrl.TrimEnd('/') + "/" + fileName;
var animatedFile = PickBestAnimatedFile(files);
var animatedUrl = animatedFile is null ? null : BuildEmoteUrl(baseUrl, animatedFile.Value.Name);
var staticName = animatedFile?.StaticName;
if (string.IsNullOrWhiteSpace(staticName))
{
staticName = PickBestStaticFileName(files);
}
var staticUrl = string.IsNullOrWhiteSpace(staticName) ? null : BuildEmoteUrl(baseUrl, staticName);
if (string.IsNullOrWhiteSpace(animatedUrl) && string.IsNullOrWhiteSpace(staticUrl))
{
return null;
}
return new EmoteSource(staticUrl, animatedUrl);
}
private static string? PickBestStaticFile(JsonElement filesElement)
{
string? png1x = null;
string? webp1x = null;
string? pngFallback = null;
string? webpFallback = null;
private static string BuildEmoteUrl(string baseUrl, string fileName)
=> baseUrl.TrimEnd('/') + "/" + fileName;
private static List<EmoteFile> ReadEmoteFiles(JsonElement filesElement)
{
var files = new List<EmoteFile>();
foreach (var file in filesElement.EnumerateArray())
{
if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False)
{
continue;
}
if (!file.TryGetProperty("name", out var nameElement))
{
continue;
@@ -190,6 +215,88 @@ public sealed class ChatEmoteService : IDisposable
continue;
}
string? staticName = null;
if (file.TryGetProperty("static_name", out var staticNameElement) && staticNameElement.ValueKind == JsonValueKind.String)
{
staticName = staticNameElement.GetString();
}
var frameCount = 1;
if (file.TryGetProperty("frame_count", out var frameCountElement) && frameCountElement.ValueKind == JsonValueKind.Number)
{
frameCountElement.TryGetInt32(out frameCount);
frameCount = Math.Max(frameCount, 1);
}
string? format = null;
if (file.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String)
{
format = formatElement.GetString();
}
files.Add(new EmoteFile(name, staticName, frameCount, format));
}
return files;
}
private static EmoteFile? PickBestAnimatedFile(IReadOnlyList<EmoteFile> files)
{
EmoteFile? webp1x = null;
EmoteFile? gif1x = null;
EmoteFile? webpFallback = null;
EmoteFile? gifFallback = null;
foreach (var file in files)
{
if (file.FrameCount <= 1 || !IsAnimatedFormatSupported(file))
{
continue;
}
if (file.Name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
{
webp1x = file;
}
else if (file.Name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
{
gif1x = file;
}
else if (file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
{
webpFallback = file;
}
else if (file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
{
gifFallback = file;
}
}
return webp1x ?? gif1x ?? webpFallback ?? gifFallback;
}
private static string? PickBestStaticFileName(IReadOnlyList<EmoteFile> files)
{
string? png1x = null;
string? webp1x = null;
string? gif1x = null;
string? pngFallback = null;
string? webpFallback = null;
string? gifFallback = null;
foreach (var file in files)
{
if (file.FrameCount > 1)
{
continue;
}
var name = file.StaticName ?? file.Name;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
{
png1x = name;
@@ -198,6 +305,10 @@ public sealed class ChatEmoteService : IDisposable
{
webp1x = name;
}
else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
{
gif1x = name;
}
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
{
pngFallback = name;
@@ -206,25 +317,80 @@ public sealed class ChatEmoteService : IDisposable
{
webpFallback = name;
}
else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
{
gifFallback = name;
}
}
return png1x ?? webp1x ?? pngFallback ?? webpFallback;
return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback;
}
private void QueueEmoteDownload(EmoteEntry entry)
private static bool IsAnimatedFormatSupported(EmoteFile file)
{
if (!string.IsNullOrWhiteSpace(file.Format))
{
return file.Format.Equals("WEBP", StringComparison.OrdinalIgnoreCase)
|| file.Format.Equals("GIF", StringComparison.OrdinalIgnoreCase);
}
return file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|| file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase);
}
private readonly record struct EmoteSource(string? StaticUrl, string? AnimatedUrl)
{
public bool HasStatic => !string.IsNullOrWhiteSpace(StaticUrl);
public bool HasAnimation => !string.IsNullOrWhiteSpace(AnimatedUrl);
}
private readonly record struct EmoteFile(string Name, string? StaticName, int FrameCount, string? Format);
private void QueueEmoteDownload(EmoteEntry entry, bool allowAnimation)
{
_ = Task.Run(async () =>
{
await _downloadGate.WaitAsync().ConfigureAwait(false);
try
{
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
var texture = _uiSharedService.LoadImage(data);
entry.SetTexture(texture);
if (allowAnimation)
{
if (entry.HasAnimatedSource)
{
entry.MarkAnimationAttempted();
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
{
return;
}
}
if (entry.HasStaticSource && !entry.HasStaticTexture && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
{
return;
}
}
else
{
if (entry.HasStaticSource && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
{
return;
}
if (entry.HasAnimatedSource)
{
entry.MarkAnimationAttempted();
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
{
return;
}
}
}
entry.MarkFailed();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url);
_logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code);
entry.MarkFailed();
}
finally
@@ -234,21 +400,334 @@ public sealed class ChatEmoteService : IDisposable
});
}
private sealed class EmoteEntry
private async Task<bool> TryLoadAnimatedEmoteAsync(EmoteEntry entry)
{
private int _loadingState;
public EmoteEntry(string url)
if (string.IsNullOrWhiteSpace(entry.AnimatedUrl))
{
Url = url;
return false;
}
public string Url { get; }
public IDalamudTextureWrap? Texture { get; private set; }
public void EnsureLoading(Action<EmoteEntry> queueDownload)
try
{
if (Texture is not null)
var data = await _httpClient.GetByteArrayAsync(entry.AnimatedUrl).ConfigureAwait(false);
var isWebp = entry.AnimatedUrl.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
if (!TryDecodeAnimation(data, isWebp, out var animation))
{
return false;
}
entry.SetAnimation(animation);
return true;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to decode animated 7TV emote {Emote}", entry.Code);
return false;
}
}
private async Task<bool> TryLoadStaticEmoteAsync(EmoteEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.StaticUrl))
{
return false;
}
try
{
var data = await _httpClient.GetByteArrayAsync(entry.StaticUrl).ConfigureAwait(false);
var texture = _uiSharedService.LoadImage(data);
entry.SetStaticTexture(texture);
return true;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to decode static 7TV emote {Emote}", entry.Code);
return false;
}
}
private bool TryDecodeAnimation(byte[] data, bool isWebp, out EmoteAnimation? animation)
{
animation = null;
List<EmoteFrame>? frames = null;
try
{
Image<Rgba32> image;
if (isWebp)
{
using var stream = new MemoryStream(data);
image = WebpDecoder.Instance.Decode<Rgba32>(
new WebpDecoderOptions { BackgroundColorHandling = BackgroundColorHandling.Ignore },
stream);
}
else
{
image = Image.Load<Rgba32>(data);
}
using (image)
{
if (image.Frames.Count <= 1)
{
return false;
}
using var composite = new Image<Rgba32>(image.Width, image.Height, Color.Transparent);
Image<Rgba32>? restoreCanvas = null;
GifDisposalMethod? pendingGifDisposal = null;
WebpDisposalMethod? pendingWebpDisposal = null;
frames = new List<EmoteFrame>(image.Frames.Count);
for (var i = 0; i < image.Frames.Count; i++)
{
var frameMetadata = image.Frames[i].Metadata;
var delayMs = GetFrameDelayMs(frameMetadata);
ApplyDisposal(composite, ref restoreCanvas, pendingGifDisposal, pendingWebpDisposal);
GifDisposalMethod? currentGifDisposal = null;
WebpDisposalMethod? currentWebpDisposal = null;
var blendMethod = WebpBlendMethod.Over;
if (isWebp)
{
if (frameMetadata.TryGetWebpFrameMetadata(out var webpMetadata))
{
currentWebpDisposal = webpMetadata.DisposalMethod;
blendMethod = webpMetadata.BlendMethod;
}
}
else if (frameMetadata.TryGetGifMetadata(out var gifMetadata))
{
currentGifDisposal = gifMetadata.DisposalMethod;
}
if (currentGifDisposal == GifDisposalMethod.RestoreToPrevious)
{
restoreCanvas?.Dispose();
restoreCanvas = composite.Clone();
}
using var frameImage = image.Frames.CloneFrame(i);
var alphaMode = blendMethod == WebpBlendMethod.Source
? PixelAlphaCompositionMode.Src
: PixelAlphaCompositionMode.SrcOver;
composite.Mutate(ctx => ctx.DrawImage(frameImage, PixelColorBlendingMode.Normal, alphaMode, 1f));
using var renderedFrame = composite.Clone();
using var ms = new MemoryStream();
renderedFrame.SaveAsPng(ms);
var texture = _uiSharedService.LoadImage(ms.ToArray());
frames.Add(new EmoteFrame(texture, delayMs));
pendingGifDisposal = currentGifDisposal;
pendingWebpDisposal = currentWebpDisposal;
}
restoreCanvas?.Dispose();
animation = new EmoteAnimation(frames);
return true;
}
}
catch
{
if (frames is not null)
{
foreach (var frame in frames)
{
frame.Texture.Dispose();
}
}
return false;
}
}
private static int GetFrameDelayMs(ImageFrameMetadata metadata)
{
if (metadata.TryGetGifMetadata(out var gifMetadata))
{
var delayMs = (long)gifMetadata.FrameDelay * 10L;
return NormalizeFrameDelayMs(delayMs);
}
if (metadata.TryGetWebpFrameMetadata(out var webpMetadata))
{
return NormalizeFrameDelayMs(webpMetadata.FrameDelay);
}
return DefaultFrameDelayMs;
}
private static int NormalizeFrameDelayMs(long delayMs)
{
if (delayMs <= 0)
{
return DefaultFrameDelayMs;
}
var clamped = delayMs > int.MaxValue ? int.MaxValue : (int)delayMs;
return Math.Max(clamped, MinFrameDelayMs);
}
private static void ApplyDisposal(
Image<Rgba32> composite,
ref Image<Rgba32>? restoreCanvas,
GifDisposalMethod? gifDisposal,
WebpDisposalMethod? webpDisposal)
{
if (gifDisposal is not null)
{
switch (gifDisposal)
{
case GifDisposalMethod.RestoreToBackground:
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
break;
case GifDisposalMethod.RestoreToPrevious:
if (restoreCanvas is not null)
{
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
var restoreSnapshot = restoreCanvas;
composite.Mutate(ctx => ctx.DrawImage(restoreSnapshot, PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.Src, 1f));
restoreCanvas.Dispose();
restoreCanvas = null;
}
break;
}
}
else if (webpDisposal == WebpDisposalMethod.RestoreToBackground)
{
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
}
}
private sealed class EmoteAnimation : IDisposable
{
private readonly EmoteFrame[] _frames;
private readonly int _durationMs;
private readonly long _startTimestamp;
public EmoteAnimation(IReadOnlyList<EmoteFrame> frames)
{
_frames = frames.ToArray();
_durationMs = Math.Max(1, frames.Sum(frame => frame.DurationMs));
_startTimestamp = Stopwatch.GetTimestamp();
}
public IDalamudTextureWrap? GetCurrentFrame()
{
if (_frames.Length == 0)
{
return null;
}
if (_frames.Length == 1)
{
return _frames[0].Texture;
}
var elapsedTicks = Stopwatch.GetTimestamp() - _startTimestamp;
var elapsedMs = (elapsedTicks * 1000L) / Stopwatch.Frequency;
var targetMs = (int)(elapsedMs % _durationMs);
var accumulated = 0;
foreach (var frame in _frames)
{
accumulated += frame.DurationMs;
if (targetMs < accumulated)
{
return frame.Texture;
}
}
return _frames[^1].Texture;
}
public IDalamudTextureWrap? GetStaticFrame()
{
if (_frames.Length == 0)
{
return null;
}
return _frames[0].Texture;
}
public void Dispose()
{
foreach (var frame in _frames)
{
frame.Texture.Dispose();
}
}
}
private readonly record struct EmoteFrame(IDalamudTextureWrap Texture, int DurationMs);
private sealed class EmoteEntry : IDisposable
{
private int _loadingState;
private int _animationAttempted;
private IDalamudTextureWrap? _staticTexture;
private EmoteAnimation? _animation;
public EmoteEntry(string code, EmoteSource source)
{
Code = code;
StaticUrl = source.StaticUrl;
AnimatedUrl = source.AnimatedUrl;
}
public string Code { get; }
public string? StaticUrl { get; }
public string? AnimatedUrl { get; }
public bool HasStaticSource => !string.IsNullOrWhiteSpace(StaticUrl);
public bool HasAnimatedSource => !string.IsNullOrWhiteSpace(AnimatedUrl);
public bool HasStaticTexture => _staticTexture is not null;
public bool HasAttemptedAnimation => Interlocked.CompareExchange(ref _animationAttempted, 0, 0) != 0;
public bool NeedsAnimationLoad => _animation is null && HasAnimatedSource;
public void MarkAnimationAttempted()
{
Interlocked.Exchange(ref _animationAttempted, 1);
}
public bool TryGetTexture(bool allowAnimation, out IDalamudTextureWrap? texture)
{
if (allowAnimation && _animation is not null)
{
texture = _animation.GetCurrentFrame();
return true;
}
if (_staticTexture is not null)
{
texture = _staticTexture;
return true;
}
if (!allowAnimation && _animation is not null)
{
texture = _animation.GetStaticFrame();
return true;
}
texture = null;
return false;
}
public void EnsureLoading(bool allowAnimation, Action<EmoteEntry, bool> queueDownload, bool allowWhenStaticLoaded = false)
{
if (_animation is not null)
{
return;
}
if (!allowWhenStaticLoaded && _staticTexture is not null)
{
return;
}
@@ -258,12 +737,22 @@ public sealed class ChatEmoteService : IDisposable
return;
}
queueDownload(this);
queueDownload(this, allowAnimation);
}
public void SetTexture(IDalamudTextureWrap texture)
public void SetAnimation(EmoteAnimation animation)
{
Texture = texture;
_staticTexture?.Dispose();
_staticTexture = null;
_animation?.Dispose();
_animation = animation;
Interlocked.Exchange(ref _loadingState, 0);
}
public void SetStaticTexture(IDalamudTextureWrap texture)
{
_staticTexture?.Dispose();
_staticTexture = texture;
Interlocked.Exchange(ref _loadingState, 0);
}
@@ -271,5 +760,11 @@ public sealed class ChatEmoteService : IDisposable
{
Interlocked.Exchange(ref _loadingState, 0);
}
public void Dispose()
{
_animation?.Dispose();
_staticTexture?.Dispose();
}
}
}

View File

@@ -22,8 +22,10 @@ using LightlessSync.Utils;
using Lumina.Excel.Sheets;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
@@ -843,31 +845,41 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return Task.CompletedTask;
}
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null)
public async Task WaitWhileCharacterIsDrawing(
ILogger logger,
GameObjectHandler handler,
Guid redrawId,
int timeOut = 5000,
CancellationToken? ct = null)
{
if (!_clientState.IsLoggedIn) return;
if (ct == null)
ct = CancellationToken.None;
var token = ct ?? CancellationToken.None;
const int tick = 250;
const int initialSettle = 50;
var sw = Stopwatch.StartNew();
const int tick = 250;
int curWaitTime = 0;
try
{
logger.LogTrace("[{redrawId}] Starting wait for {handler} to draw", redrawId, handler);
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
curWaitTime += tick;
while ((!ct.Value.IsCancellationRequested)
&& curWaitTime < timeOut
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something
await Task.Delay(initialSettle, token).ConfigureAwait(false);
while (!token.IsCancellationRequested
&& sw.ElapsedMilliseconds < timeOut
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false))
{
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
curWaitTime += tick;
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
await Task.Delay(tick, token).ConfigureAwait(false);
}
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
logger.LogTrace("[{redrawId}] Finished drawing after {ms}ms", redrawId, sw.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
// ignore
}
catch (AccessViolationException ex)
{
@@ -1032,7 +1044,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (actor.ObjectIndex >= 200)
continue;
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, out bool firstTime) && firstTime)
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime)
{
_logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X"));
continue;

View File

@@ -83,12 +83,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
var now = DateTime.UtcNow;
foreach (var address in _actorTracker.PlayerAddresses)
foreach (var descriptor in _actorTracker.PlayerDescriptors)
{
if (address == nint.Zero)
if (string.IsNullOrEmpty(descriptor.HashedContentId))
continue;
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
var cid = descriptor.HashedContentId;
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)

View File

@@ -73,7 +73,7 @@ public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
public record ResumeScanMessage(string Source) : MessageBase;
public record FileCacheInitializedMessage : MessageBase;
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, IReadOnlyDictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
@@ -104,6 +104,7 @@ public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase;
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
public record TargetPairMessage(Pair Pair) : MessageBase;
public record PairFocusCharacterMessage(Pair Pair) : SameThreadMessage;
public record PairOnlineMessage(PairUniqueIdentifier PairIdent) : MessageBase;
public record CombatStartMessage : MessageBase;
public record CombatEndMessage : MessageBase;
public record PerformanceStartMessage : MessageBase;
@@ -138,4 +139,4 @@ public record OpenUserProfileMessage(UserData User) : MessageBase;
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
public record MapChangedMessage(uint MapId) : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name
#pragma warning restore MA0048 // File name must match type name

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,381 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Globalization;
namespace LightlessSync.Services.ModelDecimation;
public sealed class ModelDecimationService
{
private const int MaxConcurrentJobs = 1;
private const double MinTargetRatio = 0.01;
private const double MaxTargetRatio = 0.99;
private readonly ILogger<ModelDecimationService> _logger;
private readonly LightlessConfigService _configService;
private readonly FileCacheManager _fileCacheManager;
private readonly PlayerPerformanceConfigService _performanceConfigService;
private readonly XivDataStorageService _xivDataStorageService;
private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs);
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase);
public ModelDecimationService(
ILogger<ModelDecimationService> logger,
LightlessConfigService configService,
FileCacheManager fileCacheManager,
PlayerPerformanceConfigService performanceConfigService,
XivDataStorageService xivDataStorageService)
{
_logger = logger;
_configService = configService;
_fileCacheManager = fileCacheManager;
_performanceConfigService = performanceConfigService;
_xivDataStorageService = xivDataStorageService;
}
public void ScheduleDecimation(string hash, string filePath, string? gamePath = null)
{
if (!ShouldScheduleDecimation(hash, filePath, gamePath))
{
return;
}
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _activeJobs.ContainsKey(hash))
{
return;
}
_logger.LogInformation("Queued model decimation for {Hash}", hash);
_activeJobs[hash] = Task.Run(async () =>
{
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
await DecimateInternalAsync(hash, filePath).ConfigureAwait(false);
}
catch (Exception ex)
{
_failedHashes[hash] = 1;
_logger.LogWarning(ex, "Model decimation failed for {Hash}", hash);
}
finally
{
_decimationSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}, CancellationToken.None);
}
public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null)
=> IsDecimationEnabled()
&& filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
&& IsDecimationAllowed(gamePath)
&& !ShouldSkipByTriangleCache(hash);
public string GetPreferredPath(string hash, string originalPath)
{
if (!IsDecimationEnabled())
{
return originalPath;
}
if (_decimatedPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
{
return existing;
}
var resolved = GetExistingDecimatedPath(hash);
if (!string.IsNullOrEmpty(resolved))
{
_decimatedPaths[hash] = resolved;
return resolved;
}
return originalPath;
}
public Task WaitForPendingJobsAsync(IEnumerable<string>? hashes, CancellationToken token)
{
if (hashes is null)
{
return Task.CompletedTask;
}
var pending = new List<Task>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var hash in hashes)
{
if (string.IsNullOrEmpty(hash) || !seen.Add(hash))
{
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
{
pending.Add(job);
}
}
if (pending.Count == 0)
{
return Task.CompletedTask;
}
return Task.WhenAll(pending).WaitAsync(token);
}
private Task DecimateInternalAsync(string hash, string sourcePath)
{
if (!File.Exists(sourcePath))
{
_failedHashes[hash] = 1;
_logger.LogWarning("Cannot decimate model {Hash}; source path missing: {Path}", hash, sourcePath);
return Task.CompletedTask;
}
if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio))
{
_logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash);
return Task.CompletedTask;
}
_logger.LogInformation("Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##})", hash, triangleThreshold, targetRatio);
var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
if (File.Exists(destination))
{
RegisterDecimatedModel(hash, sourcePath, destination);
return Task.CompletedTask;
}
if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, _logger))
{
_failedHashes[hash] = 1;
_logger.LogInformation("Model decimation skipped for {Hash}", hash);
return Task.CompletedTask;
}
RegisterDecimatedModel(hash, sourcePath, destination);
_logger.LogInformation("Decimated model {Hash} -> {Path}", hash, destination);
return Task.CompletedTask;
}
private void RegisterDecimatedModel(string hash, string sourcePath, string destination)
{
_decimatedPaths[hash] = destination;
var performanceConfig = _performanceConfigService.Current;
if (performanceConfig.KeepOriginalModelFiles)
{
return;
}
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!TryReplaceCacheEntryWithDecimated(hash, sourcePath, destination))
{
return;
}
TryDelete(sourcePath);
}
private bool TryReplaceCacheEntryWithDecimated(string hash, string sourcePath, string destination)
{
try
{
var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntry is null || !cacheEntry.IsCacheEntry)
{
return File.Exists(sourcePath) ? false : true;
}
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder))
{
return false;
}
if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var info = new FileInfo(destination);
if (!info.Exists)
{
return false;
}
var relative = Path.GetRelativePath(cacheFolder, destination)
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
var replacement = new FileCacheEntity(
hash,
prefixed,
info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
info.Length,
cacheEntry.CompressedSize);
replacement.SetResolvedFilePath(destination);
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
_fileCacheManager.WriteOutFullCsv();
_logger.LogTrace("Replaced cache entry for model {Hash} to decimated path {Path}", hash, destination);
return true;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to replace cache entry for model {Hash}", hash);
return false;
}
}
private bool IsDecimationEnabled()
=> _performanceConfigService.Current.EnableModelDecimation;
private bool ShouldSkipByTriangleCache(string hash)
{
if (string.IsNullOrEmpty(hash))
{
return false;
}
if (!_xivDataStorageService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) || cachedTris <= 0)
{
return false;
}
var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold);
return threshold > 0 && cachedTris < threshold;
}
private bool IsDecimationAllowed(string? gamePath)
{
if (string.IsNullOrWhiteSpace(gamePath))
{
return true;
}
var normalized = NormalizeGamePath(gamePath);
if (normalized.Contains("/hair/", StringComparison.Ordinal))
{
return false;
}
if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowClothing;
}
if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowAccessories;
}
if (normalized.Contains("/chara/human/", StringComparison.Ordinal))
{
if (normalized.Contains("/body/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowBody;
}
if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowFaceHead;
}
if (normalized.Contains("/tail/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowTail;
}
}
return true;
}
private static string NormalizeGamePath(string path)
=> path.Replace('\\', '/').ToLowerInvariant();
private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio)
{
triangleThreshold = 15_000;
targetRatio = 0.8;
var config = _performanceConfigService.Current;
if (!config.EnableModelDecimation)
{
return false;
}
triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold);
targetRatio = config.ModelDecimationTargetRatio;
if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio))
{
return false;
}
targetRatio = Math.Clamp(targetRatio, MinTargetRatio, MaxTargetRatio);
return true;
}
private string? GetExistingDecimatedPath(string hash)
{
var candidate = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
return File.Exists(candidate) ? candidate : null;
}
private string GetDecimatedDirectory()
{
var directory = Path.Combine(_configService.Current.CacheFolder, "decimated");
if (!Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to create decimated directory {Directory}", directory);
}
}
return directory;
}
private static void TryDelete(string? path)
{
if (string.IsNullOrEmpty(path))
{
return;
}
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignored
}
}
}

View File

@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI;
using LightlessSync.WebAPI.Files.Models;
@@ -18,12 +19,14 @@ public class PlayerPerformanceService
private readonly ILogger<PlayerPerformanceService> _logger;
private readonly LightlessMediator _mediator;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly ModelDecimationService _modelDecimationService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly Dictionary<string, bool> _warnedForPlayers = new(StringComparer.Ordinal);
public PlayerPerformanceService(ILogger<PlayerPerformanceService> logger, LightlessMediator mediator,
PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager,
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService)
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService)
{
_logger = logger;
_mediator = mediator;
@@ -31,6 +34,7 @@ public class PlayerPerformanceService
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = xivDataAnalyzer;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
}
public async Task<bool> CheckBothThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData)
@@ -111,10 +115,12 @@ public class PlayerPerformanceService
var config = _playerPerformanceConfigService.Current;
long triUsage = 0;
long effectiveTriUsage = 0;
if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List<FileReplacementData>? playerReplacements))
{
pairHandler.LastAppliedDataTris = 0;
pairHandler.LastAppliedApproximateEffectiveTris = 0;
return true;
}
@@ -123,14 +129,40 @@ public class PlayerPerformanceService
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var skipDecimation = config.SkipModelDecimationForPreferredPairs && pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
foreach (var hash in moddedModelHashes)
{
triUsage += await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
var tris = await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
triUsage += tris;
long effectiveTris = tris;
var fileEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (fileEntry != null)
{
var preferredPath = fileEntry.ResolvedFilepath;
if (!skipDecimation)
{
preferredPath = _modelDecimationService.GetPreferredPath(hash, fileEntry.ResolvedFilepath);
}
if (!string.Equals(preferredPath, fileEntry.ResolvedFilepath, StringComparison.OrdinalIgnoreCase))
{
var decimatedTris = await _xivDataAnalyzer.GetEffectiveTrianglesByHash(hash, preferredPath).ConfigureAwait(false);
if (decimatedTris > 0)
{
effectiveTris = decimatedTris;
}
}
}
effectiveTriUsage += effectiveTris;
}
pairHandler.LastAppliedDataTris = triUsage;
pairHandler.LastAppliedApproximateEffectiveTris = effectiveTriUsage;
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
_logger.LogDebug("Calculated triangle usage for {p}", pairHandler);
// no warning of any kind on ignored pairs
if (config.UIDsToIgnore
@@ -167,7 +199,9 @@ public class PlayerPerformanceService
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles)
{
var config = _playerPerformanceConfigService.Current;
bool skipDownscale = pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
bool skipDownscale = config.SkipTextureDownscaleForPreferredPairs
&& pairHandler.IsDirectlyPaired
&& pairHandler.HasStickyPermissions;
long vramUsage = 0;
long effectiveVramUsage = 0;
@@ -274,4 +308,4 @@ public class PlayerPerformanceService
private static bool CheckForThreshold(bool thresholdEnabled, long threshold, long value, bool checkForPrefPerm, bool isPrefPerm) =>
thresholdEnabled && threshold > 0 && threshold < value && ((checkForPrefPerm && isPrefPerm) || !isPrefPerm);
}
}

View File

@@ -77,16 +77,39 @@ public sealed class TextureDownscaleService
}
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
=> ScheduleDownscale(hash, filePath, () => mapKind);
public void ScheduleDownscale(string hash, string filePath, Func<TextureMapKind> mapKindFactory)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
if (_activeJobs.ContainsKey(hash)) return;
_activeJobs[hash] = Task.Run(async () =>
{
TextureMapKind mapKind;
try
{
mapKind = mapKindFactory();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to determine texture map kind for {Hash}; skipping downscale", hash);
return;
}
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, CancellationToken.None);
}
public bool ShouldScheduleDownscale(string filePath)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
return false;
var performanceConfig = _playerPerformanceConfigService.Current;
return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale;
}
public string GetPreferredPath(string hash, string originalPath)
{
if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
@@ -655,7 +678,7 @@ public sealed class TextureDownscaleService
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);

View File

@@ -6,18 +6,22 @@ using FFXIVClientStructs.Havok.Common.Serialize.Util;
using LightlessSync.FileCache;
using LightlessSync.Interop.GameModel;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace LightlessSync.Services;
public sealed class XivDataAnalyzer
public sealed partial class XivDataAnalyzer
{
private readonly ILogger<XivDataAnalyzer> _logger;
private readonly FileCacheManager _fileCacheManager;
private readonly XivDataStorageService _configService;
private readonly List<string> _failedCalculatedTris = [];
private readonly List<string> _failedCalculatedEffectiveTris = [];
public XivDataAnalyzer(ILogger<XivDataAnalyzer> logger, FileCacheManager fileCacheManager,
XivDataStorageService configService)
@@ -29,127 +33,441 @@ public sealed class XivDataAnalyzer
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
{
if (handler.Address == nint.Zero) return null;
var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject);
if (chara->GetModelType() != CharacterBase.ModelType.Human) return null;
var resHandles = chara->Skeleton->SkeletonResourceHandles;
Dictionary<string, List<ushort>> outputIndices = [];
if (handler is null || handler.Address == nint.Zero)
return null;
Dictionary<string, HashSet<ushort>> sets = new(StringComparer.OrdinalIgnoreCase);
try
{
for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++)
var drawObject = ((Character*)handler.Address)->GameObject.DrawObject;
if (drawObject == null)
return null;
var chara = (CharacterBase*)drawObject;
if (chara->GetModelType() != CharacterBase.ModelType.Human)
return null;
var skeleton = chara->Skeleton;
if (skeleton == null)
return null;
var resHandles = skeleton->SkeletonResourceHandles;
var partialCount = skeleton->PartialSkeletonCount;
if (partialCount <= 0)
return null;
for (int i = 0; i < partialCount; i++)
{
var handle = *(resHandles + i);
_logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X"));
if ((nint)handle == nint.Zero) continue;
var curBones = handle->BoneCount;
// this is unrealistic, the filename shouldn't ever be that long
if (handle->FileName.Length > 1024) continue;
var skeletonName = handle->FileName.ToString();
if (string.IsNullOrEmpty(skeletonName)) continue;
outputIndices[skeletonName] = [];
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
if ((nint)handle == nint.Zero)
continue;
if (handle->FileName.Length > 1024)
continue;
var rawName = handle->FileName.ToString();
if (string.IsNullOrWhiteSpace(rawName))
continue;
var skeletonKey = CanonicalizeSkeletonKey(rawName);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneCount = handle->BoneCount;
if (boneCount == 0)
continue;
var havokSkel = handle->HavokSkeleton;
if ((nint)havokSkel == nint.Zero)
continue;
if (!sets.TryGetValue(skeletonKey, out var set))
{
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String;
if (boneName == null) continue;
outputIndices[skeletonName].Add((ushort)(boneIdx + 1));
set = [];
sets[skeletonKey] = set;
}
uint maxExclusive = boneCount;
uint ushortExclusive = (uint)ushort.MaxValue + 1u;
if (maxExclusive > ushortExclusive)
maxExclusive = ushortExclusive;
for (uint boneIdx = 0; boneIdx < maxExclusive; boneIdx++)
{
var name = havokSkel->Bones[boneIdx].Name.String;
if (name == null)
continue;
set.Add((ushort)boneIdx);
}
_logger.LogTrace("Local skeleton raw file='{raw}', key='{key}', boneCount={count}",
rawName, skeletonKey, boneCount);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not process skeleton data");
return null;
}
return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null;
if (sets.Count == 0)
return null;
var output = new Dictionary<string, List<ushort>>(sets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in sets)
{
if (set.Count == 0)
continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
}
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash, bool persistToConfig = true)
{
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
if (string.IsNullOrWhiteSpace(hash))
return null;
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var cached) && cached is not null)
return cached;
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntity == null) return null;
if (cacheEntity == null || string.IsNullOrEmpty(cacheEntity.ResolvedFilepath) || !File.Exists(cacheEntity.ResolvedFilepath))
return null;
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(fs);
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium:
reader.ReadInt32(); // ignore
reader.ReadInt32(); // ignore
reader.ReadInt16(); // read 2 (num animations)
reader.ReadInt16(); // read 2 (modelid)
var type = reader.ReadByte();// read 1 (type)
if (type != 0) return null; // it's not human, just ignore it, whatever
// PAP header (mostly from vfxeditor)
_ = reader.ReadInt32(); // ignore
_ = reader.ReadInt32(); // ignore
_ = reader.ReadInt16(); // num animations
_ = reader.ReadInt16(); // modelid
var type = reader.ReadByte(); // type
if (type != 0)
return null; // not human
_ = reader.ReadByte(); // variant
_ = reader.ReadInt32(); // ignore
reader.ReadByte(); // read 1 (variant)
reader.ReadInt32(); // ignore
var havokPosition = reader.ReadInt32();
var footerPosition = reader.ReadInt32();
var havokDataSize = footerPosition - havokPosition;
// sanity checks
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
return null;
var havokDataSizeLong = (long)footerPosition - havokPosition;
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
return null;
var havokDataSize = (int)havokDataSizeLong;
reader.BaseStream.Position = havokPosition;
var havokData = reader.ReadBytes(havokDataSize);
if (havokData.Length <= 8) return null; // no havok data
if (havokData.Length <= 8)
return null;
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
try
{
File.WriteAllBytes(tempHavokDataPath, havokData);
if (!File.Exists(tempHavokDataPath))
{
_logger.LogTrace("Temporary havok file did not exist when attempting to load: {path}", tempHavokDataPath);
return null;
}
tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
{
Storage = (int)(hkSerializeUtil.LoadOptionBits.Default)
Storage = (int)hkSerializeUtil.LoadOptionBits.Default
};
var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
if (resource == null)
{
throw new InvalidOperationException("Resource was null after loading");
_logger.LogWarning("Havok resource was null after loading from {path}", tempHavokDataPath);
return null;
}
var rootLevelName = @"hkRootLevelContainer"u8;
fixed (byte* n1 = rootLevelName)
{
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
if (container == null)
return null;
var animationName = @"hkaAnimationContainer"u8;
fixed (byte* n2 = animationName)
{
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
if (animContainer == null)
return null;
for (int i = 0; i < animContainer->Bindings.Length; i++)
{
var binding = animContainer->Bindings[i].ptr;
if (binding == null)
continue;
var rawSkel = binding->OriginalSkeletonName.String;
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneTransform = binding->TransformTrackToBoneIndices;
string name = binding->OriginalSkeletonName.String! + "_" + i;
output[name] = [];
if (boneTransform.Length <= 0)
continue;
if (!tempSets.TryGetValue(skeletonKey, out var set))
{
set = [];
tempSets[skeletonKey] = set;
}
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
{
output[name].Add((ushort)boneTransform[boneIdx]);
var v = boneTransform[boneIdx];
if (v < 0) continue;
set.Add((ushort)v);
}
output[name].Sort();
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
return null;
}
finally
{
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
File.Delete(tempHavokDataPath);
if (tempHavokDataPathAnsi != IntPtr.Zero)
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
try
{
if (File.Exists(tempHavokDataPath))
File.Delete(tempHavokDataPath);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Could not delete temporary havok file: {path}", tempHavokDataPath);
}
}
if (tempSets.Count == 0)
return null;
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in tempSets)
{
if (set.Count == 0) continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
if (output.Count == 0)
return null;
_configService.Current.BonesDictionary[hash] = output;
_configService.Save();
if (persistToConfig)
_configService.Save();
return output;
}
public static string CanonicalizeSkeletonKey(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
var s = raw.Replace('\\', '/').Trim();
var underscore = s.LastIndexOf('_');
if (underscore > 0 && underscore + 1 < s.Length && char.IsDigit(s[underscore + 1]))
s = s[..underscore];
if (s.StartsWith("skeleton", StringComparison.OrdinalIgnoreCase))
return "skeleton";
var m = _bucketPathRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
m = _bucketSklRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
m = _bucketLooseRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
return string.Empty;
}
public static bool ContainsIndexCompat(
HashSet<ushort> available,
ushort idx,
bool papLikelyOneBased,
bool allowOneBasedShift,
bool allowNeighborTolerance)
{
Span<ushort> candidates = stackalloc ushort[2];
int count = 0;
candidates[count++] = idx;
if (allowOneBasedShift && papLikelyOneBased && idx > 0)
candidates[count++] = (ushort)(idx - 1);
for (int i = 0; i < count; i++)
{
var c = candidates[i];
if (available.Contains(c))
return true;
if (allowNeighborTolerance)
{
if (c > 0 && available.Contains((ushort)(c - 1)))
return true;
if (c < ushort.MaxValue && available.Contains((ushort)(c + 1)))
return true;
}
}
return false;
}
public static bool IsPapCompatible(
IReadOnlyDictionary<string, HashSet<ushort>> localBoneSets,
IReadOnlyDictionary<string, List<ushort>> papBoneIndices,
AnimationValidationMode mode,
bool allowOneBasedShift,
bool allowNeighborTolerance,
out string reason)
{
reason = string.Empty;
if (mode == AnimationValidationMode.Unsafe)
return true;
var papBuckets = papBoneIndices.Keys
.Select(CanonicalizeSkeletonKey)
.Where(k => !string.IsNullOrEmpty(k))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (papBuckets.Count == 0)
{
reason = "No skeleton bucket bindings found in the PAP";
return false;
}
if (mode == AnimationValidationMode.Safe)
{
if (papBuckets.Any(b => localBoneSets.ContainsKey(b)))
return true;
reason = $"No matching skeleton bucket between PAP [{string.Join(", ", papBuckets)}] and local [{string.Join(", ", localBoneSets.Keys.Order())}].";
return false;
}
foreach (var bucket in papBuckets)
{
if (!localBoneSets.TryGetValue(bucket, out var available))
{
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
return false;
}
var indices = papBoneIndices
.Where(kvp => string.Equals(CanonicalizeSkeletonKey(kvp.Key), bucket, StringComparison.OrdinalIgnoreCase))
.SelectMany(kvp => kvp.Value ?? Enumerable.Empty<ushort>())
.Distinct()
.ToList();
if (indices.Count == 0)
continue;
bool has0 = false, has1 = false;
ushort min = ushort.MaxValue;
foreach (var v in indices)
{
if (v == 0) has0 = true;
if (v == 1) has1 = true;
if (v < min) min = v;
}
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
foreach (var idx in indices)
{
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
{
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {idx}.";
return false;
}
}
}
return true;
}
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
{
var skels = GetSkeletonBoneIndices(handler);
if (skels == null)
{
_logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found");
return;
}
var keys = skels.Keys
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
_logger.LogTrace("Local skeleton indices found ({count}): {keys}",
keys.Length,
string.Join(", ", keys));
if (!string.IsNullOrWhiteSpace(filter))
{
var hits = keys.Where(k =>
k.Equals(filter, StringComparison.OrdinalIgnoreCase) ||
k.StartsWith(filter + "_", StringComparison.OrdinalIgnoreCase) ||
filter.StartsWith(k + "_", StringComparison.OrdinalIgnoreCase) ||
k.Contains(filter, StringComparison.OrdinalIgnoreCase))
.ToArray();
_logger.LogTrace("Matches found for '{filter}': {hits}",
filter,
hits.Length == 0 ? "<none>" : string.Join(", ", hits));
}
}
public async Task<long> GetTrianglesByHash(string hash)
{
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
@@ -162,16 +480,41 @@ public sealed class XivDataAnalyzer
if (path == null || !path.ResolvedFilepath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
return 0;
var filePath = path.ResolvedFilepath;
return CalculateTrianglesFromPath(hash, path.ResolvedFilepath, _configService.Current.TriangleDictionary, _failedCalculatedTris);
}
public async Task<long> GetEffectiveTrianglesByHash(string hash, string filePath)
{
if (_configService.Current.EffectiveTriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
return cachedTris;
if (_failedCalculatedEffectiveTris.Contains(hash, StringComparer.Ordinal))
return 0;
if (string.IsNullOrEmpty(filePath)
|| !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
|| !File.Exists(filePath))
{
return 0;
}
return CalculateTrianglesFromPath(hash, filePath, _configService.Current.EffectiveTriangleDictionary, _failedCalculatedEffectiveTris);
}
private long CalculateTrianglesFromPath(
string hash,
string filePath,
ConcurrentDictionary<string, long> cache,
List<string> failedList)
{
try
{
_logger.LogDebug("Detected Model File {path}, calculating Tris", filePath);
var file = new MdlFile(filePath);
if (file.LodCount <= 0)
{
_failedCalculatedTris.Add(hash);
_configService.Current.TriangleDictionary[hash] = 0;
failedList.Add(hash);
cache[hash] = 0;
_configService.Save();
return 0;
}
@@ -195,7 +538,7 @@ public sealed class XivDataAnalyzer
if (tris > 0)
{
_logger.LogDebug("TriAnalysis: {filePath} => {tris} triangles", filePath, tris);
_configService.Current.TriangleDictionary[hash] = tris;
cache[hash] = tris;
_configService.Save();
break;
}
@@ -205,11 +548,30 @@ public sealed class XivDataAnalyzer
}
catch (Exception e)
{
_failedCalculatedTris.Add(hash);
_configService.Current.TriangleDictionary[hash] = 0;
failedList.Add(hash);
cache[hash] = 0;
_configService.Save();
_logger.LogWarning(e, "Could not parse file {file}", filePath);
return 0;
}
}
// Regexes for canonicalizing skeleton keys
private static readonly Regex _bucketPathRegex =
BucketRegex();
private static readonly Regex _bucketSklRegex =
SklRegex();
private static readonly Regex _bucketLooseRegex =
LooseBucketRegex();
[GeneratedRegex(@"(?i)(?:^|/)(?<bucket>c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")]
private static partial Regex BucketRegex();
[GeneratedRegex(@"(?i)\bskl_(?<bucket>c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")]
private static partial Regex SklRegex();
[GeneratedRegex(@"(?i)(?<![a-z0-9])(?<bucket>c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")]
private static partial Regex LooseBucketRegex();
}

View File

@@ -0,0 +1,169 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using Microsoft.Extensions.Logging;
namespace MeshDecimator.Algorithms
{
/// <summary>
/// A decimation algorithm.
/// </summary>
public abstract class DecimationAlgorithm
{
#region Delegates
/// <summary>
/// A callback for decimation status reports.
/// </summary>
/// <param name="iteration">The current iteration, starting at zero.</param>
/// <param name="originalTris">The original count of triangles.</param>
/// <param name="currentTris">The current count of triangles.</param>
/// <param name="targetTris">The target count of triangles.</param>
public delegate void StatusReportCallback(int iteration, int originalTris, int currentTris, int targetTris);
#endregion
#region Fields
private bool preserveBorders = false;
private int maxVertexCount = 0;
private bool verbose = false;
private StatusReportCallback statusReportInvoker = null;
#endregion
#region Properties
/// <summary>
/// Gets or sets if borders should be kept.
/// Default value: false
/// </summary>
[Obsolete("Use the 'DecimationAlgorithm.PreserveBorders' property instead.", false)]
public bool KeepBorders
{
get { return preserveBorders; }
set { preserveBorders = value; }
}
/// <summary>
/// Gets or sets if borders should be preserved.
/// Default value: false
/// </summary>
public bool PreserveBorders
{
get { return preserveBorders; }
set { preserveBorders = value; }
}
/// <summary>
/// Gets or sets if linked vertices should be kept.
/// Default value: false
/// </summary>
[Obsolete("This feature has been removed, for more details why please read the readme.", true)]
public bool KeepLinkedVertices
{
get { return false; }
set { }
}
/// <summary>
/// Gets or sets the maximum vertex count. Set to zero for no limitation.
/// Default value: 0 (no limitation)
/// </summary>
public int MaxVertexCount
{
get { return maxVertexCount; }
set { maxVertexCount = Math.MathHelper.Max(value, 0); }
}
/// <summary>
/// Gets or sets if verbose information should be printed in the console.
/// Default value: false
/// </summary>
public bool Verbose
{
get { return verbose; }
set { verbose = value; }
}
/// <summary>
/// Gets or sets the logger used for diagnostics.
/// </summary>
public ILogger? Logger { get; set; }
#endregion
#region Events
/// <summary>
/// An event for status reports for this algorithm.
/// </summary>
public event StatusReportCallback StatusReport
{
add { statusReportInvoker += value; }
remove { statusReportInvoker -= value; }
}
#endregion
#region Protected Methods
/// <summary>
/// Reports the current status of the decimation.
/// </summary>
/// <param name="iteration">The current iteration, starting at zero.</param>
/// <param name="originalTris">The original count of triangles.</param>
/// <param name="currentTris">The current count of triangles.</param>
/// <param name="targetTris">The target count of triangles.</param>
protected void ReportStatus(int iteration, int originalTris, int currentTris, int targetTris)
{
var statusReportInvoker = this.statusReportInvoker;
if (statusReportInvoker != null)
{
statusReportInvoker.Invoke(iteration, originalTris, currentTris, targetTris);
}
}
#endregion
#region Public Methods
/// <summary>
/// Initializes the algorithm with the original mesh.
/// </summary>
/// <param name="mesh">The mesh.</param>
public abstract void Initialize(Mesh mesh);
/// <summary>
/// Decimates the mesh.
/// </summary>
/// <param name="targetTrisCount">The target triangle count.</param>
public abstract void DecimateMesh(int targetTrisCount);
/// <summary>
/// Decimates the mesh without losing any quality.
/// </summary>
public abstract void DecimateMeshLossless();
/// <summary>
/// Returns the resulting mesh.
/// </summary>
/// <returns>The resulting mesh.</returns>
public abstract Mesh ToMesh();
#endregion
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,249 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using MeshDecimator.Math;
namespace MeshDecimator
{
/// <summary>
/// A bone weight.
/// </summary>
public struct BoneWeight : IEquatable<BoneWeight>
{
#region Fields
/// <summary>
/// The first bone index.
/// </summary>
public int boneIndex0;
/// <summary>
/// The second bone index.
/// </summary>
public int boneIndex1;
/// <summary>
/// The third bone index.
/// </summary>
public int boneIndex2;
/// <summary>
/// The fourth bone index.
/// </summary>
public int boneIndex3;
/// <summary>
/// The first bone weight.
/// </summary>
public float boneWeight0;
/// <summary>
/// The second bone weight.
/// </summary>
public float boneWeight1;
/// <summary>
/// The third bone weight.
/// </summary>
public float boneWeight2;
/// <summary>
/// The fourth bone weight.
/// </summary>
public float boneWeight3;
#endregion
#region Constructor
/// <summary>
/// Creates a new bone weight.
/// </summary>
/// <param name="boneIndex0">The first bone index.</param>
/// <param name="boneIndex1">The second bone index.</param>
/// <param name="boneIndex2">The third bone index.</param>
/// <param name="boneIndex3">The fourth bone index.</param>
/// <param name="boneWeight0">The first bone weight.</param>
/// <param name="boneWeight1">The second bone weight.</param>
/// <param name="boneWeight2">The third bone weight.</param>
/// <param name="boneWeight3">The fourth bone weight.</param>
public BoneWeight(int boneIndex0, int boneIndex1, int boneIndex2, int boneIndex3, float boneWeight0, float boneWeight1, float boneWeight2, float boneWeight3)
{
this.boneIndex0 = boneIndex0;
this.boneIndex1 = boneIndex1;
this.boneIndex2 = boneIndex2;
this.boneIndex3 = boneIndex3;
this.boneWeight0 = boneWeight0;
this.boneWeight1 = boneWeight1;
this.boneWeight2 = boneWeight2;
this.boneWeight3 = boneWeight3;
}
#endregion
#region Operators
/// <summary>
/// Returns if two bone weights equals eachother.
/// </summary>
/// <param name="lhs">The left hand side bone weight.</param>
/// <param name="rhs">The right hand side bone weight.</param>
/// <returns>If equals.</returns>
public static bool operator ==(BoneWeight lhs, BoneWeight rhs)
{
return (lhs.boneIndex0 == rhs.boneIndex0 && lhs.boneIndex1 == rhs.boneIndex1 && lhs.boneIndex2 == rhs.boneIndex2 && lhs.boneIndex3 == rhs.boneIndex3 &&
new Vector4(lhs.boneWeight0, lhs.boneWeight1, lhs.boneWeight2, lhs.boneWeight3) == new Vector4(rhs.boneWeight0, rhs.boneWeight1, rhs.boneWeight2, rhs.boneWeight3));
}
/// <summary>
/// Returns if two bone weights don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side bone weight.</param>
/// <param name="rhs">The right hand side bone weight.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(BoneWeight lhs, BoneWeight rhs)
{
return !(lhs == rhs);
}
#endregion
#region Private Methods
private void MergeBoneWeight(int boneIndex, float weight)
{
if (boneIndex == boneIndex0)
{
boneWeight0 = (boneWeight0 + weight) * 0.5f;
}
else if (boneIndex == boneIndex1)
{
boneWeight1 = (boneWeight1 + weight) * 0.5f;
}
else if (boneIndex == boneIndex2)
{
boneWeight2 = (boneWeight2 + weight) * 0.5f;
}
else if (boneIndex == boneIndex3)
{
boneWeight3 = (boneWeight3 + weight) * 0.5f;
}
else if(boneWeight0 == 0f)
{
boneIndex0 = boneIndex;
boneWeight0 = weight;
}
else if (boneWeight1 == 0f)
{
boneIndex1 = boneIndex;
boneWeight1 = weight;
}
else if (boneWeight2 == 0f)
{
boneIndex2 = boneIndex;
boneWeight2 = weight;
}
else if (boneWeight3 == 0f)
{
boneIndex3 = boneIndex;
boneWeight3 = weight;
}
Normalize();
}
private void Normalize()
{
float mag = (float)System.Math.Sqrt(boneWeight0 * boneWeight0 + boneWeight1 * boneWeight1 + boneWeight2 * boneWeight2 + boneWeight3 * boneWeight3);
if (mag > float.Epsilon)
{
boneWeight0 /= mag;
boneWeight1 /= mag;
boneWeight2 /= mag;
boneWeight3 /= mag;
}
else
{
boneWeight0 = boneWeight1 = boneWeight2 = boneWeight3 = 0f;
}
}
#endregion
#region Public Methods
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return boneIndex0.GetHashCode() ^ boneIndex1.GetHashCode() << 2 ^ boneIndex2.GetHashCode() >> 2 ^ boneIndex3.GetHashCode() >>
1 ^ boneWeight0.GetHashCode() << 5 ^ boneWeight1.GetHashCode() << 4 ^ boneWeight2.GetHashCode() >> 4 ^ boneWeight3.GetHashCode() >> 3;
}
/// <summary>
/// Returns if this bone weight is equal to another object.
/// </summary>
/// <param name="obj">The other object to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object obj)
{
if (!(obj is BoneWeight))
{
return false;
}
BoneWeight other = (BoneWeight)obj;
return (boneIndex0 == other.boneIndex0 && boneIndex1 == other.boneIndex1 && boneIndex2 == other.boneIndex2 && boneIndex3 == other.boneIndex3 &&
boneWeight0 == other.boneWeight0 && boneWeight1 == other.boneWeight1 && boneWeight2 == other.boneWeight2 && boneWeight3 == other.boneWeight3);
}
/// <summary>
/// Returns if this bone weight is equal to another one.
/// </summary>
/// <param name="other">The other bone weight to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(BoneWeight other)
{
return (boneIndex0 == other.boneIndex0 && boneIndex1 == other.boneIndex1 && boneIndex2 == other.boneIndex2 && boneIndex3 == other.boneIndex3 &&
boneWeight0 == other.boneWeight0 && boneWeight1 == other.boneWeight1 && boneWeight2 == other.boneWeight2 && boneWeight3 == other.boneWeight3);
}
/// <summary>
/// Returns a nicely formatted string for this bone weight.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}:{4:F1}, {1}:{5:F1}, {2}:{6:F1}, {3}:{7:F1})",
boneIndex0, boneIndex1, boneIndex2, boneIndex3, boneWeight0, boneWeight1, boneWeight2, boneWeight3);
}
#endregion
#region Static
/// <summary>
/// Merges two bone weights and stores the merged result in the first parameter.
/// </summary>
/// <param name="a">The first bone weight, also stores result.</param>
/// <param name="b">The second bone weight.</param>
public static void Merge(ref BoneWeight a, ref BoneWeight b)
{
if (b.boneWeight0 > 0f) a.MergeBoneWeight(b.boneIndex0, b.boneWeight0);
if (b.boneWeight1 > 0f) a.MergeBoneWeight(b.boneIndex1, b.boneWeight1);
if (b.boneWeight2 > 0f) a.MergeBoneWeight(b.boneIndex2, b.boneWeight2);
if (b.boneWeight3 > 0f) a.MergeBoneWeight(b.boneIndex3, b.boneWeight3);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,179 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
namespace MeshDecimator.Collections
{
/// <summary>
/// A resizable array.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
internal sealed class ResizableArray<T>
{
#region Fields
private T[] items = null;
private int length = 0;
private static T[] emptyArr = new T[0];
#endregion
#region Properties
/// <summary>
/// Gets the length of this array.
/// </summary>
public int Length
{
get { return length; }
}
/// <summary>
/// Gets the internal data buffer for this array.
/// </summary>
public T[] Data
{
get { return items; }
}
/// <summary>
/// Gets or sets the element value at a specific index.
/// </summary>
/// <param name="index">The element index.</param>
/// <returns>The element value.</returns>
public T this[int index]
{
get { return items[index]; }
set { items[index] = value; }
}
#endregion
#region Constructor
/// <summary>
/// Creates a new resizable array.
/// </summary>
/// <param name="capacity">The initial array capacity.</param>
public ResizableArray(int capacity)
: this(capacity, 0)
{
}
/// <summary>
/// Creates a new resizable array.
/// </summary>
/// <param name="capacity">The initial array capacity.</param>
/// <param name="length">The initial length of the array.</param>
public ResizableArray(int capacity, int length)
{
if (capacity < 0)
throw new ArgumentOutOfRangeException("capacity");
else if (length < 0 || length > capacity)
throw new ArgumentOutOfRangeException("length");
if (capacity > 0)
items = new T[capacity];
else
items = emptyArr;
this.length = length;
}
#endregion
#region Private Methods
private void IncreaseCapacity(int capacity)
{
T[] newItems = new T[capacity];
Array.Copy(items, 0, newItems, 0, System.Math.Min(length, capacity));
items = newItems;
}
#endregion
#region Public Methods
/// <summary>
/// Clears this array.
/// </summary>
public void Clear()
{
Array.Clear(items, 0, length);
length = 0;
}
/// <summary>
/// Resizes this array.
/// </summary>
/// <param name="length">The new length.</param>
/// <param name="trimExess">If exess memory should be trimmed.</param>
public void Resize(int length, bool trimExess = false)
{
if (length < 0)
throw new ArgumentOutOfRangeException("capacity");
if (length > items.Length)
{
IncreaseCapacity(length);
}
else if (length < this.length)
{
//Array.Clear(items, capacity, length - capacity);
}
this.length = length;
if (trimExess)
{
TrimExcess();
}
}
/// <summary>
/// Trims any excess memory for this array.
/// </summary>
public void TrimExcess()
{
if (items.Length == length) // Nothing to do
return;
T[] newItems = new T[length];
Array.Copy(items, 0, newItems, 0, length);
items = newItems;
}
/// <summary>
/// Adds a new item to the end of this array.
/// </summary>
/// <param name="item">The new item.</param>
public void Add(T item)
{
if (length >= items.Length)
{
IncreaseCapacity(items.Length << 1);
}
items[length++] = item;
}
#endregion
}
}

View File

@@ -0,0 +1,79 @@
using System;
namespace MeshDecimator.Collections
{
/// <summary>
/// A collection of UV channels.
/// </summary>
/// <typeparam name="TVec">The UV vector type.</typeparam>
internal sealed class UVChannels<TVec>
{
#region Fields
private ResizableArray<TVec>[] channels = null;
private TVec[][] channelsData = null;
#endregion
#region Properties
/// <summary>
/// Gets the channel collection data.
/// </summary>
public TVec[][] Data
{
get
{
for (int i = 0; i < Mesh.UVChannelCount; i++)
{
if (channels[i] != null)
{
channelsData[i] = channels[i].Data;
}
else
{
channelsData[i] = null;
}
}
return channelsData;
}
}
/// <summary>
/// Gets or sets a specific channel by index.
/// </summary>
/// <param name="index">The channel index.</param>
public ResizableArray<TVec> this[int index]
{
get { return channels[index]; }
set { channels[index] = value; }
}
#endregion
#region Constructor
/// <summary>
/// Creates a new collection of UV channels.
/// </summary>
public UVChannels()
{
channels = new ResizableArray<TVec>[Mesh.UVChannelCount];
channelsData = new TVec[Mesh.UVChannelCount][];
}
#endregion
#region Public Methods
/// <summary>
/// Resizes all channels at once.
/// </summary>
/// <param name="capacity">The new capacity.</param>
/// <param name="trimExess">If exess memory should be trimmed.</param>
public void Resize(int capacity, bool trimExess = false)
{
for (int i = 0; i < Mesh.UVChannelCount; i++)
{
if (channels[i] != null)
{
channels[i].Resize(capacity, trimExess);
}
}
}
#endregion
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,286 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
namespace MeshDecimator.Math
{
/// <summary>
/// Math helpers.
/// </summary>
public static class MathHelper
{
#region Consts
/// <summary>
/// The Pi constant.
/// </summary>
public const float PI = 3.14159274f;
/// <summary>
/// The Pi constant.
/// </summary>
public const double PId = 3.1415926535897932384626433832795;
/// <summary>
/// Degrees to radian constant.
/// </summary>
public const float Deg2Rad = PI / 180f;
/// <summary>
/// Degrees to radian constant.
/// </summary>
public const double Deg2Radd = PId / 180.0;
/// <summary>
/// Radians to degrees constant.
/// </summary>
public const float Rad2Deg = 180f / PI;
/// <summary>
/// Radians to degrees constant.
/// </summary>
public const double Rad2Degd = 180.0 / PId;
#endregion
#region Min
/// <summary>
/// Returns the minimum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The minimum value.</returns>
public static int Min(int val1, int val2)
{
return (val1 < val2 ? val1 : val2);
}
/// <summary>
/// Returns the minimum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The minimum value.</returns>
public static int Min(int val1, int val2, int val3)
{
return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3));
}
/// <summary>
/// Returns the minimum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The minimum value.</returns>
public static float Min(float val1, float val2)
{
return (val1 < val2 ? val1 : val2);
}
/// <summary>
/// Returns the minimum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The minimum value.</returns>
public static float Min(float val1, float val2, float val3)
{
return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3));
}
/// <summary>
/// Returns the minimum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The minimum value.</returns>
public static double Min(double val1, double val2)
{
return (val1 < val2 ? val1 : val2);
}
/// <summary>
/// Returns the minimum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The minimum value.</returns>
public static double Min(double val1, double val2, double val3)
{
return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3));
}
#endregion
#region Max
/// <summary>
/// Returns the maximum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The maximum value.</returns>
public static int Max(int val1, int val2)
{
return (val1 > val2 ? val1 : val2);
}
/// <summary>
/// Returns the maximum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The maximum value.</returns>
public static int Max(int val1, int val2, int val3)
{
return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3));
}
/// <summary>
/// Returns the maximum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The maximum value.</returns>
public static float Max(float val1, float val2)
{
return (val1 > val2 ? val1 : val2);
}
/// <summary>
/// Returns the maximum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The maximum value.</returns>
public static float Max(float val1, float val2, float val3)
{
return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3));
}
/// <summary>
/// Returns the maximum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The maximum value.</returns>
public static double Max(double val1, double val2)
{
return (val1 > val2 ? val1 : val2);
}
/// <summary>
/// Returns the maximum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The maximum value.</returns>
public static double Max(double val1, double val2, double val3)
{
return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3));
}
#endregion
#region Clamping
/// <summary>
/// Clamps a value between a minimum and a maximum value.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <param name="min">The minimum value.</param>
/// <param name="max">The maximum value.</param>
/// <returns>The clamped value.</returns>
public static float Clamp(float value, float min, float max)
{
return (value >= min ? (value <= max ? value : max) : min);
}
/// <summary>
/// Clamps a value between a minimum and a maximum value.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <param name="min">The minimum value.</param>
/// <param name="max">The maximum value.</param>
/// <returns>The clamped value.</returns>
public static double Clamp(double value, double min, double max)
{
return (value >= min ? (value <= max ? value : max) : min);
}
/// <summary>
/// Clamps the value between 0 and 1.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <returns>The clamped value.</returns>
public static float Clamp01(float value)
{
return (value > 0f ? (value < 1f ? value : 1f) : 0f);
}
/// <summary>
/// Clamps the value between 0 and 1.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <returns>The clamped value.</returns>
public static double Clamp01(double value)
{
return (value > 0.0 ? (value < 1.0 ? value : 1.0) : 0.0);
}
#endregion
#region Triangle Area
/// <summary>
/// Calculates the area of a triangle.
/// </summary>
/// <param name="p0">The first point.</param>
/// <param name="p1">The second point.</param>
/// <param name="p2">The third point.</param>
/// <returns>The triangle area.</returns>
public static float TriangleArea(ref Vector3 p0, ref Vector3 p1, ref Vector3 p2)
{
var dx = p1 - p0;
var dy = p2 - p0;
return dx.Magnitude * ((float)System.Math.Sin(Vector3.Angle(ref dx, ref dy) * Deg2Rad) * dy.Magnitude) * 0.5f;
}
/// <summary>
/// Calculates the area of a triangle.
/// </summary>
/// <param name="p0">The first point.</param>
/// <param name="p1">The second point.</param>
/// <param name="p2">The third point.</param>
/// <returns>The triangle area.</returns>
public static double TriangleArea(ref Vector3d p0, ref Vector3d p1, ref Vector3d p2)
{
var dx = p1 - p0;
var dy = p2 - p0;
return dx.Magnitude * (System.Math.Sin(Vector3d.Angle(ref dx, ref dy) * Deg2Radd) * dy.Magnitude) * 0.5f;
}
#endregion
}
}

View File

@@ -0,0 +1,303 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
namespace MeshDecimator.Math
{
/// <summary>
/// A symmetric matrix.
/// </summary>
public struct SymmetricMatrix
{
#region Fields
/// <summary>
/// The m11 component.
/// </summary>
public double m0;
/// <summary>
/// The m12 component.
/// </summary>
public double m1;
/// <summary>
/// The m13 component.
/// </summary>
public double m2;
/// <summary>
/// The m14 component.
/// </summary>
public double m3;
/// <summary>
/// The m22 component.
/// </summary>
public double m4;
/// <summary>
/// The m23 component.
/// </summary>
public double m5;
/// <summary>
/// The m24 component.
/// </summary>
public double m6;
/// <summary>
/// The m33 component.
/// </summary>
public double m7;
/// <summary>
/// The m34 component.
/// </summary>
public double m8;
/// <summary>
/// The m44 component.
/// </summary>
public double m9;
#endregion
#region Properties
/// <summary>
/// Gets the component value with a specific index.
/// </summary>
/// <param name="index">The component index.</param>
/// <returns>The value.</returns>
public double this[int index]
{
get
{
switch (index)
{
case 0:
return m0;
case 1:
return m1;
case 2:
return m2;
case 3:
return m3;
case 4:
return m4;
case 5:
return m5;
case 6:
return m6;
case 7:
return m7;
case 8:
return m8;
case 9:
return m9;
default:
throw new IndexOutOfRangeException();
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a symmetric matrix with a value in each component.
/// </summary>
/// <param name="c">The component value.</param>
public SymmetricMatrix(double c)
{
this.m0 = c;
this.m1 = c;
this.m2 = c;
this.m3 = c;
this.m4 = c;
this.m5 = c;
this.m6 = c;
this.m7 = c;
this.m8 = c;
this.m9 = c;
}
/// <summary>
/// Creates a symmetric matrix.
/// </summary>
/// <param name="m0">The m11 component.</param>
/// <param name="m1">The m12 component.</param>
/// <param name="m2">The m13 component.</param>
/// <param name="m3">The m14 component.</param>
/// <param name="m4">The m22 component.</param>
/// <param name="m5">The m23 component.</param>
/// <param name="m6">The m24 component.</param>
/// <param name="m7">The m33 component.</param>
/// <param name="m8">The m34 component.</param>
/// <param name="m9">The m44 component.</param>
public SymmetricMatrix(double m0, double m1, double m2, double m3,
double m4, double m5, double m6, double m7, double m8, double m9)
{
this.m0 = m0;
this.m1 = m1;
this.m2 = m2;
this.m3 = m3;
this.m4 = m4;
this.m5 = m5;
this.m6 = m6;
this.m7 = m7;
this.m8 = m8;
this.m9 = m9;
}
/// <summary>
/// Creates a symmetric matrix from a plane.
/// </summary>
/// <param name="a">The plane x-component.</param>
/// <param name="b">The plane y-component</param>
/// <param name="c">The plane z-component</param>
/// <param name="d">The plane w-component</param>
public SymmetricMatrix(double a, double b, double c, double d)
{
this.m0 = a * a;
this.m1 = a * b;
this.m2 = a * c;
this.m3 = a * d;
this.m4 = b * b;
this.m5 = b * c;
this.m6 = b * d;
this.m7 = c * c;
this.m8 = c * d;
this.m9 = d * d;
}
#endregion
#region Operators
/// <summary>
/// Adds two matrixes together.
/// </summary>
/// <param name="a">The left hand side.</param>
/// <param name="b">The right hand side.</param>
/// <returns>The resulting matrix.</returns>
public static SymmetricMatrix operator +(SymmetricMatrix a, SymmetricMatrix b)
{
return new SymmetricMatrix(
a.m0 + b.m0, a.m1 + b.m1, a.m2 + b.m2, a.m3 + b.m3,
a.m4 + b.m4, a.m5 + b.m5, a.m6 + b.m6,
a.m7 + b.m7, a.m8 + b.m8,
a.m9 + b.m9
);
}
#endregion
#region Internal Methods
/// <summary>
/// Determinant(0, 1, 2, 1, 4, 5, 2, 5, 7)
/// </summary>
/// <returns></returns>
internal double Determinant1()
{
double det =
m0 * m4 * m7 +
m2 * m1 * m5 +
m1 * m5 * m2 -
m2 * m4 * m2 -
m0 * m5 * m5 -
m1 * m1 * m7;
return det;
}
/// <summary>
/// Determinant(1, 2, 3, 4, 5, 6, 5, 7, 8)
/// </summary>
/// <returns></returns>
internal double Determinant2()
{
double det =
m1 * m5 * m8 +
m3 * m4 * m7 +
m2 * m6 * m5 -
m3 * m5 * m5 -
m1 * m6 * m7 -
m2 * m4 * m8;
return det;
}
/// <summary>
/// Determinant(0, 2, 3, 1, 5, 6, 2, 7, 8)
/// </summary>
/// <returns></returns>
internal double Determinant3()
{
double det =
m0 * m5 * m8 +
m3 * m1 * m7 +
m2 * m6 * m2 -
m3 * m5 * m2 -
m0 * m6 * m7 -
m2 * m1 * m8;
return det;
}
/// <summary>
/// Determinant(0, 1, 3, 1, 4, 6, 2, 5, 8)
/// </summary>
/// <returns></returns>
internal double Determinant4()
{
double det =
m0 * m4 * m8 +
m3 * m1 * m5 +
m1 * m6 * m2 -
m3 * m4 * m2 -
m0 * m6 * m5 -
m1 * m1 * m8;
return det;
}
#endregion
#region Public Methods
/// <summary>
/// Computes the determinant of this matrix.
/// </summary>
/// <param name="a11">The a11 index.</param>
/// <param name="a12">The a12 index.</param>
/// <param name="a13">The a13 index.</param>
/// <param name="a21">The a21 index.</param>
/// <param name="a22">The a22 index.</param>
/// <param name="a23">The a23 index.</param>
/// <param name="a31">The a31 index.</param>
/// <param name="a32">The a32 index.</param>
/// <param name="a33">The a33 index.</param>
/// <returns>The determinant value.</returns>
public double Determinant(int a11, int a12, int a13,
int a21, int a22, int a23,
int a31, int a32, int a33)
{
double det =
this[a11] * this[a22] * this[a33] +
this[a13] * this[a21] * this[a32] +
this[a12] * this[a23] * this[a31] -
this[a13] * this[a22] * this[a31] -
this[a11] * this[a23] * this[a32] -
this[a12] * this[a21] * this[a33];
return det;
}
#endregion
}
}

View File

@@ -0,0 +1,425 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A single precision 2D vector.
/// </summary>
public struct Vector2 : IEquatable<Vector2>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector2 zero = new Vector2(0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const float Epsilon = 9.99999944E-11f;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public float x;
/// <summary>
/// The y component.
/// </summary>
public float y;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public float Magnitude
{
get { return (float)System.Math.Sqrt(x * x + y * y); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public float MagnitudeSqr
{
get { return (x * x + y * y); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector2 Normalized
{
get
{
Vector2 result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public float this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
default:
throw new IndexOutOfRangeException("Invalid Vector2 index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector2 index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector2(float value)
{
this.x = value;
this.y = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public Vector2(float x, float y)
{
this.x = x;
this.y = y;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator +(Vector2 a, Vector2 b)
{
return new Vector2(a.x + b.x, a.y + b.y);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator -(Vector2 a, Vector2 b)
{
return new Vector2(a.x - b.x, a.y - b.y);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator *(Vector2 a, float d)
{
return new Vector2(a.x * d, a.y * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator *(float d, Vector2 a)
{
return new Vector2(a.x * d, a.y * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator /(Vector2 a, float d)
{
return new Vector2(a.x / d, a.y / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator -(Vector2 a)
{
return new Vector2(-a.x, -a.y);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector2 lhs, Vector2 rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector2 lhs, Vector2 rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Explicitly converts from a double-precision vector into a single-precision vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector2(Vector2d v)
{
return new Vector2((float)v.x, (float)v.y);
}
/// <summary>
/// Implicitly converts from an integer vector into a single-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector2(Vector2i v)
{
return new Vector2(v.x, v.y);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x and y components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public void Set(float x, float y)
{
this.x = x;
this.y = y;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector2 scale)
{
x *= scale.x;
y *= scale.y;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
float mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
}
else
{
x = y = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(float min, float max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector2))
{
return false;
}
Vector2 vector = (Vector2)other;
return (x == vector.x && y == vector.y);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector2 other)
{
return (x == other.x && y == other.y);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static float Dot(ref Vector2 lhs, ref Vector2 rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector2 a, ref Vector2 b, float t, out Vector2 result)
{
result = new Vector2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector2 a, ref Vector2 b, out Vector2 result)
{
result = new Vector2(a.x * b.x, a.y * b.y);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector2 value, out Vector2 result)
{
float mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector2(value.x / mag, value.y / mag);
}
else
{
result = Vector2.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,425 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A double precision 2D vector.
/// </summary>
public struct Vector2d : IEquatable<Vector2d>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector2d zero = new Vector2d(0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const double Epsilon = double.Epsilon;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public double x;
/// <summary>
/// The y component.
/// </summary>
public double y;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public double Magnitude
{
get { return System.Math.Sqrt(x * x + y * y); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public double MagnitudeSqr
{
get { return (x * x + y * y); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector2d Normalized
{
get
{
Vector2d result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public double this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
default:
throw new IndexOutOfRangeException("Invalid Vector2d index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector2d index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector2d(double value)
{
this.x = value;
this.y = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public Vector2d(double x, double y)
{
this.x = x;
this.y = y;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator +(Vector2d a, Vector2d b)
{
return new Vector2d(a.x + b.x, a.y + b.y);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator -(Vector2d a, Vector2d b)
{
return new Vector2d(a.x - b.x, a.y - b.y);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator *(Vector2d a, double d)
{
return new Vector2d(a.x * d, a.y * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator *(double d, Vector2d a)
{
return new Vector2d(a.x * d, a.y * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator /(Vector2d a, double d)
{
return new Vector2d(a.x / d, a.y / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator -(Vector2d a)
{
return new Vector2d(-a.x, -a.y);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector2d lhs, Vector2d rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector2d lhs, Vector2d rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Implicitly converts from a single-precision vector into a double-precision vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static implicit operator Vector2d(Vector2 v)
{
return new Vector2d(v.x, v.y);
}
/// <summary>
/// Implicitly converts from an integer vector into a double-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector2d(Vector2i v)
{
return new Vector2d(v.x, v.y);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x and y components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public void Set(double x, double y)
{
this.x = x;
this.y = y;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector2d scale)
{
x *= scale.x;
y *= scale.y;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
double mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
}
else
{
x = y = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(double min, double max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector2d))
{
return false;
}
Vector2d vector = (Vector2d)other;
return (x == vector.x && y == vector.y);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector2d other)
{
return (x == other.x && y == other.y);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static double Dot(ref Vector2d lhs, ref Vector2d rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector2d a, ref Vector2d b, double t, out Vector2d result)
{
result = new Vector2d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector2d a, ref Vector2d b, out Vector2d result)
{
result = new Vector2d(a.x * b.x, a.y * b.y);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector2d value, out Vector2d result)
{
double mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector2d(value.x / mag, value.y / mag);
}
else
{
result = Vector2d.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,348 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A 2D integer vector.
/// </summary>
public struct Vector2i : IEquatable<Vector2i>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector2i zero = new Vector2i(0, 0);
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public int x;
/// <summary>
/// The y component.
/// </summary>
public int y;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public int Magnitude
{
get { return (int)System.Math.Sqrt(x * x + y * y); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public int MagnitudeSqr
{
get { return (x * x + y * y); }
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public int this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
default:
throw new IndexOutOfRangeException("Invalid Vector2i index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector2i index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector2i(int value)
{
this.x = value;
this.y = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public Vector2i(int x, int y)
{
this.x = x;
this.y = y;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator +(Vector2i a, Vector2i b)
{
return new Vector2i(a.x + b.x, a.y + b.y);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator -(Vector2i a, Vector2i b)
{
return new Vector2i(a.x - b.x, a.y - b.y);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator *(Vector2i a, int d)
{
return new Vector2i(a.x * d, a.y * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator *(int d, Vector2i a)
{
return new Vector2i(a.x * d, a.y * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator /(Vector2i a, int d)
{
return new Vector2i(a.x / d, a.y / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator -(Vector2i a)
{
return new Vector2i(-a.x, -a.y);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector2i lhs, Vector2i rhs)
{
return (lhs.x == rhs.x && lhs.y == rhs.y);
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector2i lhs, Vector2i rhs)
{
return (lhs.x != rhs.x || lhs.y != rhs.y);
}
/// <summary>
/// Explicitly converts from a single-precision vector into an integer vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static explicit operator Vector2i(Vector2 v)
{
return new Vector2i((int)v.x, (int)v.y);
}
/// <summary>
/// Explicitly converts from a double-precision vector into an integer vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector2i(Vector2d v)
{
return new Vector2i((int)v.x, (int)v.y);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x and y components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public void Set(int x, int y)
{
this.x = x;
this.y = y;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector2i scale)
{
x *= scale.x;
y *= scale.y;
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(int min, int max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector2i))
{
return false;
}
Vector2i vector = (Vector2i)other;
return (x == vector.x && y == vector.y);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector2i other)
{
return (x == other.x && y == other.y);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1})",
x.ToString(CultureInfo.InvariantCulture),
y.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The integer format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector2i a, ref Vector2i b, out Vector2i result)
{
result = new Vector2i(a.x * b.x, a.y * b.y);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,494 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A single precision 3D vector.
/// </summary>
public struct Vector3 : IEquatable<Vector3>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector3 zero = new Vector3(0, 0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const float Epsilon = 9.99999944E-11f;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public float x;
/// <summary>
/// The y component.
/// </summary>
public float y;
/// <summary>
/// The z component.
/// </summary>
public float z;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public float Magnitude
{
get { return (float)System.Math.Sqrt(x * x + y * y + z * z); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public float MagnitudeSqr
{
get { return (x * x + y * y + z * z); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector3 Normalized
{
get
{
Vector3 result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public float this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
default:
throw new IndexOutOfRangeException("Invalid Vector3 index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector3 index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector3(float value)
{
this.x = value;
this.y = value;
this.z = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public Vector3(float x, float y, float z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Creates a new vector from a double precision vector.
/// </summary>
/// <param name="vector">The double precision vector.</param>
public Vector3(Vector3d vector)
{
this.x = (float)vector.x;
this.y = (float)vector.y;
this.z = (float)vector.z;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator +(Vector3 a, Vector3 b)
{
return new Vector3(a.x + b.x, a.y + b.y, a.z + b.z);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator -(Vector3 a, Vector3 b)
{
return new Vector3(a.x - b.x, a.y - b.y, a.z - b.z);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator *(Vector3 a, float d)
{
return new Vector3(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator *(float d, Vector3 a)
{
return new Vector3(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator /(Vector3 a, float d)
{
return new Vector3(a.x / d, a.y / d, a.z / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator -(Vector3 a)
{
return new Vector3(-a.x, -a.y, -a.z);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector3 lhs, Vector3 rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector3 lhs, Vector3 rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Explicitly converts from a double-precision vector into a single-precision vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector3(Vector3d v)
{
return new Vector3((float)v.x, (float)v.y, (float)v.z);
}
/// <summary>
/// Implicitly converts from an integer vector into a single-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector3(Vector3i v)
{
return new Vector3(v.x, v.y, v.z);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public void Set(float x, float y, float z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector3 scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
float mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
z /= mag;
}
else
{
x = y = z = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(float min, float max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector3))
{
return false;
}
Vector3 vector = (Vector3)other;
return (x == vector.x && y == vector.y && z == vector.z);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector3 other)
{
return (x == other.x && y == other.y && z == other.z);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture),
z.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static float Dot(ref Vector3 lhs, ref Vector3 rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z;
}
/// <summary>
/// Cross Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Cross(ref Vector3 lhs, ref Vector3 rhs, out Vector3 result)
{
result = new Vector3(lhs.y * rhs.z - lhs.z * rhs.y, lhs.z * rhs.x - lhs.x * rhs.z, lhs.x * rhs.y - lhs.y * rhs.x);
}
/// <summary>
/// Calculates the angle between two vectors.
/// </summary>
/// <param name="from">The from vector.</param>
/// <param name="to">The to vector.</param>
/// <returns>The angle.</returns>
public static float Angle(ref Vector3 from, ref Vector3 to)
{
Vector3 fromNormalized = from.Normalized;
Vector3 toNormalized = to.Normalized;
return (float)System.Math.Acos(MathHelper.Clamp(Vector3.Dot(ref fromNormalized, ref toNormalized), -1f, 1f)) * MathHelper.Rad2Deg;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector3 a, ref Vector3 b, float t, out Vector3 result)
{
result = new Vector3(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector3 a, ref Vector3 b, out Vector3 result)
{
result = new Vector3(a.x * b.x, a.y * b.y, a.z * b.z);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector3 value, out Vector3 result)
{
float mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector3(value.x / mag, value.y / mag, value.z / mag);
}
else
{
result = Vector3.zero;
}
}
/// <summary>
/// Normalizes both vectors and makes them orthogonal to each other.
/// </summary>
/// <param name="normal">The normal vector.</param>
/// <param name="tangent">The tangent.</param>
public static void OrthoNormalize(ref Vector3 normal, ref Vector3 tangent)
{
normal.Normalize();
Vector3 proj = normal * Vector3.Dot(ref tangent, ref normal);
tangent -= proj;
tangent.Normalize();
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,481 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A double precision 3D vector.
/// </summary>
public struct Vector3d : IEquatable<Vector3d>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector3d zero = new Vector3d(0, 0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const double Epsilon = double.Epsilon;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public double x;
/// <summary>
/// The y component.
/// </summary>
public double y;
/// <summary>
/// The z component.
/// </summary>
public double z;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public double Magnitude
{
get { return System.Math.Sqrt(x * x + y * y + z * z); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public double MagnitudeSqr
{
get { return (x * x + y * y + z * z); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector3d Normalized
{
get
{
Vector3d result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public double this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
default:
throw new IndexOutOfRangeException("Invalid Vector3d index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector3d index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector3d(double value)
{
this.x = value;
this.y = value;
this.z = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public Vector3d(double x, double y, double z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Creates a new vector from a single precision vector.
/// </summary>
/// <param name="vector">The single precision vector.</param>
public Vector3d(Vector3 vector)
{
this.x = vector.x;
this.y = vector.y;
this.z = vector.z;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator +(Vector3d a, Vector3d b)
{
return new Vector3d(a.x + b.x, a.y + b.y, a.z + b.z);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator -(Vector3d a, Vector3d b)
{
return new Vector3d(a.x - b.x, a.y - b.y, a.z - b.z);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator *(Vector3d a, double d)
{
return new Vector3d(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator *(double d, Vector3d a)
{
return new Vector3d(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator /(Vector3d a, double d)
{
return new Vector3d(a.x / d, a.y / d, a.z / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator -(Vector3d a)
{
return new Vector3d(-a.x, -a.y, -a.z);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector3d lhs, Vector3d rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector3d lhs, Vector3d rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Implicitly converts from a single-precision vector into a double-precision vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static implicit operator Vector3d(Vector3 v)
{
return new Vector3d(v.x, v.y, v.z);
}
/// <summary>
/// Implicitly converts from an integer vector into a double-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector3d(Vector3i v)
{
return new Vector3d(v.x, v.y, v.z);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public void Set(double x, double y, double z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector3d scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
double mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
z /= mag;
}
else
{
x = y = z = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(double min, double max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector3d))
{
return false;
}
Vector3d vector = (Vector3d)other;
return (x == vector.x && y == vector.y && z == vector.z);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector3d other)
{
return (x == other.x && y == other.y && z == other.z);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture),
z.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static double Dot(ref Vector3d lhs, ref Vector3d rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z;
}
/// <summary>
/// Cross Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Cross(ref Vector3d lhs, ref Vector3d rhs, out Vector3d result)
{
result = new Vector3d(lhs.y * rhs.z - lhs.z * rhs.y, lhs.z * rhs.x - lhs.x * rhs.z, lhs.x * rhs.y - lhs.y * rhs.x);
}
/// <summary>
/// Calculates the angle between two vectors.
/// </summary>
/// <param name="from">The from vector.</param>
/// <param name="to">The to vector.</param>
/// <returns>The angle.</returns>
public static double Angle(ref Vector3d from, ref Vector3d to)
{
Vector3d fromNormalized = from.Normalized;
Vector3d toNormalized = to.Normalized;
return System.Math.Acos(MathHelper.Clamp(Vector3d.Dot(ref fromNormalized, ref toNormalized), -1.0, 1.0)) * MathHelper.Rad2Degd;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector3d a, ref Vector3d b, double t, out Vector3d result)
{
result = new Vector3d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector3d a, ref Vector3d b, out Vector3d result)
{
result = new Vector3d(a.x * b.x, a.y * b.y, a.z * b.z);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector3d value, out Vector3d result)
{
double mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector3d(value.x / mag, value.y / mag, value.z / mag);
}
else
{
result = Vector3d.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,368 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A 3D integer vector.
/// </summary>
public struct Vector3i : IEquatable<Vector3i>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector3i zero = new Vector3i(0, 0, 0);
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public int x;
/// <summary>
/// The y component.
/// </summary>
public int y;
/// <summary>
/// The z component.
/// </summary>
public int z;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public int Magnitude
{
get { return (int)System.Math.Sqrt(x * x + y * y + z * z); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public int MagnitudeSqr
{
get { return (x * x + y * y + z * z); }
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public int this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
default:
throw new IndexOutOfRangeException("Invalid Vector3i index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector3i index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector3i(int value)
{
this.x = value;
this.y = value;
this.z = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public Vector3i(int x, int y, int z)
{
this.x = x;
this.y = y;
this.z = z;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator +(Vector3i a, Vector3i b)
{
return new Vector3i(a.x + b.x, a.y + b.y, a.z + b.z);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator -(Vector3i a, Vector3i b)
{
return new Vector3i(a.x - b.x, a.y - b.y, a.z - b.z);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator *(Vector3i a, int d)
{
return new Vector3i(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator *(int d, Vector3i a)
{
return new Vector3i(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator /(Vector3i a, int d)
{
return new Vector3i(a.x / d, a.y / d, a.z / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator -(Vector3i a)
{
return new Vector3i(-a.x, -a.y, -a.z);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector3i lhs, Vector3i rhs)
{
return (lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z);
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector3i lhs, Vector3i rhs)
{
return (lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z);
}
/// <summary>
/// Explicitly converts from a single-precision vector into an integer vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static implicit operator Vector3i(Vector3 v)
{
return new Vector3i((int)v.x, (int)v.y, (int)v.z);
}
/// <summary>
/// Explicitly converts from a double-precision vector into an integer vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector3i(Vector3d v)
{
return new Vector3i((int)v.x, (int)v.y, (int)v.z);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public void Set(int x, int y, int z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector3i scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(int min, int max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector3i))
{
return false;
}
Vector3i vector = (Vector3i)other;
return (x == vector.x && y == vector.y && z == vector.z);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector3i other)
{
return (x == other.x && y == other.y && z == other.z);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2})",
x.ToString(CultureInfo.InvariantCulture),
y.ToString(CultureInfo.InvariantCulture),
z.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The integer format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector3i a, ref Vector3i b, out Vector3i result)
{
result = new Vector3i(a.x * b.x, a.y * b.y, a.z * b.z);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,467 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A single precision 4D vector.
/// </summary>
public struct Vector4 : IEquatable<Vector4>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector4 zero = new Vector4(0, 0, 0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const float Epsilon = 9.99999944E-11f;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public float x;
/// <summary>
/// The y component.
/// </summary>
public float y;
/// <summary>
/// The z component.
/// </summary>
public float z;
/// <summary>
/// The w component.
/// </summary>
public float w;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public float Magnitude
{
get { return (float)System.Math.Sqrt(x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public float MagnitudeSqr
{
get { return (x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector4 Normalized
{
get
{
Vector4 result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public float this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
case 3:
return w;
default:
throw new IndexOutOfRangeException("Invalid Vector4 index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
case 3:
w = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector4 index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector4(float value)
{
this.x = value;
this.y = value;
this.z = value;
this.w = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public Vector4(float x, float y, float z, float w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator +(Vector4 a, Vector4 b)
{
return new Vector4(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator -(Vector4 a, Vector4 b)
{
return new Vector4(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator *(Vector4 a, float d)
{
return new Vector4(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator *(float d, Vector4 a)
{
return new Vector4(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator /(Vector4 a, float d)
{
return new Vector4(a.x / d, a.y / d, a.z / d, a.w / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator -(Vector4 a)
{
return new Vector4(-a.x, -a.y, -a.z, -a.w);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector4 lhs, Vector4 rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector4 lhs, Vector4 rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Explicitly converts from a double-precision vector into a single-precision vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector4(Vector4d v)
{
return new Vector4((float)v.x, (float)v.y, (float)v.z, (float)v.w);
}
/// <summary>
/// Implicitly converts from an integer vector into a single-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector4(Vector4i v)
{
return new Vector4(v.x, v.y, v.z, v.w);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public void Set(float x, float y, float z, float w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector4 scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
w *= scale.w;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
float mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
z /= mag;
w /= mag;
}
else
{
x = y = z = w = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(float min, float max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
if (w < min) w = min;
else if (w > max) w = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector4))
{
return false;
}
Vector4 vector = (Vector4)other;
return (x == vector.x && y == vector.y && z == vector.z && w == vector.w);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector4 other)
{
return (x == other.x && y == other.y && z == other.z && w == other.w);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture),
z.ToString("F1", CultureInfo.InvariantCulture),
w.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture),
w.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static float Dot(ref Vector4 lhs, ref Vector4 rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector4 a, ref Vector4 b, float t, out Vector4 result)
{
result = new Vector4(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector4 a, ref Vector4 b, out Vector4 result)
{
result = new Vector4(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector4 value, out Vector4 result)
{
float mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector4(value.x / mag, value.y / mag, value.z / mag, value.w / mag);
}
else
{
result = Vector4.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,467 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A double precision 4D vector.
/// </summary>
public struct Vector4d : IEquatable<Vector4d>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector4d zero = new Vector4d(0, 0, 0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const double Epsilon = double.Epsilon;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public double x;
/// <summary>
/// The y component.
/// </summary>
public double y;
/// <summary>
/// The z component.
/// </summary>
public double z;
/// <summary>
/// The w component.
/// </summary>
public double w;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public double Magnitude
{
get { return System.Math.Sqrt(x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public double MagnitudeSqr
{
get { return (x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector4d Normalized
{
get
{
Vector4d result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public double this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
case 3:
return w;
default:
throw new IndexOutOfRangeException("Invalid Vector4d index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
case 3:
w = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector4d index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector4d(double value)
{
this.x = value;
this.y = value;
this.z = value;
this.w = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public Vector4d(double x, double y, double z, double w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator +(Vector4d a, Vector4d b)
{
return new Vector4d(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator -(Vector4d a, Vector4d b)
{
return new Vector4d(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator *(Vector4d a, double d)
{
return new Vector4d(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator *(double d, Vector4d a)
{
return new Vector4d(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator /(Vector4d a, double d)
{
return new Vector4d(a.x / d, a.y / d, a.z / d, a.w / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator -(Vector4d a)
{
return new Vector4d(-a.x, -a.y, -a.z, -a.w);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector4d lhs, Vector4d rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector4d lhs, Vector4d rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Implicitly converts from a single-precision vector into a double-precision vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static implicit operator Vector4d(Vector4 v)
{
return new Vector4d(v.x, v.y, v.z, v.w);
}
/// <summary>
/// Implicitly converts from an integer vector into a double-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector4d(Vector4i v)
{
return new Vector4d(v.x, v.y, v.z, v.w);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public void Set(double x, double y, double z, double w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector4d scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
w *= scale.w;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
double mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
z /= mag;
w /= mag;
}
else
{
x = y = z = w = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(double min, double max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
if (w < min) w = min;
else if (w > max) w = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector4d))
{
return false;
}
Vector4d vector = (Vector4d)other;
return (x == vector.x && y == vector.y && z == vector.z && w == vector.w);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector4d other)
{
return (x == other.x && y == other.y && z == other.z && w == other.w);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture),
z.ToString("F1", CultureInfo.InvariantCulture),
w.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture),
w.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static double Dot(ref Vector4d lhs, ref Vector4d rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector4d a, ref Vector4d b, double t, out Vector4d result)
{
result = new Vector4d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector4d a, ref Vector4d b, out Vector4d result)
{
result = new Vector4d(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector4d value, out Vector4d result)
{
double mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector4d(value.x / mag, value.y / mag, value.z / mag, value.w / mag);
}
else
{
result = Vector4d.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,388 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A 4D integer vector.
/// </summary>
public struct Vector4i : IEquatable<Vector4i>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector4i zero = new Vector4i(0, 0, 0, 0);
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public int x;
/// <summary>
/// The y component.
/// </summary>
public int y;
/// <summary>
/// The z component.
/// </summary>
public int z;
/// <summary>
/// The w component.
/// </summary>
public int w;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public int Magnitude
{
get { return (int)System.Math.Sqrt(x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public int MagnitudeSqr
{
get { return (x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public int this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
case 3:
return w;
default:
throw new IndexOutOfRangeException("Invalid Vector4i index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
case 3:
w = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector4i index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector4i(int value)
{
this.x = value;
this.y = value;
this.z = value;
this.w = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public Vector4i(int x, int y, int z, int w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator +(Vector4i a, Vector4i b)
{
return new Vector4i(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator -(Vector4i a, Vector4i b)
{
return new Vector4i(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator *(Vector4i a, int d)
{
return new Vector4i(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator *(int d, Vector4i a)
{
return new Vector4i(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator /(Vector4i a, int d)
{
return new Vector4i(a.x / d, a.y / d, a.z / d, a.w / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator -(Vector4i a)
{
return new Vector4i(-a.x, -a.y, -a.z, -a.w);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector4i lhs, Vector4i rhs)
{
return (lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z && lhs.w == rhs.w);
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector4i lhs, Vector4i rhs)
{
return (lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z || lhs.w != rhs.w);
}
/// <summary>
/// Explicitly converts from a single-precision vector into an integer vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static explicit operator Vector4i(Vector4 v)
{
return new Vector4i((int)v.x, (int)v.y, (int)v.z, (int)v.w);
}
/// <summary>
/// Explicitly converts from a double-precision vector into an integer vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector4i(Vector4d v)
{
return new Vector4i((int)v.x, (int)v.y, (int)v.z, (int)v.w);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public void Set(int x, int y, int z, int w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector4i scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
w *= scale.w;
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(int min, int max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
if (w < min) w = min;
else if (w > max) w = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector4i))
{
return false;
}
Vector4i vector = (Vector4i)other;
return (x == vector.x && y == vector.y && z == vector.z && w == vector.w);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector4i other)
{
return (x == other.x && y == other.y && z == other.z && w == other.w);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString(CultureInfo.InvariantCulture),
y.ToString(CultureInfo.InvariantCulture),
z.ToString(CultureInfo.InvariantCulture),
w.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The integer format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture),
w.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector4i a, ref Vector4i b, out Vector4i result)
{
result = new Vector4i(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,955 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Collections.Generic;
using MeshDecimator.Math;
namespace MeshDecimator
{
/// <summary>
/// A mesh.
/// </summary>
public sealed class Mesh
{
#region Consts
/// <summary>
/// The count of supported UV channels.
/// </summary>
public const int UVChannelCount = 4;
#endregion
#region Fields
private Vector3d[] vertices = null;
private int[][] indices = null;
private Vector3[] normals = null;
private Vector4[] tangents = null;
private Vector2[][] uvs2D = null;
private Vector3[][] uvs3D = null;
private Vector4[][] uvs4D = null;
private Vector4[] colors = null;
private BoneWeight[] boneWeights = null;
private static readonly int[] emptyIndices = new int[0];
#endregion
#region Properties
/// <summary>
/// Gets the count of vertices of this mesh.
/// </summary>
public int VertexCount
{
get { return vertices.Length; }
}
/// <summary>
/// Gets or sets the count of submeshes in this mesh.
/// </summary>
public int SubMeshCount
{
get { return indices.Length; }
set
{
if (value <= 0)
throw new ArgumentOutOfRangeException("value");
int[][] newIndices = new int[value][];
Array.Copy(indices, 0, newIndices, 0, MathHelper.Min(indices.Length, newIndices.Length));
indices = newIndices;
}
}
/// <summary>
/// Gets the total count of triangles in this mesh.
/// </summary>
public int TriangleCount
{
get
{
int triangleCount = 0;
for (int i = 0; i < indices.Length; i++)
{
if (indices[i] != null)
{
triangleCount += indices[i].Length / 3;
}
}
return triangleCount;
}
}
/// <summary>
/// Gets or sets the vertices for this mesh. Note that this resets all other vertex attributes.
/// </summary>
public Vector3d[] Vertices
{
get { return vertices; }
set
{
if (value == null)
throw new ArgumentNullException("value");
vertices = value;
ClearVertexAttributes();
}
}
/// <summary>
/// Gets or sets the combined indices for this mesh. Once set, the sub-mesh count gets set to 1.
/// </summary>
public int[] Indices
{
get
{
if (indices.Length == 1)
{
return indices[0] ?? emptyIndices;
}
else
{
List<int> indexList = new List<int>(TriangleCount * 3);
for (int i = 0; i < indices.Length; i++)
{
if (indices[i] != null)
{
indexList.AddRange(indices[i]);
}
}
return indexList.ToArray();
}
}
set
{
if (value == null)
throw new ArgumentNullException("value");
else if ((value.Length % 3) != 0)
throw new ArgumentException("The index count must be multiple by 3.", "value");
SubMeshCount = 1;
SetIndices(0, value);
}
}
/// <summary>
/// Gets or sets the normals for this mesh.
/// </summary>
public Vector3[] Normals
{
get { return normals; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex normals must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
normals = value;
}
}
/// <summary>
/// Gets or sets the tangents for this mesh.
/// </summary>
public Vector4[] Tangents
{
get { return tangents; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex tangents must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
tangents = value;
}
}
/// <summary>
/// Gets or sets the first UV set for this mesh.
/// </summary>
public Vector2[] UV1
{
get { return GetUVs2D(0); }
set { SetUVs(0, value); }
}
/// <summary>
/// Gets or sets the second UV set for this mesh.
/// </summary>
public Vector2[] UV2
{
get { return GetUVs2D(1); }
set { SetUVs(1, value); }
}
/// <summary>
/// Gets or sets the third UV set for this mesh.
/// </summary>
public Vector2[] UV3
{
get { return GetUVs2D(2); }
set { SetUVs(2, value); }
}
/// <summary>
/// Gets or sets the fourth UV set for this mesh.
/// </summary>
public Vector2[] UV4
{
get { return GetUVs2D(3); }
set { SetUVs(3, value); }
}
/// <summary>
/// Gets or sets the vertex colors for this mesh.
/// </summary>
public Vector4[] Colors
{
get { return colors; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex colors must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
colors = value;
}
}
/// <summary>
/// Gets or sets the vertex bone weights for this mesh.
/// </summary>
public BoneWeight[] BoneWeights
{
get { return boneWeights; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex bone weights must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
boneWeights = value;
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new mesh.
/// </summary>
/// <param name="vertices">The mesh vertices.</param>
/// <param name="indices">The mesh indices.</param>
public Mesh(Vector3d[] vertices, int[] indices)
{
if (vertices == null)
throw new ArgumentNullException("vertices");
else if (indices == null)
throw new ArgumentNullException("indices");
else if ((indices.Length % 3) != 0)
throw new ArgumentException("The index count must be multiple by 3.", "indices");
this.vertices = vertices;
this.indices = new int[1][];
this.indices[0] = indices;
}
/// <summary>
/// Creates a new mesh.
/// </summary>
/// <param name="vertices">The mesh vertices.</param>
/// <param name="indices">The mesh indices.</param>
public Mesh(Vector3d[] vertices, int[][] indices)
{
if (vertices == null)
throw new ArgumentNullException("vertices");
else if (indices == null)
throw new ArgumentNullException("indices");
for (int i = 0; i < indices.Length; i++)
{
if (indices[i] != null && (indices[i].Length % 3) != 0)
throw new ArgumentException(string.Format("The index count must be multiple by 3 at sub-mesh index {0}.", i), "indices");
}
this.vertices = vertices;
this.indices = indices;
}
#endregion
#region Private Methods
private void ClearVertexAttributes()
{
normals = null;
tangents = null;
uvs2D = null;
uvs3D = null;
uvs4D = null;
colors = null;
boneWeights = null;
}
#endregion
#region Public Methods
#region Recalculate Normals
/// <summary>
/// Recalculates the normals for this mesh smoothly.
/// </summary>
public void RecalculateNormals()
{
int vertexCount = vertices.Length;
Vector3[] normals = new Vector3[vertexCount];
int subMeshCount = this.indices.Length;
for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++)
{
int[] indices = this.indices[subMeshIndex];
if (indices == null)
continue;
int indexCount = indices.Length;
for (int i = 0; i < indexCount; i += 3)
{
int i0 = indices[i];
int i1 = indices[i + 1];
int i2 = indices[i + 2];
var v0 = (Vector3)vertices[i0];
var v1 = (Vector3)vertices[i1];
var v2 = (Vector3)vertices[i2];
var nx = v1 - v0;
var ny = v2 - v0;
Vector3 normal;
Vector3.Cross(ref nx, ref ny, out normal);
normal.Normalize();
normals[i0] += normal;
normals[i1] += normal;
normals[i2] += normal;
}
}
for (int i = 0; i < vertexCount; i++)
{
normals[i].Normalize();
}
this.normals = normals;
}
#endregion
#region Recalculate Tangents
/// <summary>
/// Recalculates the tangents for this mesh.
/// </summary>
public void RecalculateTangents()
{
// Make sure we have the normals first
if (normals == null)
return;
// Also make sure that we have the first UV set
bool uvIs2D = (uvs2D != null && uvs2D[0] != null);
bool uvIs3D = (uvs3D != null && uvs3D[0] != null);
bool uvIs4D = (uvs4D != null && uvs4D[0] != null);
if (!uvIs2D && !uvIs3D && !uvIs4D)
return;
int vertexCount = vertices.Length;
var tangents = new Vector4[vertexCount];
var tan1 = new Vector3[vertexCount];
var tan2 = new Vector3[vertexCount];
Vector2[] uv2D = (uvIs2D ? uvs2D[0] : null);
Vector3[] uv3D = (uvIs3D ? uvs3D[0] : null);
Vector4[] uv4D = (uvIs4D ? uvs4D[0] : null);
int subMeshCount = this.indices.Length;
for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++)
{
int[] indices = this.indices[subMeshIndex];
if (indices == null)
continue;
int indexCount = indices.Length;
for (int i = 0; i < indexCount; i += 3)
{
int i0 = indices[i];
int i1 = indices[i + 1];
int i2 = indices[i + 2];
var v0 = vertices[i0];
var v1 = vertices[i1];
var v2 = vertices[i2];
float s1, s2, t1, t2;
if (uvIs2D)
{
var w0 = uv2D[i0];
var w1 = uv2D[i1];
var w2 = uv2D[i2];
s1 = w1.x - w0.x;
s2 = w2.x - w0.x;
t1 = w1.y - w0.y;
t2 = w2.y - w0.y;
}
else if (uvIs3D)
{
var w0 = uv3D[i0];
var w1 = uv3D[i1];
var w2 = uv3D[i2];
s1 = w1.x - w0.x;
s2 = w2.x - w0.x;
t1 = w1.y - w0.y;
t2 = w2.y - w0.y;
}
else
{
var w0 = uv4D[i0];
var w1 = uv4D[i1];
var w2 = uv4D[i2];
s1 = w1.x - w0.x;
s2 = w2.x - w0.x;
t1 = w1.y - w0.y;
t2 = w2.y - w0.y;
}
float x1 = (float)(v1.x - v0.x);
float x2 = (float)(v2.x - v0.x);
float y1 = (float)(v1.y - v0.y);
float y2 = (float)(v2.y - v0.y);
float z1 = (float)(v1.z - v0.z);
float z2 = (float)(v2.z - v0.z);
float r = 1f / (s1 * t2 - s2 * t1);
var sdir = new Vector3((t2 * x1 - t1 * x2) * r, (t2 * y1 - t1 * y2) * r, (t2 * z1 - t1 * z2) * r);
var tdir = new Vector3((s1 * x2 - s2 * x1) * r, (s1 * y2 - s2 * y1) * r, (s1 * z2 - s2 * z1) * r);
tan1[i0] += sdir;
tan1[i1] += sdir;
tan1[i2] += sdir;
tan2[i0] += tdir;
tan2[i1] += tdir;
tan2[i2] += tdir;
}
}
for (int i = 0; i < vertexCount; i++)
{
var n = normals[i];
var t = tan1[i];
var tmp = (t - n * Vector3.Dot(ref n, ref t));
tmp.Normalize();
Vector3 c;
Vector3.Cross(ref n, ref t, out c);
float dot = Vector3.Dot(ref c, ref tan2[i]);
float w = (dot < 0f ? -1f : 1f);
tangents[i] = new Vector4(tmp.x, tmp.y, tmp.z, w);
}
this.tangents = tangents;
}
#endregion
#region Triangles
/// <summary>
/// Returns the count of triangles for a specific sub-mesh in this mesh.
/// </summary>
/// <param name="subMeshIndex">The sub-mesh index.</param>
/// <returns>The triangle count.</returns>
public int GetTriangleCount(int subMeshIndex)
{
if (subMeshIndex < 0 || subMeshIndex >= indices.Length)
throw new IndexOutOfRangeException();
return indices[subMeshIndex].Length / 3;
}
/// <summary>
/// Returns the triangle indices of a specific sub-mesh in this mesh.
/// </summary>
/// <param name="subMeshIndex">The sub-mesh index.</param>
/// <returns>The triangle indices.</returns>
public int[] GetIndices(int subMeshIndex)
{
if (subMeshIndex < 0 || subMeshIndex >= indices.Length)
throw new IndexOutOfRangeException();
return indices[subMeshIndex] ?? emptyIndices;
}
/// <summary>
/// Returns the triangle indices for all sub-meshes in this mesh.
/// </summary>
/// <returns>The sub-mesh triangle indices.</returns>
public int[][] GetSubMeshIndices()
{
var subMeshIndices = new int[indices.Length][];
for (int subMeshIndex = 0; subMeshIndex < indices.Length; subMeshIndex++)
{
subMeshIndices[subMeshIndex] = indices[subMeshIndex] ?? emptyIndices;
}
return subMeshIndices;
}
/// <summary>
/// Sets the triangle indices of a specific sub-mesh in this mesh.
/// </summary>
/// <param name="subMeshIndex">The sub-mesh index.</param>
/// <param name="indices">The triangle indices.</param>
public void SetIndices(int subMeshIndex, int[] indices)
{
if (subMeshIndex < 0 || subMeshIndex >= this.indices.Length)
throw new IndexOutOfRangeException();
else if (indices == null)
throw new ArgumentNullException("indices");
else if ((indices.Length % 3) != 0)
throw new ArgumentException("The index count must be multiple by 3.", "indices");
this.indices[subMeshIndex] = indices;
}
#endregion
#region UV Sets
#region Getting
/// <summary>
/// Returns the UV dimension for a specific channel.
/// </summary>
/// <param name="channel"></param>
/// <returns>The UV dimension count.</returns>
public int GetUVDimension(int channel)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs2D != null && uvs2D[channel] != null)
{
return 2;
}
else if (uvs3D != null && uvs3D[channel] != null)
{
return 3;
}
else if (uvs4D != null && uvs4D[channel] != null)
{
return 4;
}
else
{
return 0;
}
}
/// <summary>
/// Returns the UVs (2D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <returns>The UVs.</returns>
public Vector2[] GetUVs2D(int channel)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs2D != null && uvs2D[channel] != null)
{
return uvs2D[channel];
}
else
{
return null;
}
}
/// <summary>
/// Returns the UVs (3D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <returns>The UVs.</returns>
public Vector3[] GetUVs3D(int channel)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs3D != null && uvs3D[channel] != null)
{
return uvs3D[channel];
}
else
{
return null;
}
}
/// <summary>
/// Returns the UVs (4D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <returns>The UVs.</returns>
public Vector4[] GetUVs4D(int channel)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs4D != null && uvs4D[channel] != null)
{
return uvs4D[channel];
}
else
{
return null;
}
}
/// <summary>
/// Returns the UVs (2D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void GetUVs(int channel, List<Vector2> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
else if (uvs == null)
throw new ArgumentNullException("uvs");
uvs.Clear();
if (uvs2D != null && uvs2D[channel] != null)
{
var uvData = uvs2D[channel];
if (uvData != null)
{
uvs.AddRange(uvData);
}
}
}
/// <summary>
/// Returns the UVs (3D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void GetUVs(int channel, List<Vector3> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
else if (uvs == null)
throw new ArgumentNullException("uvs");
uvs.Clear();
if (uvs3D != null && uvs3D[channel] != null)
{
var uvData = uvs3D[channel];
if (uvData != null)
{
uvs.AddRange(uvData);
}
}
}
/// <summary>
/// Returns the UVs (4D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void GetUVs(int channel, List<Vector4> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
else if (uvs == null)
throw new ArgumentNullException("uvs");
uvs.Clear();
if (uvs4D != null && uvs4D[channel] != null)
{
var uvData = uvs4D[channel];
if (uvData != null)
{
uvs.AddRange(uvData);
}
}
}
#endregion
#region Setting
/// <summary>
/// Sets the UVs (2D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, Vector2[] uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Length > 0)
{
if (uvs.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvs.Length, vertices.Length));
if (uvs2D == null)
uvs2D = new Vector2[UVChannelCount][];
int uvCount = uvs.Length;
var uvSet = new Vector2[uvCount];
uvs2D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs2D != null)
{
uvs2D[channel] = null;
}
}
if (uvs3D != null)
{
uvs3D[channel] = null;
}
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (3D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, Vector3[] uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Length > 0)
{
int uvCount = uvs.Length;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs3D == null)
uvs3D = new Vector3[UVChannelCount][];
var uvSet = new Vector3[uvCount];
uvs3D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs3D != null)
{
uvs3D[channel] = null;
}
}
if (uvs2D != null)
{
uvs2D[channel] = null;
}
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (4D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, Vector4[] uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Length > 0)
{
int uvCount = uvs.Length;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs4D == null)
uvs4D = new Vector4[UVChannelCount][];
var uvSet = new Vector4[uvCount];
uvs4D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
if (uvs2D != null)
{
uvs2D[channel] = null;
}
if (uvs3D != null)
{
uvs3D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (2D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, List<Vector2> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Count > 0)
{
int uvCount = uvs.Count;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs2D == null)
uvs2D = new Vector2[UVChannelCount][];
var uvSet = new Vector2[uvCount];
uvs2D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs2D != null)
{
uvs2D[channel] = null;
}
}
if (uvs3D != null)
{
uvs3D[channel] = null;
}
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (3D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, List<Vector3> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Count > 0)
{
int uvCount = uvs.Count;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs3D == null)
uvs3D = new Vector3[UVChannelCount][];
var uvSet = new Vector3[uvCount];
uvs3D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs3D != null)
{
uvs3D[channel] = null;
}
}
if (uvs2D != null)
{
uvs2D[channel] = null;
}
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (4D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, List<Vector4> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Count > 0)
{
int uvCount = uvs.Count;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs4D == null)
uvs4D = new Vector4[UVChannelCount][];
var uvSet = new Vector4[uvCount];
uvs4D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
if (uvs2D != null)
{
uvs2D[channel] = null;
}
if (uvs3D != null)
{
uvs3D[channel] = null;
}
}
#endregion
#endregion
#region To String
/// <summary>
/// Returns the text-representation of this mesh.
/// </summary>
/// <returns>The text-representation.</returns>
public override string ToString()
{
return string.Format("Vertices: {0}", vertices.Length);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,180 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using MeshDecimator.Algorithms;
namespace MeshDecimator
{
#region Algorithm
/// <summary>
/// The decimation algorithms.
/// </summary>
public enum Algorithm
{
/// <summary>
/// The default algorithm.
/// </summary>
Default,
/// <summary>
/// The fast quadric mesh simplification algorithm.
/// </summary>
FastQuadricMesh
}
#endregion
/// <summary>
/// The mesh decimation API.
/// </summary>
public static class MeshDecimation
{
#region Public Methods
#region Create Algorithm
/// <summary>
/// Creates a specific decimation algorithm.
/// </summary>
/// <param name="algorithm">The desired algorithm.</param>
/// <returns>The decimation algorithm.</returns>
public static DecimationAlgorithm CreateAlgorithm(Algorithm algorithm)
{
DecimationAlgorithm alg = null;
switch (algorithm)
{
case Algorithm.Default:
case Algorithm.FastQuadricMesh:
alg = new FastQuadricMeshSimplification();
break;
default:
throw new ArgumentException("The specified algorithm is not supported.", "algorithm");
}
return alg;
}
#endregion
#region Decimate Mesh
/// <summary>
/// Decimates a mesh.
/// </summary>
/// <param name="mesh">The mesh to decimate.</param>
/// <param name="targetTriangleCount">The target triangle count.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMesh(Mesh mesh, int targetTriangleCount)
{
return DecimateMesh(Algorithm.Default, mesh, targetTriangleCount);
}
/// <summary>
/// Decimates a mesh.
/// </summary>
/// <param name="algorithm">The desired algorithm.</param>
/// <param name="mesh">The mesh to decimate.</param>
/// <param name="targetTriangleCount">The target triangle count.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMesh(Algorithm algorithm, Mesh mesh, int targetTriangleCount)
{
if (mesh == null)
throw new ArgumentNullException("mesh");
var decimationAlgorithm = CreateAlgorithm(algorithm);
return DecimateMesh(decimationAlgorithm, mesh, targetTriangleCount);
}
/// <summary>
/// Decimates a mesh.
/// </summary>
/// <param name="algorithm">The decimation algorithm.</param>
/// <param name="mesh">The mesh to decimate.</param>
/// <param name="targetTriangleCount">The target triangle count.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMesh(DecimationAlgorithm algorithm, Mesh mesh, int targetTriangleCount)
{
if (algorithm == null)
throw new ArgumentNullException("algorithm");
else if (mesh == null)
throw new ArgumentNullException("mesh");
int currentTriangleCount = mesh.TriangleCount;
if (targetTriangleCount > currentTriangleCount)
targetTriangleCount = currentTriangleCount;
else if (targetTriangleCount < 0)
targetTriangleCount = 0;
algorithm.Initialize(mesh);
algorithm.DecimateMesh(targetTriangleCount);
return algorithm.ToMesh();
}
#endregion
#region Decimate Mesh Lossless
/// <summary>
/// Decimates a mesh without losing any quality.
/// </summary>
/// <param name="mesh">The mesh to decimate.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMeshLossless(Mesh mesh)
{
return DecimateMeshLossless(Algorithm.Default, mesh);
}
/// <summary>
/// Decimates a mesh without losing any quality.
/// </summary>
/// <param name="algorithm">The desired algorithm.</param>
/// <param name="mesh">The mesh to decimate.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMeshLossless(Algorithm algorithm, Mesh mesh)
{
if (mesh == null)
throw new ArgumentNullException("mesh");
var decimationAlgorithm = CreateAlgorithm(algorithm);
return DecimateMeshLossless(decimationAlgorithm, mesh);
}
/// <summary>
/// Decimates a mesh without losing any quality.
/// </summary>
/// <param name="algorithm">The decimation algorithm.</param>
/// <param name="mesh">The mesh to decimate.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMeshLossless(DecimationAlgorithm algorithm, Mesh mesh)
{
if (algorithm == null)
throw new ArgumentNullException("algorithm");
else if (mesh == null)
throw new ArgumentNullException("mesh");
int currentTriangleCount = mesh.TriangleCount;
algorithm.Initialize(mesh);
algorithm.DecimateMeshLossless();
return algorithm.ToMesh();
}
#endregion
#endregion
}
}

View File

@@ -52,6 +52,10 @@ public class CompactUi : WindowMediatorSubscriberBase
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator;
private readonly PairLedger _pairLedger;
private readonly ConcurrentDictionary<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly DrawEntityFactory _drawEntityFactory;
private readonly FileUploadManager _fileTransferManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly PairUiService _pairUiService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly ServerConfigurationManager _serverManager;
@@ -991,6 +995,7 @@ public class CompactUi : WindowMediatorSubscriberBase
VisiblePairSortMode.VramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateVramBytes),
VisiblePairSortMode.EffectiveVramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveVramBytes),
VisiblePairSortMode.TriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedDataTris),
VisiblePairSortMode.EffectiveTriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveTris),
VisiblePairSortMode.Alphabetical => [.. entryList.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)],
VisiblePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList),
_ => SortEntries(entryList),

View File

@@ -326,6 +326,7 @@ public class DrawFolderTag : DrawFolderBase
VisiblePairSortMode.VramUsage => "VRAM usage (descending)",
VisiblePairSortMode.EffectiveVramUsage => "Effective VRAM usage (descending)",
VisiblePairSortMode.TriangleCount => "Triangle count (descending)",
VisiblePairSortMode.EffectiveTriangleCount => "Effective triangle count (descending)",
VisiblePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs",
_ => "Default",
};

View File

@@ -429,6 +429,7 @@ public class DrawUserPair
_pair.LastAppliedApproximateVRAMBytes,
_pair.LastAppliedApproximateEffectiveVRAMBytes,
_pair.LastAppliedDataTris,
_pair.LastAppliedApproximateEffectiveTris,
_pair.IsPaired,
groupDisplays is null ? ImmutableArray<string>.Empty : ImmutableArray.CreateRange(groupDisplays));
@@ -444,6 +445,8 @@ public class DrawUserPair
private static string BuildTooltip(in TooltipSnapshot snapshot)
{
var builder = new StringBuilder(256);
static string FormatTriangles(long count) =>
count > 1000 ? (count / 1000d).ToString("0.0'k'") : count.ToString();
if (snapshot.IsPaused)
{
@@ -510,9 +513,13 @@ public class DrawUserPair
{
builder.Append(Environment.NewLine);
builder.Append("Approx. Triangle Count (excl. Vanilla): ");
builder.Append(snapshot.LastAppliedDataTris > 1000
? (snapshot.LastAppliedDataTris / 1000d).ToString("0.0'k'")
: snapshot.LastAppliedDataTris);
builder.Append(FormatTriangles(snapshot.LastAppliedDataTris));
if (snapshot.LastAppliedApproximateEffectiveTris >= 0)
{
builder.Append(" (Effective: ");
builder.Append(FormatTriangles(snapshot.LastAppliedApproximateEffectiveTris));
builder.Append(')');
}
}
}
@@ -544,11 +551,12 @@ public class DrawUserPair
long LastAppliedApproximateVRAMBytes,
long LastAppliedApproximateEffectiveVRAMBytes,
long LastAppliedDataTris,
long LastAppliedApproximateEffectiveTris,
bool IsPaired,
ImmutableArray<string> GroupDisplays)
{
public static TooltipSnapshot Empty { get; } =
new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, -1, false, ImmutableArray<string>.Empty);
new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, -1, -1, false, ImmutableArray<string>.Empty);
}
private void DrawPairedClientMenu()

View File

@@ -11,6 +11,7 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI.Models;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using OtterTex;
@@ -34,12 +35,15 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private const float TextureDetailSplitterWidth = 12f;
private const float TextureDetailSplitterCollapsedWidth = 18f;
private const float SelectedFilePanelLogicalHeight = 90f;
private const float TextureHoverPreviewDelaySeconds = 1.75f;
private const float TextureHoverPreviewSize = 350f;
private static readonly Vector4 SelectedTextureRowTextColor = new(0f, 0f, 0f, 1f);
private readonly CharacterAnalyzer _characterAnalyzer;
private readonly Progress<TextureConversionProgress> _conversionProgress = new();
private readonly IpcManager _ipcManager;
private readonly UiSharedService _uiSharedService;
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly TransientResourceManager _transientResourceManager;
private readonly TransientConfigService _transientConfigService;
@@ -77,6 +81,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private string _selectedJobEntry = string.Empty;
private string _filterGamePath = string.Empty;
private string _filterFilePath = string.Empty;
private string _textureHoverKey = string.Empty;
private int _conversionCurrentFileProgress = 0;
private int _conversionTotalJobs;
@@ -87,6 +92,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private bool _textureRowsDirty = true;
private bool _textureDetailCollapsed = false;
private bool _conversionFailed;
private double _textureHoverStartTime = 0;
#if DEBUG
private bool _debugCompressionModalOpen = false;
private TextureConversionProgress? _debugConversionProgress;
#endif
private bool _showAlreadyAddedTransients = false;
private bool _acknowledgeReview = false;
@@ -98,10 +108,12 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private TextureUsageCategory? _textureCategoryFilter = null;
private TextureMapKind? _textureMapFilter = null;
private TextureCompressionTarget? _textureTargetFilter = null;
private TextureFormatSortMode _textureFormatSortMode = TextureFormatSortMode.None;
public DataAnalysisUi(ILogger<DataAnalysisUi> logger, LightlessMediator mediator,
CharacterAnalyzer characterAnalyzer, IpcManager ipcManager,
PerformanceCollectorService performanceCollectorService, UiSharedService uiSharedService,
LightlessConfigService configService,
PlayerPerformanceConfigService playerPerformanceConfig, TransientResourceManager transientResourceManager,
TransientConfigService transientConfigService, TextureCompressionService textureCompressionService,
TextureMetadataHelper textureMetadataHelper)
@@ -110,6 +122,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
_characterAnalyzer = characterAnalyzer;
_ipcManager = ipcManager;
_uiSharedService = uiSharedService;
_configService = configService;
_playerPerformanceConfig = playerPerformanceConfig;
_transientResourceManager = transientResourceManager;
_transientConfigService = transientConfigService;
@@ -135,21 +148,30 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private void HandleConversionModal()
{
if (_conversionTask == null)
bool hasConversion = _conversionTask != null;
#if DEBUG
bool showDebug = _debugCompressionModalOpen && !hasConversion;
#else
const bool showDebug = false;
#endif
if (!hasConversion && !showDebug)
{
return;
}
if (_conversionTask.IsCompleted)
if (hasConversion && _conversionTask!.IsCompleted)
{
ResetConversionModalState();
return;
if (!showDebug)
{
return;
}
}
_showModal = true;
if (ImGui.BeginPopupModal("Texture Compression in Progress", ImGuiWindowFlags.AlwaysAutoResize))
if (ImGui.BeginPopupModal("Texture Compression in Progress", UiSharedService.PopupWindowFlags))
{
DrawConversionModalContent();
DrawConversionModalContent(showDebug);
ImGui.EndPopup();
}
else
@@ -164,31 +186,190 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
}
}
private void DrawConversionModalContent()
private void DrawConversionModalContent(bool isDebugPreview)
{
var progress = _lastConversionProgress;
var scale = ImGuiHelpers.GlobalScale;
TextureConversionProgress? progress;
#if DEBUG
progress = isDebugPreview ? _debugConversionProgress : _lastConversionProgress;
#else
progress = _lastConversionProgress;
#endif
var total = progress?.Total ?? Math.Max(_conversionTotalJobs, 1);
var completed = progress != null
? Math.Min(progress.Completed + 1, total)
: _conversionCurrentFileProgress;
var currentLabel = !string.IsNullOrEmpty(_conversionCurrentFileName)
? _conversionCurrentFileName
: "Preparing...";
? Math.Clamp(progress.Completed + 1, 0, total)
: Math.Clamp(_conversionCurrentFileProgress, 0, total);
var percent = total > 0 ? Math.Clamp(completed / (float)total, 0f, 1f) : 0f;
ImGui.TextUnformatted($"Compressing textures ({completed}/{total})");
UiSharedService.TextWrapped("Current file: " + currentLabel);
var job = progress?.CurrentJob;
var inputPath = job?.InputFile ?? string.Empty;
var targetLabel = job != null ? job.TargetType.ToString() : "Unknown";
var currentLabel = !string.IsNullOrEmpty(inputPath)
? Path.GetFileName(inputPath)
: !string.IsNullOrEmpty(_conversionCurrentFileName) ? _conversionCurrentFileName : "Preparing...";
var mapKind = !string.IsNullOrEmpty(inputPath)
? _textureMetadataHelper.DetermineMapKind(inputPath)
: TextureMapKind.Unknown;
if (_conversionFailed)
var accent = UIColors.Get("LightlessPurple");
var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.18f);
var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.4f);
var headerHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.6f, 46f * scale);
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale))
using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize)))
using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale)))
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 2f * scale)))
using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg)))
using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder)))
using (var header = ImRaii.Child("compressionHeader", new Vector2(-1f, headerHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse))
{
UiSharedService.ColorText("Conversion encountered errors. Please review the log for details.", ImGuiColors.DalamudRed);
if (header)
{
if (ImGui.BeginTable("compressionHeaderTable", 2,
ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX))
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
DrawCompressionTitle(accent, scale);
var statusText = isDebugPreview ? "Preview mode" : "Working...";
var statusColor = isDebugPreview ? UIColors.Get("LightlessYellow") : ImGuiColors.DalamudGrey;
UiSharedService.ColorText(statusText, statusColor);
ImGui.TableNextColumn();
var progressText = $"{completed}/{total}";
var percentText = $"{percent * 100f:0}%";
var summaryText = $"{progressText} ({percentText})";
var summaryWidth = ImGui.CalcTextSize(summaryText).X;
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + MathF.Max(0f, ImGui.GetColumnWidth() - summaryWidth));
UiSharedService.ColorText(summaryText, ImGuiColors.DalamudGrey);
ImGui.EndTable();
}
}
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion"))
ImGuiHelpers.ScaledDummy(6);
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f * scale))
using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(0f, 4f * scale)))
using (ImRaii.PushColor(ImGuiCol.FrameBg, UiSharedService.Color(new Vector4(0.15f, 0.15f, 0.18f, 1f))))
using (ImRaii.PushColor(ImGuiCol.PlotHistogram, UiSharedService.Color(accent)))
{
_conversionCancellationTokenSource.Cancel();
ImGui.ProgressBar(percent, new Vector2(-1f, 0f), $"{percent * 100f:0}%");
}
UiSharedService.SetScaledWindowSize(520);
ImGuiHelpers.ScaledDummy(6);
var infoAccent = UIColors.Get("LightlessBlue");
var infoBg = new Vector4(infoAccent.X, infoAccent.Y, infoAccent.Z, 0.12f);
var infoBorder = new Vector4(infoAccent.X, infoAccent.Y, infoAccent.Z, 0.32f);
const int detailRows = 3;
var detailHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * (detailRows + 1.2f), 72f * scale);
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f * scale))
using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize)))
using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(10f * scale, 6f * scale)))
using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(infoBg)))
using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(infoBorder)))
using (var details = ImRaii.Child("compressionDetail", new Vector2(-1f, detailHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse))
{
if (details)
{
if (ImGui.BeginTable("compressionDetailTable", 2,
ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX))
{
DrawDetailRow("Current file", currentLabel, inputPath);
DrawDetailRow("Target format", targetLabel, null);
DrawDetailRow("Map type", mapKind.ToString(), null);
ImGui.EndTable();
}
}
}
if (_conversionFailed && !isDebugPreview)
{
ImGuiHelpers.ScaledDummy(4);
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudRed);
ImGui.SameLine(0f, 6f * scale);
UiSharedService.TextWrapped("Conversion encountered errors. Please review the log for details.", color: ImGuiColors.DalamudRed);
}
ImGuiHelpers.ScaledDummy(6);
if (!isDebugPreview)
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion"))
{
_conversionCancellationTokenSource.Cancel();
}
}
else
{
#if DEBUG
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Times, "Close preview"))
{
CloseDebugCompressionModal();
}
#endif
}
UiSharedService.SetScaledWindowSize(600);
void DrawDetailRow(string label, string value, string? tooltip)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey))
{
ImGui.TextUnformatted(label);
}
ImGui.TableNextColumn();
ImGui.TextUnformatted(value);
if (!string.IsNullOrEmpty(tooltip))
{
UiSharedService.AttachToolTip(tooltip);
}
}
void DrawCompressionTitle(Vector4 iconColor, float localScale)
{
const string title = "Texture Compression";
var spacing = 6f * localScale;
var iconText = FontAwesomeIcon.CompressArrowsAlt.ToIconString();
Vector2 iconSize;
using (_uiSharedService.IconFont.Push())
{
iconSize = ImGui.CalcTextSize(iconText);
}
Vector2 titleSize;
using (_uiSharedService.MediumFont.Push())
{
titleSize = ImGui.CalcTextSize(title);
}
var lineHeight = MathF.Max(iconSize.Y, titleSize.Y);
var iconOffsetY = (lineHeight - iconSize.Y) / 2f;
var textOffsetY = (lineHeight - titleSize.Y) / 2f;
var start = ImGui.GetCursorScreenPos();
var drawList = ImGui.GetWindowDrawList();
using (_uiSharedService.IconFont.Push())
{
drawList.AddText(new Vector2(start.X, start.Y + iconOffsetY), UiSharedService.Color(iconColor), iconText);
}
using (_uiSharedService.MediumFont.Push())
{
var textPos = new Vector2(start.X + iconSize.X + spacing, start.Y + textOffsetY);
drawList.AddText(textPos, ImGui.GetColorU32(ImGuiCol.Text), title);
}
ImGui.Dummy(new Vector2(iconSize.X + spacing + titleSize.X, lineHeight));
}
}
private void ResetConversionModalState()
@@ -202,6 +383,41 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
_conversionTotalJobs = 0;
}
#if DEBUG
private void OpenCompressionDebugModal()
{
if (_conversionTask != null && !_conversionTask.IsCompleted)
{
return;
}
_debugCompressionModalOpen = true;
_debugConversionProgress = new TextureConversionProgress(
Completed: 3,
Total: 10,
CurrentJob: new TextureConversionJob(
@"C:\Lightless\Mods\Textures\example_diffuse.tex",
@"C:\Lightless\Mods\Textures\example_diffuse_bc7.tex",
Penumbra.Api.Enums.TextureType.Bc7Tex));
_showModal = true;
_modalOpen = false;
}
private void ResetDebugCompressionModalState()
{
_debugCompressionModalOpen = false;
_debugConversionProgress = null;
}
private void CloseDebugCompressionModal()
{
ResetDebugCompressionModalState();
_showModal = false;
_modalOpen = false;
ImGui.CloseCurrentPopup();
}
#endif
private void RefreshAnalysisCache()
{
if (!_hasUpdate)
@@ -757,6 +973,16 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
ResetTextureFilters();
InvalidateTextureRows();
_conversionFailed = false;
#if DEBUG
ResetDebugCompressionModalState();
#endif
var savedFormatSort = _configService.Current.TextureFormatSortMode;
if (!Enum.IsDefined(typeof(TextureFormatSortMode), savedFormatSort))
{
savedFormatSort = TextureFormatSortMode.None;
}
SetTextureFormatSortMode(savedFormatSort, persist: false);
}
protected override void Dispose(bool disposing)
@@ -1955,6 +2181,17 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
{
InvalidateTextureRows();
}
#if DEBUG
ImGui.SameLine();
using (ImRaii.Disabled(conversionRunning || !UiSharedService.CtrlPressed()))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Preview popup (debug)", 200f * scale))
{
OpenCompressionDebugModal();
}
}
UiSharedService.AttachToolTip("Hold CTRL to open the compression popup preview.");
#endif
TextureRow? lastSelected = null;
using (var table = ImRaii.Table("textureDataTable", 9,
@@ -1973,26 +2210,56 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
ImGui.TableSetupColumn("Original", ImGuiTableColumnFlags.PreferSortDescending);
ImGui.TableSetupColumn("Compressed", ImGuiTableColumnFlags.PreferSortDescending);
ImGui.TableSetupScrollFreeze(0, 1);
ImGui.TableHeadersRow();
DrawTextureTableHeaderRow();
var targets = _textureCompressionService.SelectableTargets;
IEnumerable<TextureRow> orderedRows = rows;
var sortSpecs = ImGui.TableGetSortSpecs();
var sizeSortColumn = -1;
var sizeSortDirection = ImGuiSortDirection.Ascending;
if (sortSpecs.SpecsCount > 0)
{
var spec = sortSpecs.Specs[0];
orderedRows = spec.ColumnIndex switch
if (spec.ColumnIndex is 7 or 8)
{
7 => spec.SortDirection == ImGuiSortDirection.Ascending
? rows.OrderBy(r => r.OriginalSize)
: rows.OrderByDescending(r => r.OriginalSize),
8 => spec.SortDirection == ImGuiSortDirection.Ascending
? rows.OrderBy(r => r.CompressedSize)
: rows.OrderByDescending(r => r.CompressedSize),
_ => rows
};
sizeSortColumn = spec.ColumnIndex;
sizeSortDirection = spec.SortDirection;
}
}
var hasSizeSort = sizeSortColumn != -1;
var indexedRows = rows.Select((row, idx) => (row, idx));
if (_textureFormatSortMode != TextureFormatSortMode.None)
{
bool compressedFirst = _textureFormatSortMode == TextureFormatSortMode.CompressedFirst;
int GroupKey(TextureRow row) => row.IsAlreadyCompressed == compressedFirst ? 0 : 1;
long SizeKey(TextureRow row) => sizeSortColumn == 7 ? row.OriginalSize : row.CompressedSize;
var ordered = indexedRows.OrderBy(pair => GroupKey(pair.row));
if (hasSizeSort)
{
ordered = sizeSortDirection == ImGuiSortDirection.Ascending
? ordered.ThenBy(pair => SizeKey(pair.row))
: ordered.ThenByDescending(pair => SizeKey(pair.row));
}
orderedRows = ordered
.ThenBy(pair => pair.idx)
.Select(pair => pair.row);
}
else if (hasSizeSort)
{
long SizeKey(TextureRow row) => sizeSortColumn == 7 ? row.OriginalSize : row.CompressedSize;
orderedRows = sizeSortDirection == ImGuiSortDirection.Ascending
? indexedRows.OrderBy(pair => SizeKey(pair.row)).ThenBy(pair => pair.idx).Select(pair => pair.row)
: indexedRows.OrderByDescending(pair => SizeKey(pair.row)).ThenBy(pair => pair.idx).Select(pair => pair.row);
}
if (sortSpecs.SpecsCount > 0)
{
sortSpecs.SpecsDirty = false;
}
@@ -2034,6 +2301,79 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
}
}
}
private void DrawTextureTableHeaderRow()
{
ImGui.TableNextRow(ImGuiTableRowFlags.Headers);
DrawHeaderCell(0, "##select");
DrawHeaderCell(1, "Texture");
DrawHeaderCell(2, "Slot");
DrawHeaderCell(3, "Map");
DrawFormatHeaderCell();
DrawHeaderCell(5, "Recommended");
DrawHeaderCell(6, "Target");
DrawHeaderCell(7, "Original");
DrawHeaderCell(8, "Compressed");
}
private static void DrawHeaderCell(int columnIndex, string label)
{
ImGui.TableSetColumnIndex(columnIndex);
ImGui.TableHeader(label);
}
private void DrawFormatHeaderCell()
{
ImGui.TableSetColumnIndex(4);
ImGui.TableHeader(GetFormatHeaderLabel());
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
{
CycleTextureFormatSortMode();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Click to cycle sort: normal, compressed first, uncompressed first.");
}
}
private string GetFormatHeaderLabel()
=> _textureFormatSortMode switch
{
TextureFormatSortMode.CompressedFirst => "Format (C)##formatHeader",
TextureFormatSortMode.UncompressedFirst => "Format (U)##formatHeader",
_ => "Format##formatHeader"
};
private void SetTextureFormatSortMode(TextureFormatSortMode mode, bool persist = true)
{
if (_textureFormatSortMode == mode)
{
return;
}
_textureFormatSortMode = mode;
if (persist)
{
_configService.Current.TextureFormatSortMode = mode;
_configService.Save();
}
}
private void CycleTextureFormatSortMode()
{
var nextMode = _textureFormatSortMode switch
{
TextureFormatSortMode.None => TextureFormatSortMode.CompressedFirst,
TextureFormatSortMode.CompressedFirst => TextureFormatSortMode.UncompressedFirst,
_ => TextureFormatSortMode.None
};
SetTextureFormatSortMode(nextMode);
}
private void StartTextureConversion()
{
if (_conversionTask != null && !_conversionTask.IsCompleted)
@@ -2335,11 +2675,30 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
{
if (_texturePreviews.TryGetValue(key, out var state))
{
var loadTask = state.LoadTask;
if (loadTask is { IsCompleted: false })
{
_ = loadTask.ContinueWith(_ =>
{
state.Texture?.Dispose();
}, TaskScheduler.Default);
}
state.Texture?.Dispose();
_texturePreviews.Remove(key);
}
}
private void ClearHoverPreview(TextureRow row)
{
if (string.Equals(_selectedTextureKey, row.Key, StringComparison.Ordinal))
{
return;
}
ResetPreview(row.Key);
}
private TextureResolutionInfo? GetTextureResolution(TextureRow row)
{
if (_textureResolutionCache.TryGetValue(row.Key, out var cached))
@@ -2440,7 +2799,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
UiSharedService.AttachToolTip("Already stored in a compressed format; additional compression is disabled.");
}
DrawSelectableColumn(isSelected, () =>
var nameHovered = DrawSelectableColumn(isSelected, () =>
{
var selectableLabel = $"{row.DisplayName}##texName{index}";
if (ImGui.Selectable(selectableLabel, isSelected))
@@ -2448,20 +2807,20 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
_selectedTextureKey = isSelected ? string.Empty : key;
}
return () => UiSharedService.AttachToolTip($"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}");
return null;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
ImGui.TextUnformatted(row.Slot);
return null;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
ImGui.TextUnformatted(row.MapKind.ToString());
return null;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
Action? tooltipAction = null;
ImGui.TextUnformatted(row.Format);
@@ -2475,7 +2834,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
return tooltipAction;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
if (row.SuggestedTarget.HasValue)
{
@@ -2537,19 +2896,21 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
UiSharedService.AttachToolTip("This texture is already compressed and cannot be processed again.");
}
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
ImGui.TextUnformatted(UiSharedService.ByteToString(row.OriginalSize));
return null;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
ImGui.TextUnformatted(UiSharedService.ByteToString(row.CompressedSize));
return null;
});
DrawTextureRowHoverTooltip(row, nameHovered);
}
private static void DrawSelectableColumn(bool isSelected, Func<Action?> draw)
private static bool DrawSelectableColumn(bool isSelected, Func<Action?> draw)
{
ImGui.TableNextColumn();
if (isSelected)
@@ -2558,6 +2919,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
}
var after = draw();
var hovered = ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem);
if (isSelected)
{
@@ -2565,6 +2927,127 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
}
after?.Invoke();
return hovered;
}
private void DrawTextureRowHoverTooltip(TextureRow row, bool isHovered)
{
if (!isHovered)
{
if (string.Equals(_textureHoverKey, row.Key, StringComparison.Ordinal))
{
_textureHoverKey = string.Empty;
_textureHoverStartTime = 0;
ClearHoverPreview(row);
}
return;
}
var now = ImGui.GetTime();
if (!string.Equals(_textureHoverKey, row.Key, StringComparison.Ordinal))
{
_textureHoverKey = row.Key;
_textureHoverStartTime = now;
}
var elapsed = now - _textureHoverStartTime;
if (elapsed < TextureHoverPreviewDelaySeconds)
{
var progress = (float)Math.Clamp(elapsed / TextureHoverPreviewDelaySeconds, 0f, 1f);
DrawTextureRowTextTooltip(row, progress);
return;
}
DrawTextureRowPreviewTooltip(row);
}
private void DrawTextureRowTextTooltip(TextureRow row, float progress)
{
ImGui.BeginTooltip();
ImGui.SetWindowFontScale(1f);
DrawTextureRowTooltipBody(row);
ImGuiHelpers.ScaledDummy(4);
DrawTextureHoverProgressBar(progress, GetTooltipContentWidth());
ImGui.EndTooltip();
}
private void DrawTextureRowPreviewTooltip(TextureRow row)
{
ImGui.BeginTooltip();
ImGui.SetWindowFontScale(1f);
DrawTextureRowTooltipBody(row);
ImGuiHelpers.ScaledDummy(4);
var previewSize = new Vector2(TextureHoverPreviewSize * ImGuiHelpers.GlobalScale);
var (previewTexture, previewLoading, previewError) = GetTexturePreview(row);
if (previewTexture != null)
{
ImGui.Image(previewTexture.Handle, previewSize);
}
else
{
using (ImRaii.Child("textureHoverPreview", previewSize, true))
{
UiSharedService.TextWrapped(previewLoading ? "Loading preview..." : previewError ?? "Preview unavailable.");
}
}
ImGui.EndTooltip();
}
private static void DrawTextureRowTooltipBody(TextureRow row)
{
var text = row.GamePaths.Count > 0
? $"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}"
: row.PrimaryFilePath;
var wrapWidth = GetTextureHoverTooltipWidth();
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth);
if (text.Contains(UiSharedService.TooltipSeparator, StringComparison.Ordinal))
{
var splitText = text.Split(UiSharedService.TooltipSeparator, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < splitText.Length; i++)
{
ImGui.TextUnformatted(splitText[i]);
if (i != splitText.Length - 1)
{
ImGui.Separator();
}
}
}
else
{
ImGui.TextUnformatted(text);
}
ImGui.PopTextWrapPos();
}
private static void DrawTextureHoverProgressBar(float progress, float width)
{
var scale = ImGuiHelpers.GlobalScale;
var barHeight = 4f * scale;
var barWidth = width > 0f ? width : -1f;
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 3f * scale))
using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero))
using (ImRaii.PushColor(ImGuiCol.PlotHistogram, UiSharedService.Color(UIColors.Get("LightlessPurple"))))
{
ImGui.ProgressBar(progress, new Vector2(barWidth, barHeight), string.Empty);
}
}
private static float GetTextureHoverTooltipWidth()
=> ImGui.GetFontSize() * 35f;
private static float GetTooltipContentWidth()
{
var min = ImGui.GetWindowContentRegionMin();
var max = ImGui.GetWindowContentRegionMax();
var width = max.X - min.X;
if (width <= 0f)
{
width = ImGui.GetContentRegionAvail().X;
}
return width;
}
private static void ApplyTextureRowBackground(TextureRow row, bool isSelected)

View File

@@ -17,7 +17,7 @@ namespace LightlessSync.UI;
public class DownloadUi : WindowMediatorSubscriberBase
{
private readonly LightlessConfigService _configService;
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly ConcurrentDictionary<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly DalamudUtilService _dalamudUtilService;
private readonly FileUploadManager _fileTransferManager;
private readonly UiSharedService _uiShared;
@@ -25,6 +25,8 @@ public class DownloadUi : WindowMediatorSubscriberBase
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
private readonly Dictionary<GameObjectHandler, Vector2> _smoothed = [];
private readonly Dictionary<GameObjectHandler, DownloadSpeedTracker> _downloadSpeeds = [];
private readonly Dictionary<GameObjectHandler, (int TotalFiles, long TotalBytes)> _downloadInitialTotals = [];
private byte _transferBoxTransparency = 100;
private bool _notificationDismissed = true;
@@ -66,6 +68,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
{
_currentDownloads[msg.DownloadId] = msg.DownloadStatus;
// Capture initial totals when download starts
var totalFiles = msg.DownloadStatus.Values.Sum(s => s.TotalFiles);
var totalBytes = msg.DownloadStatus.Values.Sum(s => s.TotalBytes);
_downloadInitialTotals[msg.DownloadId] = (totalFiles, totalBytes);
_notificationDismissed = false;
});
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
@@ -164,10 +170,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
const float rounding = 6f;
var shadowOffset = new Vector2(2, 2);
List<KeyValuePair<GameObjectHandler, Dictionary<string, FileDownloadStatus>>> transfers;
List<KeyValuePair<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>>> transfers;
try
{
transfers = _currentDownloads.ToList();
transfers = [.. _currentDownloads];
}
catch (ArgumentException)
{
@@ -206,12 +212,16 @@ public class DownloadUi : WindowMediatorSubscriberBase
var dlQueue = 0;
var dlProg = 0;
var dlDecomp = 0;
var dlComplete = 0;
foreach (var entry in transfer.Value)
{
var fileStatus = entry.Value;
switch (fileStatus.DownloadStatus)
{
case DownloadStatus.Initializing:
dlQueue++;
break;
case DownloadStatus.WaitingForSlot:
dlSlot++;
break;
@@ -224,15 +234,20 @@ public class DownloadUi : WindowMediatorSubscriberBase
case DownloadStatus.Decompressing:
dlDecomp++;
break;
case DownloadStatus.Completed:
dlComplete++;
break;
}
}
var isAllComplete = dlComplete > 0 && dlProg == 0 && dlDecomp == 0 && dlQueue == 0 && dlSlot == 0;
string statusText;
if (dlProg > 0)
{
statusText = "Downloading";
}
else if (dlDecomp > 0 || (totalBytes > 0 && transferredBytes >= totalBytes))
else if (dlDecomp > 0)
{
statusText = "Decompressing";
}
@@ -244,6 +259,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
{
statusText = "Waiting for slot";
}
else if (isAllComplete)
{
statusText = "Completed";
}
else
{
statusText = "Waiting";
@@ -309,7 +328,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
fillPercent = transferredBytes / (double)totalBytes;
showFill = true;
}
else if (dlDecomp > 0 || transferredBytes >= totalBytes)
else if (dlDecomp > 0 || dlComplete > 0 || transferredBytes >= totalBytes)
{
fillPercent = 1.0;
showFill = true;
@@ -341,10 +360,14 @@ public class DownloadUi : WindowMediatorSubscriberBase
downloadText =
$"{statusText} {UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}";
}
else if ((dlDecomp > 0 || transferredBytes >= totalBytes) && hasValidSize)
else if (dlDecomp > 0)
{
downloadText = "Decompressing";
}
else if (isAllComplete)
{
downloadText = "Completed";
}
else
{
// Waiting states
@@ -417,6 +440,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
var totalDlQueue = 0;
var totalDlProg = 0;
var totalDlDecomp = 0;
var totalDlComplete = 0;
var perPlayer = new List<(
string Name,
@@ -428,16 +452,21 @@ public class DownloadUi : WindowMediatorSubscriberBase
int DlSlot,
int DlQueue,
int DlProg,
int DlDecomp)>();
int DlDecomp,
int DlComplete)>();
foreach (var transfer in _currentDownloads)
{
var handler = transfer.Key;
var statuses = transfer.Value.Values;
var playerTotalFiles = statuses.Sum(s => s.TotalFiles);
var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles);
var playerTotalBytes = statuses.Sum(s => s.TotalBytes);
var (playerTotalFiles, playerTotalBytes) = _downloadInitialTotals.TryGetValue(handler, out var totals)
? totals
: (statuses.Sum(s => s.TotalFiles), statuses.Sum(s => s.TotalBytes));
var playerTransferredFiles = statuses.Count(s =>
s.DownloadStatus == DownloadStatus.Decompressing ||
s.TransferredBytes >= s.TotalBytes);
var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes);
totalFiles += playerTotalFiles;
@@ -450,12 +479,17 @@ public class DownloadUi : WindowMediatorSubscriberBase
var playerDlQueue = 0;
var playerDlProg = 0;
var playerDlDecomp = 0;
var playerDlComplete = 0;
foreach (var entry in transfer.Value)
{
var fileStatus = entry.Value;
switch (fileStatus.DownloadStatus)
{
case DownloadStatus.Initializing:
playerDlQueue++;
totalDlQueue++;
break;
case DownloadStatus.WaitingForSlot:
playerDlSlot++;
totalDlSlot++;
@@ -472,6 +506,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
playerDlDecomp++;
totalDlDecomp++;
break;
case DownloadStatus.Completed:
playerDlComplete++;
totalDlComplete++;
break;
}
}
@@ -497,7 +535,8 @@ public class DownloadUi : WindowMediatorSubscriberBase
playerDlSlot,
playerDlQueue,
playerDlProg,
playerDlDecomp
playerDlDecomp,
playerDlComplete
));
}
@@ -521,7 +560,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
// Overall texts
var headerText =
$"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}]";
$"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}/C:{totalDlComplete}]";
var bytesText =
$"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}";
@@ -544,7 +583,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
foreach (var p in perPlayer)
{
var line =
$"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}";
$"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}/C:{p.DlComplete}] {p.TransferredFiles}/{p.TotalFiles}";
var lineSize = ImGui.CalcTextSize(line);
if (lineSize.X > contentWidth)
@@ -662,7 +701,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
&& p.TransferredBytes > 0;
var labelLine =
$"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}";
$"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}/C:{p.DlComplete}] {p.TransferredFiles}/{p.TotalFiles}";
if (!showBar)
{
@@ -721,13 +760,18 @@ public class DownloadUi : WindowMediatorSubscriberBase
// Text inside bar: downloading vs decompressing
string barText;
var isDecompressing = p.DlDecomp > 0 && p.TransferredBytes >= p.TotalBytes && p.TotalBytes > 0;
var isDecompressing = p.DlDecomp > 0;
var isAllComplete = p.DlComplete > 0 && p.DlProg == 0 && p.DlDecomp == 0 && p.DlQueue == 0 && p.DlSlot == 0;
if (isDecompressing)
{
// Keep bar full, static text showing decompressing
barText = "Decompressing...";
}
else if (isAllComplete)
{
barText = "Completed";
}
else
{
var bytesInside =
@@ -808,6 +852,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
var dlQueue = 0;
var dlProg = 0;
var dlDecomp = 0;
var dlComplete = 0;
long totalBytes = 0;
long transferredBytes = 0;
@@ -817,22 +862,29 @@ public class DownloadUi : WindowMediatorSubscriberBase
var fileStatus = entry.Value;
switch (fileStatus.DownloadStatus)
{
case DownloadStatus.Initializing: dlQueue++; break;
case DownloadStatus.WaitingForSlot: dlSlot++; break;
case DownloadStatus.WaitingForQueue: dlQueue++; break;
case DownloadStatus.Downloading: dlProg++; break;
case DownloadStatus.Decompressing: dlDecomp++; break;
case DownloadStatus.Completed: dlComplete++; break;
}
totalBytes += fileStatus.TotalBytes;
transferredBytes += fileStatus.TransferredBytes;
}
var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f;
if (dlComplete > 0 && dlProg == 0 && dlDecomp == 0 && dlQueue == 0 && dlSlot == 0)
{
progress = 1f;
}
string status;
if (dlDecomp > 0) status = "decompressing";
else if (dlProg > 0) status = "downloading";
else if (dlQueue > 0) status = "queued";
else if (dlSlot > 0) status = "waiting";
else if (dlComplete > 0) status = "completed";
else status = "completed";
downloadStatus.Add((item.Key.Name, progress, status));

View File

@@ -217,6 +217,7 @@ public class DrawEntityFactory
entry.PairStatus,
handler?.LastAppliedDataBytes ?? -1,
handler?.LastAppliedDataTris ?? -1,
handler?.LastAppliedApproximateEffectiveTris ?? -1,
handler?.LastAppliedApproximateVRAMBytes ?? -1,
handler?.LastAppliedApproximateEffectiveVRAMBytes ?? -1,
handler);

View File

@@ -103,10 +103,19 @@ public sealed class DtrEntry : IDisposable, IHostedService
public async Task StopAsync(CancellationToken cancellationToken)
{
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
_cancellationTokenSource.Cancel();
if (_dalamudUtilService.IsOnFrameworkThread)
{
_logger.LogDebug("Skipping Lightfinder DTR wait on framework thread during shutdown.");
_cancellationTokenSource.Dispose();
return;
}
try
{
await _runTask!.ConfigureAwait(false);
if (_runTask != null)
await _runTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{

View File

@@ -415,7 +415,9 @@ public class IdDisplayHandler
var vramBytes = pair.LastAppliedApproximateEffectiveVRAMBytes >= 0
? pair.LastAppliedApproximateEffectiveVRAMBytes
: pair.LastAppliedApproximateVRAMBytes;
var triangleCount = pair.LastAppliedDataTris;
var triangleCount = pair.LastAppliedApproximateEffectiveTris >= 0
? pair.LastAppliedApproximateEffectiveTris
: pair.LastAppliedDataTris;
if (vramBytes < 0 && triangleCount < 0)
{
return null;

View File

@@ -21,6 +21,7 @@ public sealed record PairUiEntry(
IndividualPairStatus? PairStatus,
long LastAppliedDataBytes,
long LastAppliedDataTris,
long LastAppliedApproximateEffectiveTris,
long LastAppliedApproximateVramBytes,
long LastAppliedApproximateEffectiveVramBytes,
IPairHandlerAdapter? Handler)

View File

@@ -0,0 +1,8 @@
namespace LightlessSync.UI.Models;
public enum TextureFormatSortMode
{
None = 0,
CompressedFirst = 1,
UncompressedFirst = 2
}

View File

@@ -7,4 +7,5 @@ public enum VisiblePairSortMode
EffectiveVramUsage = 2,
TriangleCount = 3,
PreferredDirectPairs = 4,
EffectiveTriangleCount = 5,
}

View File

@@ -15,6 +15,7 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
@@ -41,6 +42,7 @@ using System.Globalization;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
@@ -52,7 +54,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly CacheMonitor _cacheMonitor;
private readonly LightlessConfigService _configService;
private readonly UiThemeConfigService _themeConfigService;
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly ConcurrentDictionary<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly DalamudUtilService _dalamudUtilService;
private readonly HttpClient _httpClient;
private readonly FileCacheManager _fileCacheManager;
@@ -108,8 +110,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
};
private readonly UiSharedService.TabOption<TransferSettingsTab>[] _transferTabOptions = new UiSharedService.TabOption<TransferSettingsTab>[2];
private readonly List<UiSharedService.TabOption<ServerSettingsTab>> _serverTabOptions = new(4);
private readonly string[] _generalTreeNavOrder = new[]
{
private readonly string[] _generalTreeNavOrder =
[
"Import & Export",
"Popup & Auto Fill",
"Behavior",
@@ -119,7 +121,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
"Colors",
"Server Info Bar",
"Nameplate",
};
"Animation & Bones"
];
private static readonly HashSet<string> _generalNavSeparatorAfter = new(StringComparer.Ordinal)
{
"Popup & Auto Fill",
@@ -581,6 +584,94 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
}
private void DrawTriangleDecimationCounters()
{
HashSet<Pair> trackedPairs = new();
var snapshot = _pairUiService.GetSnapshot();
foreach (var pair in snapshot.DirectPairs)
{
trackedPairs.Add(pair);
}
foreach (var group in snapshot.GroupPairs.Values)
{
foreach (var pair in group)
{
trackedPairs.Add(pair);
}
}
long totalOriginalTris = 0;
long totalEffectiveTris = 0;
var hasData = false;
foreach (var pair in trackedPairs)
{
if (!pair.IsVisible)
continue;
var original = pair.LastAppliedDataTris;
var effective = pair.LastAppliedApproximateEffectiveTris;
if (original >= 0)
{
hasData = true;
totalOriginalTris += original;
}
if (effective >= 0)
{
hasData = true;
totalEffectiveTris += effective;
}
}
if (!hasData)
{
ImGui.TextDisabled("Triangle usage has not been calculated yet.");
return;
}
var savedTris = Math.Max(0L, totalOriginalTris - totalEffectiveTris);
var originalText = FormatTriangleCount(totalOriginalTris);
var effectiveText = FormatTriangleCount(totalEffectiveTris);
var savedText = FormatTriangleCount(savedTris);
ImGui.TextUnformatted($"Total triangle usage (original): {originalText}");
ImGui.TextUnformatted($"Total triangle usage (effective): {effectiveText}");
if (savedTris > 0)
{
UiSharedService.ColorText($"Triangles saved by decimation: {savedText}", UIColors.Get("LightlessGreen"));
}
else
{
ImGui.TextUnformatted($"Triangles saved by decimation: {savedText}");
}
static string FormatTriangleCount(long triangleCount)
{
if (triangleCount < 0)
{
return "n/a";
}
if (triangleCount >= 1_000_000)
{
return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m tris");
}
if (triangleCount >= 1_000)
{
return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k tris");
}
return $"{triangleCount} tris";
}
}
private void DrawThemeVectorRow(MainStyle.StyleVector2Option option)
{
ImGui.TableNextRow();
@@ -870,10 +961,11 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared.DrawHelpText(
$"The download window will show the current progress of outstanding downloads.{Environment.NewLine}{Environment.NewLine}" +
$"What do W/Q/P/D stand for?{Environment.NewLine}W = Waiting for Slot (see Maximum Parallel Downloads){Environment.NewLine}" +
$"What do W/Q/P/D/C stand for?{Environment.NewLine}W = Waiting for Slot (see Maximum Parallel Downloads){Environment.NewLine}" +
$"Q = Queued on Server, waiting for queue ready signal{Environment.NewLine}" +
$"P = Processing download (aka downloading){Environment.NewLine}" +
$"D = Decompressing download");
$"D = Decompressing download{Environment.NewLine}" +
$"C = Completed download");
if (!_configService.Current.ShowTransferWindow) ImGui.BeginDisabled();
ImGui.Indent();
@@ -1148,7 +1240,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
private async Task<List<string>?> RunSpeedTest(List<string> servers, CancellationToken token)
{
List<string> speedTestResults = new();
List<string> speedTestResults = [];
foreach (var server in servers)
{
HttpResponseMessage? result = null;
@@ -1533,6 +1625,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
DrawPairPropertyRow("Approx. VRAM", FormatBytes(pair.LastAppliedApproximateVRAMBytes));
DrawPairPropertyRow("Effective VRAM", FormatBytes(pair.LastAppliedApproximateEffectiveVRAMBytes));
DrawPairPropertyRow("Last Triangles", pair.LastAppliedDataTris < 0 ? "n/a" : pair.LastAppliedDataTris.ToString(CultureInfo.InvariantCulture));
DrawPairPropertyRow("Effective Triangles", pair.LastAppliedApproximateEffectiveTris < 0 ? "n/a" : pair.LastAppliedApproximateEffectiveTris.ToString(CultureInfo.InvariantCulture));
ImGui.EndTable();
}
@@ -1964,14 +2057,25 @@ public class SettingsUi : WindowMediatorSubscriberBase
{
using (ImRaii.PushIndent(20f))
{
if (_validationTask.IsCompleted)
if (_validationTask.IsCompletedSuccessfully)
{
UiSharedService.TextWrapped(
$"The storage validation has completed and removed {_validationTask.Result.Count} invalid files from storage.");
}
else if (_validationTask.IsCanceled)
{
UiSharedService.ColorTextWrapped(
"Storage validation was cancelled.",
UIColors.Get("LightlessYellow"));
}
else if (_validationTask.IsFaulted)
{
UiSharedService.ColorTextWrapped(
"Storage validation failed with an error.",
UIColors.Get("DimRed"));
}
else
{
UiSharedService.TextWrapped(
$"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}");
if (_currentProgress.Item3 != null)
@@ -3127,10 +3231,102 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
ImGui.Separator();
ImGui.Dummy(new Vector2(10));
_uiShared.BigText("Animation");
using (var animationTree = BeginGeneralTree("Animation & Bones", UIColors.Get("LightlessPurple")))
{
if (animationTree.Visible)
{
ImGui.TextUnformatted("Animation Options");
var modes = new[]
{
AnimationValidationMode.Unsafe,
AnimationValidationMode.Safe,
AnimationValidationMode.Safest,
};
var labels = new[]
{
"Unsafe",
"Safe (Race)",
"Safest (Race + Bones)",
};
var tooltips = new[]
{
"No validation. Fastest, but may allow incompatible animations (riskier).",
"Validates skeleton race + modded skeleton check (recommended).",
"Requires matching skeleton race + bone compatibility (strictest).",
};
var currentMode = _configService.Current.AnimationValidationMode;
int selectedIndex = Array.IndexOf(modes, currentMode);
if (selectedIndex < 0) selectedIndex = 1;
ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale);
bool open = ImGui.BeginCombo("Animation validation", labels[selectedIndex]);
if (ImGui.IsItemHovered())
ImGui.SetTooltip(tooltips[selectedIndex]);
if (open)
{
for (int i = 0; i < modes.Length; i++)
{
bool isSelected = (i == selectedIndex);
if (ImGui.Selectable(labels[i], isSelected))
{
selectedIndex = i;
_configService.Current.AnimationValidationMode = modes[i];
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(tooltips[i]);
if (isSelected)
ImGui.SetItemDefaultFocus();
}
ImGui.EndCombo();
}
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
var cfg = _configService.Current;
bool oneBased = cfg.AnimationAllowOneBasedShift;
if (ImGui.Checkbox("Treat 1-based PAP indices as compatible", ref oneBased))
{
cfg.AnimationAllowOneBasedShift = oneBased;
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Fixes off-by-one PAPs (one bone differance in bones and PAP). Can also increase crashing, toggle off if alot of crashing is happening");
bool neighbor = cfg.AnimationAllowNeighborIndexTolerance;
if (ImGui.Checkbox("Allow 1+- bone index tolerance", ref neighbor))
{
cfg.AnimationAllowNeighborIndexTolerance = neighbor;
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Looser matching on bone matching. Can reduce false blocks happening but also reduces safety and more prone to crashing.");
ImGui.TreePop();
animationTree.MarkContentEnd();
}
}
ImGui.EndChild();
ImGui.EndGroup();
ImGui.Separator();
generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
}
}
@@ -3220,6 +3416,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
return 1f - (elapsed / GeneralTreeHighlightDuration);
}
[StructLayout(LayoutKind.Auto)]
private struct GeneralTreeScope : IDisposable
{
private readonly bool _visible;
@@ -3527,7 +3724,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit.");
var dimensionOptions = new[] { 512, 1024, 2048, 4096 };
var optionLabels = dimensionOptions.Select(static value => value.ToString()).ToArray();
var optionLabels = dimensionOptions.Select(selector: static value => value.ToString()).ToArray();
var currentDimension = textureConfig.TextureDownscaleMaxDimension;
var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
if (selectedIndex < 0)
@@ -3553,6 +3750,14 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SameLine();
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective VRAM usage information will not work.", UIColors.Get("LightlessYellow")));
var skipPreferredDownscale = textureConfig.SkipTextureDownscaleForPreferredPairs;
if (ImGui.Checkbox("Skip downscale for preferred/direct pairs", ref skipPreferredDownscale))
{
textureConfig.SkipTextureDownscaleForPreferredPairs = skipPreferredDownscale;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When enabled, textures for direct pairs with preferred permissions are left untouched.");
if (!textureConfig.EnableNonIndexTextureMipTrim && !textureConfig.EnableIndexTextureDownscale)
{
UiSharedService.ColorTextWrapped("Both trimming and downscale are disabled. Lightless will keep original textures regardless of size.", UIColors.Get("DimRed"));
@@ -3580,6 +3785,160 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TreePop();
}
ImGui.Separator();
if (_uiShared.MediumTreeNode("Model Optimization", UIColors.Get("DimRed")))
{
_uiShared.MediumText("Warning", UIColors.Get("DimRed"));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Model decimation is a "),
new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances."));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("This feature is encouraged to help "),
new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry(" and for use in "),
new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry("."));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Runtime decimation "),
new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads."));
_uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true));
ImGui.Dummy(new Vector2(15));
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"),
new SeStringUtils.RichTextEntry("If a mesh exceeds the "),
new SeStringUtils.RichTextEntry("triangle threshold", UIColors.Get("LightlessGreen"), true),
new SeStringUtils.RichTextEntry(", it will be decimated automatically to the set "),
new SeStringUtils.RichTextEntry("target triangle ratio", UIColors.Get("LightlessGreen"), true),
new SeStringUtils.RichTextEntry(". This will reduce quality of the mesh or may break it's intended structure."));
var performanceConfig = _playerPerformanceConfigService.Current;
var enableDecimation = performanceConfig.EnableModelDecimation;
if (ImGui.Checkbox("Enable model decimation", ref enableDecimation))
{
performanceConfig.EnableModelDecimation = enableDecimation;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When enabled, Lightless generates a decimated copy of given model after download.");
var keepOriginalModels = performanceConfig.KeepOriginalModelFiles;
if (ImGui.Checkbox("Keep original model files", ref keepOriginalModels))
{
performanceConfig.KeepOriginalModelFiles = keepOriginalModels;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When disabled, Lightless removes the original model after a decimated copy is created.");
ImGui.SameLine();
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective triangle usage information will not work.", UIColors.Get("LightlessYellow")));
var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs;
if (ImGui.Checkbox("Skip decimation for preferred/direct pairs", ref skipPreferredDecimation))
{
performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When enabled, models for direct pairs with preferred permissions are left untouched.");
var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold;
ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
if (ImGui.SliderInt("Decimate models above", ref triangleThreshold, 8_000, 100_000))
{
performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 8_000, 100_000);
_playerPerformanceConfigService.Save();
}
ImGui.SameLine();
ImGui.Text("triangles");
_uiShared.DrawHelpText($"Models below this triangle count are left untouched.{UiSharedService.TooltipSeparator}Default: 50,000");
var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0);
var clampedPercent = Math.Clamp(targetPercent, 60f, 99f);
if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon)
{
performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0;
_playerPerformanceConfigService.Save();
targetPercent = clampedPercent;
}
ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
if (ImGui.SliderFloat("Target triangle ratio", ref targetPercent, 60f, 99f, "%.0f%%"))
{
performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f);
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText($"Target ratio relative to original triangle count (80% keeps 80% of triangles).{UiSharedService.TooltipSeparator}Default: 80%");
ImGui.Dummy(new Vector2(15));
ImGui.TextUnformatted("Decimation targets");
_uiShared.DrawHelpText("Hair mods are always excluded from decimation.");
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"),
new SeStringUtils.RichTextEntry("Automatic decimation will only target the selected "),
new SeStringUtils.RichTextEntry("decimation targets", UIColors.Get("LightlessGreen"), true),
new SeStringUtils.RichTextEntry("."));
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("It is advised to not decimate any body related meshes which includes: "),
new SeStringUtils.RichTextEntry("facial mods + sculpts, chest, legs, hands and feet", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry("."));
_uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Remember, automatic decimation is not perfect and can cause meshes to be ruined, especially hair mods.", UIColors.Get("DimRed"), true));
var allowBody = performanceConfig.ModelDecimationAllowBody;
if (ImGui.Checkbox("Body", ref allowBody))
{
performanceConfig.ModelDecimationAllowBody = allowBody;
_playerPerformanceConfigService.Save();
}
var allowFaceHead = performanceConfig.ModelDecimationAllowFaceHead;
if (ImGui.Checkbox("Face/head", ref allowFaceHead))
{
performanceConfig.ModelDecimationAllowFaceHead = allowFaceHead;
_playerPerformanceConfigService.Save();
}
var allowTail = performanceConfig.ModelDecimationAllowTail;
if (ImGui.Checkbox("Tails/Ears", ref allowTail))
{
performanceConfig.ModelDecimationAllowTail = allowTail;
_playerPerformanceConfigService.Save();
}
var allowClothing = performanceConfig.ModelDecimationAllowClothing;
if (ImGui.Checkbox("Clothing (body/legs/shoes/gloves/hats)", ref allowClothing))
{
performanceConfig.ModelDecimationAllowClothing = allowClothing;
_playerPerformanceConfigService.Save();
}
var allowAccessories = performanceConfig.ModelDecimationAllowAccessories;
if (ImGui.Checkbox("Accessories (earring/rings/bracelet/necklace)", ref allowAccessories))
{
performanceConfig.ModelDecimationAllowAccessories = allowAccessories;
_playerPerformanceConfigService.Save();
}
ImGui.Dummy(new Vector2(5));
UiSharedService.ColoredSeparator(UIColors.Get("LightlessGrey"), 3f);
ImGui.Dummy(new Vector2(5));
DrawTriangleDecimationCounters();
ImGui.Dummy(new Vector2(5));
UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f);
ImGui.TreePop();
}
ImGui.Separator();
ImGui.Dummy(new Vector2(10));

View File

@@ -22,7 +22,6 @@ using LightlessSync.UI.Services;
using LightlessSync.UI.Style;
using LightlessSync.Utils;
using Dalamud.Interface.Textures.TextureWraps;
using OtterGui.Text;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.Extensions.Logging;
@@ -205,10 +204,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
private void ApplyUiVisibilitySettings()
{
var config = _chatConfigService.Current;
_uiBuilder.DisableUserUiHide = true;
_uiBuilder.DisableCutsceneUiHide = config.ShowInCutscenes;
_uiBuilder.DisableGposeUiHide = config.ShowInGpose;
_uiBuilder.DisableCutsceneUiHide = true;
}
private bool ShouldHide()
@@ -220,6 +217,16 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
return true;
}
if (!config.ShowInGpose && _dalamudUtilService.IsInGpose)
{
return true;
}
if (!config.ShowInCutscenes && _dalamudUtilService.IsInCutscene)
{
return true;
}
if (config.HideInCombat && _dalamudUtilService.IsInCombat)
{
return true;
@@ -421,150 +428,182 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
}
else
{
var itemHeight = ImGui.GetTextLineHeightWithSpacing();
using var clipper = ImUtf8.ListClipper(channel.Messages.Count, itemHeight);
while (clipper.Step())
var messageCount = channel.Messages.Count;
var contentMaxX = ImGui.GetWindowContentRegionMax().X;
var cursorStartX = ImGui.GetCursorPosX();
var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing();
var prefix = new float[messageCount + 1];
var totalHeight = 0f;
for (var i = 0; i < messageCount; i++)
{
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, ref pairSnapshot);
if (messageHeight <= 0f)
{
var message = channel.Messages[i];
ImGui.PushID(i);
messageHeight = lineHeightWithSpacing;
}
if (message.IsSystem)
totalHeight += messageHeight;
prefix[i + 1] = totalHeight;
}
var scrollY = ImGui.GetScrollY();
var windowHeight = ImGui.GetWindowHeight();
var startIndex = Math.Max(0, UpperBound(prefix, scrollY) - 1);
var endIndex = Math.Min(messageCount, LowerBound(prefix, scrollY + windowHeight));
startIndex = Math.Max(0, startIndex - 2);
endIndex = Math.Min(messageCount, endIndex + 2);
if (startIndex > 0)
{
ImGui.Dummy(new Vector2(1f, prefix[startIndex]));
}
for (var i = startIndex; i < endIndex; i++)
{
var message = channel.Messages[i];
ImGui.PushID(i);
if (message.IsSystem)
{
DrawSystemEntry(message);
ImGui.PopID();
continue;
}
if (message.Payload is not { } payload)
{
ImGui.PopID();
continue;
}
var timestampText = string.Empty;
if (showTimestamps)
{
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
}
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
var showRoleIcons = false;
var isOwner = false;
var isModerator = false;
var isPinned = false;
if (channel.Type == ChatChannelType.Group
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
&& payload.Sender.User is not null)
{
pairSnapshot ??= _pairUiService.GetSnapshot();
var groupId = channel.Descriptor.CustomKey;
if (!string.IsNullOrWhiteSpace(groupId)
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
{
DrawSystemEntry(message);
ImGui.PopID();
continue;
var senderUid = payload.Sender.User.UID;
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
{
isModerator = info.IsModerator();
isPinned = info.IsPinned();
}
}
if (message.Payload is not { } payload)
showRoleIcons = isOwner || isModerator || isPinned;
}
ImGui.BeginGroup();
ImGui.PushStyleColor(ImGuiCol.Text, color);
if (showRoleIcons)
{
if (!string.IsNullOrEmpty(timestampText))
{
ImGui.PopID();
continue;
ImGui.TextUnformatted(timestampText);
ImGui.SameLine(0f, 0f);
}
var timestampText = string.Empty;
if (showTimestamps)
var hasIcon = false;
if (isModerator)
{
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
}
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
var showRoleIcons = false;
var isOwner = false;
var isModerator = false;
var isPinned = false;
if (channel.Type == ChatChannelType.Group
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
&& payload.Sender.User is not null)
{
pairSnapshot ??= _pairUiService.GetSnapshot();
var groupId = channel.Descriptor.CustomKey;
if (!string.IsNullOrWhiteSpace(groupId)
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
{
var senderUid = payload.Sender.User.UID;
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
{
isModerator = info.IsModerator();
isPinned = info.IsPinned();
}
}
showRoleIcons = isOwner || isModerator || isPinned;
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
UiSharedService.AttachToolTip("Moderator");
hasIcon = true;
}
ImGui.BeginGroup();
ImGui.PushStyleColor(ImGuiCol.Text, color);
if (showRoleIcons)
if (isOwner)
{
if (!string.IsNullOrEmpty(timestampText))
{
ImGui.TextUnformatted(timestampText);
ImGui.SameLine(0f, 0f);
}
var hasIcon = false;
if (isModerator)
{
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
UiSharedService.AttachToolTip("Moderator");
hasIcon = true;
}
if (isOwner)
{
if (hasIcon)
{
ImGui.SameLine(0f, itemSpacing);
}
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
UiSharedService.AttachToolTip("Owner");
hasIcon = true;
}
if (isPinned)
{
if (hasIcon)
{
ImGui.SameLine(0f, itemSpacing);
}
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue"));
UiSharedService.AttachToolTip("Pinned");
hasIcon = true;
}
if (hasIcon)
{
ImGui.SameLine(0f, itemSpacing);
}
var messageStartX = ImGui.GetCursorPosX();
DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX);
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
UiSharedService.AttachToolTip("Owner");
hasIcon = true;
}
else
{
var messageStartX = ImGui.GetCursorPosX();
DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX);
}
ImGui.PopStyleColor();
ImGui.EndGroup();
ImGui.SetNextWindowSizeConstraints(
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
new Vector2(float.MaxValue, float.MaxValue));
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
if (isPinned)
{
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
ImGui.TextDisabled(contextTimestampText);
if (channel.Type == ChatChannelType.Group
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
&& payload.Sender.User is not null)
if (hasIcon)
{
var aliasOrUid = payload.Sender.User.AliasOrUID;
if (!string.IsNullOrWhiteSpace(aliasOrUid)
&& !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal))
{
ImGui.TextDisabled(aliasOrUid);
}
}
ImGui.Separator();
var actionIndex = 0;
foreach (var action in GetContextMenuActions(channel, message))
{
DrawContextMenuAction(action, actionIndex++);
ImGui.SameLine(0f, itemSpacing);
}
ImGui.EndPopup();
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue"));
UiSharedService.AttachToolTip("Pinned");
hasIcon = true;
}
ImGui.PopID();
if (hasIcon)
{
ImGui.SameLine(0f, itemSpacing);
}
var messageStartX = ImGui.GetCursorPosX();
DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX);
}
else
{
var messageStartX = ImGui.GetCursorPosX();
DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX);
}
ImGui.PopStyleColor();
ImGui.EndGroup();
ImGui.SetNextWindowSizeConstraints(
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
new Vector2(float.MaxValue, float.MaxValue));
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
{
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
ImGui.TextDisabled(contextTimestampText);
if (channel.Type == ChatChannelType.Group
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
&& payload.Sender.User is not null)
{
var aliasOrUid = payload.Sender.User.AliasOrUID;
if (!string.IsNullOrWhiteSpace(aliasOrUid)
&& !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal))
{
ImGui.TextDisabled(aliasOrUid);
}
}
ImGui.Separator();
var actionIndex = 0;
foreach (var action in GetContextMenuActions(channel, message))
{
DrawContextMenuAction(action, actionIndex++);
}
ImGui.EndPopup();
}
ImGui.PopID();
}
var remainingHeight = totalHeight - prefix[endIndex];
if (remainingHeight > 0f)
{
ImGui.Dummy(new Vector2(1f, remainingHeight));
}
}
@@ -700,7 +739,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
var clicked = false;
if (texture is not null)
{
clicked = ImGui.ImageButton(texture.Handle, new Vector2(emoteSize));
var buttonSize = new Vector2(itemWidth, itemHeight);
clicked = ImGui.InvisibleButton("##emote_button", buttonSize);
var drawList = ImGui.GetWindowDrawList();
var itemMin = ImGui.GetItemRectMin();
var itemMax = ImGui.GetItemRectMax();
var bgColor = ImGui.IsItemActive()
? ImGui.GetColorU32(ImGuiCol.ButtonActive)
: ImGui.IsItemHovered()
? ImGui.GetColorU32(ImGuiCol.ButtonHovered)
: ImGui.GetColorU32(ImGuiCol.Button);
drawList.AddRectFilled(itemMin, itemMax, bgColor, style.FrameRounding);
var imageMin = itemMin + style.FramePadding;
var imageMax = imageMin + new Vector2(emoteSize);
drawList.AddImage(texture.Handle, imageMin, imageMax);
}
else
{
@@ -870,7 +922,232 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
private static bool IsEmoteChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!';
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!' || value == '(' || value == ')';
}
private float MeasureMessageHeight(
ChatChannelSnapshot channel,
ChatMessageEntry message,
bool showTimestamps,
float cursorStartX,
float contentMaxX,
float itemSpacing,
ref PairUiSnapshot? pairSnapshot)
{
if (message.IsSystem)
{
return MeasureSystemEntryHeight(message);
}
if (message.Payload is not { } payload)
{
return 0f;
}
var timestampText = string.Empty;
if (showTimestamps)
{
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
}
var showRoleIcons = false;
var isOwner = false;
var isModerator = false;
var isPinned = false;
if (channel.Type == ChatChannelType.Group
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
&& payload.Sender.User is not null)
{
pairSnapshot ??= _pairUiService.GetSnapshot();
var groupId = channel.Descriptor.CustomKey;
if (!string.IsNullOrWhiteSpace(groupId)
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
{
var senderUid = payload.Sender.User.UID;
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
{
isModerator = info.IsModerator();
isPinned = info.IsPinned();
}
}
showRoleIcons = isOwner || isModerator || isPinned;
}
var lineStartX = cursorStartX;
string prefix;
if (showRoleIcons)
{
lineStartX += MeasureRolePrefixWidth(timestampText, isOwner, isModerator, isPinned, itemSpacing);
prefix = $"{message.DisplayName}: ";
}
else
{
prefix = $"{timestampText}{message.DisplayName}: ";
}
var lines = MeasureChatMessageLines(prefix, payload.Message, lineStartX, contentMaxX);
return Math.Max(1, lines) * ImGui.GetTextLineHeightWithSpacing();
}
private int MeasureChatMessageLines(string prefix, string message, float lineStartX, float contentMaxX)
{
var segments = BuildChatSegments(prefix, message);
if (segments.Count == 0)
{
return 1;
}
var emoteWidth = ImGui.GetTextLineHeight();
var availableWidth = Math.Max(1f, contentMaxX - lineStartX);
var remainingWidth = availableWidth;
var firstOnLine = true;
var lines = 1;
foreach (var segment in segments)
{
if (segment.IsLineBreak)
{
lines++;
firstOnLine = true;
remainingWidth = availableWidth;
continue;
}
if (segment.IsWhitespace && firstOnLine)
{
continue;
}
var segmentWidth = segment.IsEmote ? emoteWidth : ImGui.CalcTextSize(segment.Text).X;
if (!firstOnLine)
{
if (segmentWidth > remainingWidth)
{
lines++;
firstOnLine = true;
remainingWidth = availableWidth;
if (segment.IsWhitespace)
{
continue;
}
}
}
remainingWidth -= segmentWidth;
firstOnLine = false;
}
return lines;
}
private float MeasureRolePrefixWidth(string timestampText, bool isOwner, bool isModerator, bool isPinned, float itemSpacing)
{
var width = 0f;
if (!string.IsNullOrEmpty(timestampText))
{
width += ImGui.CalcTextSize(timestampText).X;
}
var hasIcon = false;
if (isModerator)
{
width += MeasureIconWidth(FontAwesomeIcon.UserShield);
hasIcon = true;
}
if (isOwner)
{
if (hasIcon)
{
width += itemSpacing;
}
width += MeasureIconWidth(FontAwesomeIcon.Crown);
hasIcon = true;
}
if (isPinned)
{
if (hasIcon)
{
width += itemSpacing;
}
width += MeasureIconWidth(FontAwesomeIcon.Thumbtack);
hasIcon = true;
}
if (hasIcon)
{
width += itemSpacing;
}
return width;
}
private float MeasureIconWidth(FontAwesomeIcon icon)
{
using var font = _uiSharedService.IconFont.Push();
return ImGui.CalcTextSize(icon.ToIconString()).X;
}
private float MeasureSystemEntryHeight(ChatMessageEntry entry)
{
_ = entry;
var spacing = ImGui.GetStyle().ItemSpacing.Y;
var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing();
var separatorHeight = Math.Max(1f, ImGuiHelpers.GlobalScale);
var height = spacing;
height += lineHeightWithSpacing;
height += spacing * 0.35f;
height += separatorHeight;
height += spacing;
return height;
}
private static int LowerBound(float[] values, float target)
{
var low = 0;
var high = values.Length;
while (low < high)
{
var mid = (low + high) / 2;
if (values[mid] < target)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private static int UpperBound(float[] values, float target)
{
var low = 0;
var high = values.Length;
while (low < high)
{
var mid = (low + high) / 2;
if (values[mid] <= target)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private void DrawEmoteTooltip(string name, IDalamudTextureWrap? texture)
@@ -2084,6 +2361,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat.");
}
var enableAnimatedEmotes = chatConfig.EnableAnimatedEmotes;
if (ImGui.Checkbox("Enable animated emotes", ref enableAnimatedEmotes))
{
chatConfig.EnableAnimatedEmotes = enableAnimatedEmotes;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("When disabled, emotes render as static images.");
}
ImGui.Separator();
ImGui.TextUnformatted("Chat Visibility");

View File

@@ -57,7 +57,8 @@ public static class VariousExtensions
}
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(this CharacterData newData, Guid applicationBase,
CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods)
CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods,
bool suppressForcedRedrawOnForcedModApply = false)
{
oldData ??= new();
@@ -78,6 +79,7 @@ public static class VariousExtensions
bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null;
bool hasNewAndOldGlamourerData = newGlamourerData != null && existingGlamourerData != null;
var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply;
if (hasNewButNotOldFileReplacements || hasOldButNotNewFileReplacements || hasNewButNotOldGlamourerData || hasOldButNotNewGlamourerData)
{
@@ -100,7 +102,7 @@ public static class VariousExtensions
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
if (forceApplyMods || objectKind != ObjectKind.Player)
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
{
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
@@ -167,7 +169,7 @@ public static class VariousExtensions
if (objectKind != ObjectKind.Player) continue;
bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
if (manipDataDifferent || forceApplyMods)
if (manipDataDifferent || forceRedrawOnForcedApply)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip);

View File

@@ -6,6 +6,7 @@ using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging;
@@ -17,19 +18,21 @@ namespace LightlessSync.WebAPI.Files;
public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{
private readonly Dictionary<string, FileDownloadStatus> _downloadStatus;
private readonly object _downloadStatusLock = new();
private readonly ConcurrentDictionary<string, FileDownloadStatus> _downloadStatus;
private readonly FileCompactor _fileCompactor;
private readonly FileCacheManager _fileDbManager;
private readonly FileTransferOrchestrator _orchestrator;
private readonly LightlessConfigService _configService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService;
private readonly TextureMetadataHelper _textureMetadataHelper;
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
private readonly SemaphoreSlim _decompressGate =
new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2));
private readonly ConcurrentQueue<string> _deferredCompressionQueue = new();
private volatile bool _disableDirectDownloads;
private int _consecutiveDirectDownloadFailures;
@@ -43,14 +46,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
FileCompactor fileCompactor,
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService,
TextureMetadataHelper textureMetadataHelper) : base(logger, mediator)
{
_downloadStatus = new Dictionary<string, FileDownloadStatus>(StringComparer.Ordinal);
_downloadStatus = new ConcurrentDictionary<string, FileDownloadStatus>(StringComparer.Ordinal);
_orchestrator = orchestrator;
_fileDbManager = fileCacheManager;
_fileCompactor = fileCompactor;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_textureMetadataHelper = textureMetadataHelper;
_activeDownloadStreams = new();
_lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads;
@@ -84,19 +89,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
public void ClearDownload()
{
CurrentDownloads.Clear();
lock (_downloadStatusLock)
{
_downloadStatus.Clear();
}
_downloadStatus.Clear();
CurrentOwnerToken = null;
}
public async Task DownloadFiles(GameObjectHandler? gameObject, List<FileReplacementData> fileReplacementDto, CancellationToken ct, bool skipDownscale = false)
public async Task DownloadFiles(GameObjectHandler? gameObject, List<FileReplacementData> fileReplacementDto, CancellationToken ct, bool skipDownscale = false, bool skipDecimation = false)
{
Mediator.Publish(new HaltScanMessage(nameof(DownloadFiles)));
try
{
await DownloadFilesInternal(gameObject, fileReplacementDto, ct, skipDownscale).ConfigureAwait(false);
await DownloadFilesInternal(gameObject, fileReplacementDto, ct, skipDownscale, skipDecimation).ConfigureAwait(false);
}
catch
{
@@ -154,29 +156,20 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private void SetStatus(string key, DownloadStatus status)
{
lock (_downloadStatusLock)
{
if (_downloadStatus.TryGetValue(key, out var st))
st.DownloadStatus = status;
}
if (_downloadStatus.TryGetValue(key, out var st))
st.DownloadStatus = status;
}
private void AddTransferredBytes(string key, long delta)
{
lock (_downloadStatusLock)
{
if (_downloadStatus.TryGetValue(key, out var st))
st.TransferredBytes += delta;
}
if (_downloadStatus.TryGetValue(key, out var st))
st.AddTransferredBytes(delta);
}
private void MarkTransferredFiles(string key, int files)
{
lock (_downloadStatusLock)
{
if (_downloadStatus.TryGetValue(key, out var st))
st.TransferredFiles = files;
}
if (_downloadStatus.TryGetValue(key, out var st))
st.SetTransferredFiles(files);
}
private static byte MungeByte(int byteOrEof)
@@ -404,76 +397,32 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private async Task WaitForDownloadReady(List<DownloadFileTransfer> downloadFileTransfer, Guid requestId, CancellationToken downloadCt)
{
bool alreadyCancelled = false;
try
while (true)
{
CancellationTokenSource localTimeoutCts = new();
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
CancellationTokenSource composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
downloadCt.ThrowIfCancellationRequested();
while (!_orchestrator.IsDownloadReady(requestId))
if (_orchestrator.IsDownloadReady(requestId))
break;
using var resp = await _orchestrator.SendRequestAsync(
HttpMethod.Get,
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
downloadFileTransfer.Select(t => t.Hash).ToList(),
downloadCt).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var body = (await resp.Content.ReadAsStringAsync(downloadCt).ConfigureAwait(false)).Trim();
if (string.Equals(body, "true", StringComparison.OrdinalIgnoreCase) ||
body.Contains("\"ready\":true", StringComparison.OrdinalIgnoreCase))
{
try
{
await Task.Delay(250, composite.Token).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
if (downloadCt.IsCancellationRequested) throw;
var req = await _orchestrator.SendRequestAsync(
HttpMethod.Get,
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
downloadFileTransfer.Select(c => c.Hash).ToList(),
downloadCt).ConfigureAwait(false);
req.EnsureSuccessStatusCode();
localTimeoutCts.Dispose();
composite.Dispose();
localTimeoutCts = new();
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
}
break;
}
localTimeoutCts.Dispose();
composite.Dispose();
Logger.LogDebug("Download {requestId} ready", requestId);
await Task.Delay(250, downloadCt).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
try
{
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
.ConfigureAwait(false);
alreadyCancelled = true;
}
catch
{
// ignore
}
throw;
}
finally
{
if (downloadCt.IsCancellationRequested && !alreadyCancelled)
{
try
{
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
.ConfigureAwait(false);
}
catch
{
// ignore
}
}
_orchestrator.ClearDownloadRequest(requestId);
}
_orchestrator.ClearDownloadRequest(requestId);
}
private async Task DownloadQueuedBlockFileAsync(
@@ -502,21 +451,15 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
}
}
private void RemoveStatus(string key)
{
lock (_downloadStatusLock)
{
_downloadStatus.Remove(key);
}
}
private async Task DecompressBlockFileAsync(
string downloadStatusKey,
string blockFilePath,
Dictionary<string, (string Extension, string GamePath)> replacementLookup,
IReadOnlyDictionary<string, long> rawSizeLookup,
string downloadLabel,
CancellationToken ct,
bool skipDownscale)
bool skipDownscale,
bool skipDecimation)
{
SetStatus(downloadStatusKey, DownloadStatus.Decompressing);
MarkTransferredFiles(downloadStatusKey, 1);
@@ -532,29 +475,33 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
try
{
// sanity check length
if (fileLengthBytes < 0 || fileLengthBytes > int.MaxValue)
throw new InvalidDataException($"Invalid block entry length: {fileLengthBytes}");
// safe cast after check
var len = checked((int)fileLengthBytes);
if (!replacementLookup.TryGetValue(fileHash, out var repl))
{
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash);
fileBlockStream.Seek(len, SeekOrigin.Current);
// still need to skip bytes:
var skip = checked((int)fileLengthBytes);
fileBlockStream.Position += skip;
continue;
}
// decompress
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
// read compressed data
var compressed = new byte[len];
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
if (len == 0)
MungeBuffer(compressed);
var decompressed = LZ4Wrapper.Unwrap(compressed);
if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize)
&& expectedRawSize > 0
&& decompressed.LongLength != expectedRawSize)
{
await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
@@ -563,21 +510,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
MungeBuffer(compressed);
// limit concurrent decompressions
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
// offload CPU-intensive decompression to threadpool to free up worker
await Task.Run(async () =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
// decompress
var decompressed = LZ4Wrapper.Unwrap(compressed);
// decompress
var decompressed = LZ4Wrapper.Unwrap(compressed);
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
// write to file
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
// write to file without compacting during download
await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
}, ct).ConfigureAwait(false);
}
finally
{
@@ -594,6 +544,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
}
}
}
SetStatus(downloadStatusKey, DownloadStatus.Completed);
}
catch (EndOfStreamException)
{
@@ -603,10 +555,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{
Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel);
}
finally
{
RemoveStatus(downloadStatusKey);
}
}
public async Task<List<DownloadFileTransfer>> InitiateDownloadList(
@@ -644,21 +592,25 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
.. await FilesGetSizes(hashes, ct).ConfigureAwait(false),
];
Logger.LogDebug("Files with size 0 or less: {files}",
string.Join(", ", downloadFileInfoFromService.Where(f => f.Size <= 0).Select(f => f.Hash)));
foreach (var dto in downloadFileInfoFromService.Where(c => c.IsForbidden))
{
if (!_orchestrator.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, dto.Hash, StringComparison.Ordinal)))
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
}
CurrentDownloads = [.. downloadFileInfoFromService
CurrentDownloads = downloadFileInfoFromService
.Distinct()
.Select(d => new DownloadFileTransfer(d))
.Where(d => d.CanBeTransferred)];
.Where(d => d.CanBeTransferred)
.ToList();
return CurrentDownloads;
}
private sealed record BatchChunk(string Key, List<DownloadFileTransfer> Items);
private sealed record BatchChunk(string HostKey, string StatusKey, List<DownloadFileTransfer> Items);
private static IEnumerable<List<T>> ChunkList<T>(List<T> items, int chunkSize)
{
@@ -666,7 +618,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
yield return items.GetRange(i, Math.Min(chunkSize, items.Count - i));
}
private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct, bool skipDownscale)
private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct, bool skipDownscale, bool skipDecimation)
{
var objectName = gameObjectHandler?.Name ?? "Unknown";
@@ -684,6 +636,20 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
var allowDirectDownloads = ShouldUseDirectDownloads();
var replacementLookup = BuildReplacementLookup(fileReplacement);
var rawSizeLookup = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
foreach (var download in CurrentDownloads)
{
if (string.IsNullOrWhiteSpace(download.Hash))
{
continue;
}
if (!rawSizeLookup.TryGetValue(download.Hash, out var existing) || existing <= 0)
{
rawSizeLookup[download.Hash] = download.TotalRaw;
}
}
var directDownloads = new List<DownloadFileTransfer>();
var batchDownloads = new List<DownloadFileTransfer>();
@@ -708,39 +674,36 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
var chunkSize = (int)Math.Ceiling(list.Count / (double)chunkCount);
return ChunkList(list, chunkSize)
.Select(chunk => new BatchChunk(g.Key, chunk));
.Select((chunk, index) => new BatchChunk(g.Key, $"{g.Key}#{index + 1}", chunk));
})
.ToArray();
// init statuses
lock (_downloadStatusLock)
_downloadStatus.Clear();
// direct downloads and batch downloads tracked separately
foreach (var d in directDownloads)
{
_downloadStatus.Clear();
// direct downloads and batch downloads tracked separately
foreach (var d in directDownloads)
_downloadStatus[d.DirectDownloadUrl!] = new FileDownloadStatus
{
_downloadStatus[d.DirectDownloadUrl!] = new FileDownloadStatus
{
DownloadStatus = DownloadStatus.Initializing,
TotalBytes = d.Total,
TotalFiles = 1,
TransferredBytes = 0,
TransferredFiles = 0
};
}
DownloadStatus = DownloadStatus.WaitingForSlot,
TotalBytes = d.Total,
TotalFiles = 1,
TransferredBytes = 0,
TransferredFiles = 0
};
}
foreach (var g in batchChunks.GroupBy(c => c.Key, StringComparer.Ordinal))
foreach (var chunk in batchChunks)
{
_downloadStatus[chunk.StatusKey] = new FileDownloadStatus
{
_downloadStatus[g.Key] = new FileDownloadStatus
{
DownloadStatus = DownloadStatus.Initializing,
TotalBytes = g.SelectMany(x => x.Items).Sum(x => x.Total),
TotalFiles = 1,
TransferredBytes = 0,
TransferredFiles = 0
};
}
DownloadStatus = DownloadStatus.WaitingForQueue,
TotalBytes = chunk.Items.Sum(x => x.Total),
TotalFiles = 1,
TransferredBytes = 0,
TransferredFiles = 0
};
}
if (directDownloads.Count > 0 || batchChunks.Length > 0)
@@ -752,30 +715,47 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
if (gameObjectHandler is not null)
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
// work based on cpu count and slots
var coreCount = Environment.ProcessorCount;
var baseWorkers = Math.Min(slots, coreCount);
// only add buffer if decompression has capacity AND we have cores to spare
var availableDecompressSlots = _decompressGate.CurrentCount;
var extraWorkers = (availableDecompressSlots > 0 && coreCount >= 6) ? 2 : 0;
// allow some extra workers so downloads can continue while earlier items decompress.
var workerDop = Math.Clamp(slots * 2, 2, 16);
var workerDop = Math.Clamp(baseWorkers + extraWorkers, 2, coreCount);
// batch downloads
Task batchTask = batchChunks.Length == 0
? Task.CompletedTask
: Parallel.ForEachAsync(batchChunks, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct },
async (chunk, token) => await ProcessBatchChunkAsync(chunk, replacementLookup, token, skipDownscale).ConfigureAwait(false));
async (chunk, token) => await ProcessBatchChunkAsync(chunk, replacementLookup, rawSizeLookup, token, skipDownscale, skipDecimation).ConfigureAwait(false));
// direct downloads
Task directTask = directDownloads.Count == 0
? Task.CompletedTask
: Parallel.ForEachAsync(directDownloads, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct },
async (d, token) => await ProcessDirectAsync(d, replacementLookup, token, skipDownscale).ConfigureAwait(false));
async (d, token) => await ProcessDirectAsync(d, replacementLookup, rawSizeLookup, token, skipDownscale, skipDecimation).ConfigureAwait(false));
await Task.WhenAll(batchTask, directTask).ConfigureAwait(false);
// process deferred compressions after all downloads complete
await ProcessDeferredCompressionsAsync(ct).ConfigureAwait(false);
Logger.LogDebug("Download end: {id}", objectName);
ClearDownload();
}
private async Task ProcessBatchChunkAsync(BatchChunk chunk, Dictionary<string, (string Extension, string GamePath)> replacementLookup, CancellationToken ct, bool skipDownscale)
private async Task ProcessBatchChunkAsync(
BatchChunk chunk,
Dictionary<string, (string Extension, string GamePath)> replacementLookup,
IReadOnlyDictionary<string, long> rawSizeLookup,
CancellationToken ct,
bool skipDownscale,
bool skipDecimation)
{
var statusKey = chunk.Key;
var statusKey = chunk.StatusKey;
// enqueue (no slot)
SetStatus(statusKey, DownloadStatus.WaitingForQueue);
@@ -793,7 +773,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
try
{
// download (with slot)
var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes));
// Download slot held on get
@@ -803,10 +782,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
if (!File.Exists(blockFile))
{
Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name);
SetStatus(statusKey, DownloadStatus.Completed);
return;
}
await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, fi.Name, ct, skipDownscale).ConfigureAwait(false);
await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, fi.Name, ct, skipDownscale, skipDecimation).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -823,7 +803,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
}
}
private async Task ProcessDirectAsync(DownloadFileTransfer directDownload, Dictionary<string, (string Extension, string GamePath)> replacementLookup, CancellationToken ct, bool skipDownscale)
private async Task ProcessDirectAsync(
DownloadFileTransfer directDownload,
Dictionary<string, (string Extension, string GamePath)> replacementLookup,
IReadOnlyDictionary<string, long> rawSizeLookup,
CancellationToken ct,
bool skipDownscale,
bool skipDecimation)
{
var progress = CreateInlineProgress(bytes =>
{
@@ -833,7 +819,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
if (!ShouldUseDirectDownloads() || string.IsNullOrEmpty(directDownload.DirectDownloadUrl))
{
await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, progress, ct, skipDownscale).ConfigureAwait(false);
await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation).ConfigureAwait(false);
return;
}
@@ -861,6 +847,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
if (!replacementLookup.TryGetValue(directDownload.Hash, out var repl))
{
Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash);
SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed);
return;
}
@@ -873,13 +860,18 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename, ct).ConfigureAwait(false);
var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes);
if (directDownload.TotalRaw > 0 && decompressedBytes.LongLength != directDownload.TotalRaw)
{
throw new InvalidDataException(
$"{directDownload.Hash}: Decompressed size mismatch (expected {directDownload.TotalRaw}, got {decompressedBytes.LongLength})");
}
await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false);
PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale);
PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale, skipDecimation);
MarkTransferredFiles(directDownload.DirectDownloadUrl!, 1);
SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed);
Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash);
RemoveStatus(directDownload.DirectDownloadUrl!);
}
catch (OperationCanceledException ex)
{
@@ -902,7 +894,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
try
{
await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, progress, ct, skipDownscale).ConfigureAwait(false);
await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation).ConfigureAwait(false);
if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads)
{
@@ -929,9 +921,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private async Task ProcessDirectAsQueuedFallbackAsync(
DownloadFileTransfer directDownload,
Dictionary<string, (string Extension, string GamePath)> replacementLookup,
IReadOnlyDictionary<string, long> rawSizeLookup,
IProgress<long> progress,
CancellationToken ct,
bool skipDownscale)
bool skipDownscale,
bool skipDecimation)
{
if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl))
throw new InvalidOperationException("Direct download fallback requested without a direct download URL.");
@@ -956,7 +950,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
if (!File.Exists(blockFile))
throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile);
await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, $"fallback-{directDownload.Hash}", ct, skipDownscale)
await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, $"fallback-{directDownload.Hash}", ct, skipDownscale, skipDecimation)
.ConfigureAwait(false);
}
finally
@@ -974,18 +968,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
if (!_orchestrator.IsInitialized)
throw new InvalidOperationException("FileTransferManager is not initialized");
// batch request
var response = await _orchestrator.SendRequestAsync(
HttpMethod.Get,
LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!),
hashes,
ct).ConfigureAwait(false);
// ensure success
return await response.Content.ReadFromJsonAsync<List<DownloadFileDto>>(cancellationToken: ct).ConfigureAwait(false) ?? [];
}
private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale)
private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale, bool skipDecimation)
{
var fi = new FileInfo(filePath);
@@ -1001,13 +993,26 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
fi.LastAccessTime = DateTime.Today;
fi.LastWriteTime = RandomDayInThePast().Invoke();
// queue file for deferred compression instead of compressing immediately
if (_configService.Current.UseCompactor)
_deferredCompressionQueue.Enqueue(filePath);
try
{
var entry = _fileDbManager.CreateCacheEntry(filePath);
var mapKind = _textureMetadataHelper.DetermineMapKind(gamePath, filePath);
var entry = _fileDbManager.CreateCacheEntryWithKnownHash(filePath, fileHash);
if (!skipDownscale)
_textureDownscaleService.ScheduleDownscale(fileHash, filePath, mapKind);
if (!skipDownscale && _textureDownscaleService.ShouldScheduleDownscale(filePath))
{
_textureDownscaleService.ScheduleDownscale(
fileHash,
filePath,
() => _textureMetadataHelper.DetermineMapKind(gamePath, filePath));
}
if (!skipDecimation && _modelDecimationService.ShouldScheduleDecimation(fileHash, filePath, gamePath))
{
_modelDecimationService.ScheduleDecimation(fileHash, filePath, gamePath);
}
if (entry != null && !string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase))
{
@@ -1026,6 +1031,52 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private static IProgress<long> CreateInlineProgress(Action<long> callback) => new InlineProgress(callback);
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
{
if (_deferredCompressionQueue.IsEmpty)
return;
var filesToCompress = new List<string>();
while (_deferredCompressionQueue.TryDequeue(out var filePath))
{
if (File.Exists(filePath))
filesToCompress.Add(filePath);
}
if (filesToCompress.Count == 0)
return;
Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count);
var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4);
await Parallel.ForEachAsync(filesToCompress,
new ParallelOptions
{
MaxDegreeOfParallelism = compressionWorkers,
CancellationToken = ct
},
async (filePath, token) =>
{
try
{
await Task.Yield();
if (_configService.Current.UseCompactor && File.Exists(filePath))
{
var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false);
await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
Logger.LogTrace("Compressed file: {filePath}", filePath);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath);
}
}).ConfigureAwait(false);
Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count);
}
private sealed class InlineProgress : IProgress<long>
{
private readonly Action<long> _callback;

View File

@@ -6,5 +6,6 @@ public enum DownloadStatus
WaitingForSlot,
WaitingForQueue,
Downloading,
Decompressing
Decompressing,
Completed
}

View File

@@ -1,10 +1,46 @@
namespace LightlessSync.WebAPI.Files.Models;
using System.Threading;
namespace LightlessSync.WebAPI.Files.Models;
public class FileDownloadStatus
{
public DownloadStatus DownloadStatus { get; set; }
public long TotalBytes { get; set; }
public int TotalFiles { get; set; }
public long TransferredBytes { get; set; }
public int TransferredFiles { get; set; }
}
private int _downloadStatus;
private long _totalBytes;
private int _totalFiles;
private long _transferredBytes;
private int _transferredFiles;
public DownloadStatus DownloadStatus
{
get => (DownloadStatus)Volatile.Read(ref _downloadStatus);
set => Volatile.Write(ref _downloadStatus, (int)value);
}
public long TotalBytes
{
get => Interlocked.Read(ref _totalBytes);
set => Interlocked.Exchange(ref _totalBytes, value);
}
public int TotalFiles
{
get => Volatile.Read(ref _totalFiles);
set => Volatile.Write(ref _totalFiles, value);
}
public long TransferredBytes
{
get => Interlocked.Read(ref _transferredBytes);
set => Interlocked.Exchange(ref _transferredBytes, value);
}
public int TransferredFiles
{
get => Volatile.Read(ref _transferredFiles);
set => Volatile.Write(ref _transferredFiles, value);
}
public void AddTransferredBytes(long delta) => Interlocked.Add(ref _transferredBytes, delta);
public void SetTransferredFiles(int files) => Volatile.Write(ref _transferredFiles, files);
}