From fad0b1d2c7ef0642b785d27a865e4c2e116293cd Mon Sep 17 00:00:00 2001 From: JeanPhilippeKernel Date: Mon, 24 Aug 2026 12:22:47 +0900 Subject: [PATCH 1/4] fix(assets): preserve companion file relative paths on download The Poly Haven API includes companion file relative paths as inc.Key (e.g. 'textures/foo_diff_4k.jpg', 'foo.bin'). The previous code only stored the URL and derived the filename via Path.GetFileName, dropping the subdirectory prefix entirely. All textures were placed flat next to the primary model, breaking GLTF/FBX/Blend/USD texture references. - AssetFormatOption: replace CompanionUrls List with CompanionFiles List<(string RelativePath, string Url)> - LoadAssetFormatsAsync: store (inc.Key, url) instead of url only - LoadAndDownloadAssetAsync: use RelativePath verbatim relative to destDir, creating subdirectories (e.g. textures/) as needed --- ViewModels/AssetDetailViewModel.cs | 3 ++- ViewModels/MainWindowViewModel.cs | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ViewModels/AssetDetailViewModel.cs b/ViewModels/AssetDetailViewModel.cs index c28f813..83e51a8 100644 --- a/ViewModels/AssetDetailViewModel.cs +++ b/ViewModels/AssetDetailViewModel.cs @@ -13,7 +13,8 @@ internal class AssetFormatOption : ReactiveObject public string Resolution { get; } public string Url { get; } public string Label => $"{Format.ToUpperInvariant()} · {Resolution}"; - public List CompanionUrls { get; } = new(); + // RelativePath is the inc.Key from the Poly Haven API (e.g. "textures/foo_diff_4k.jpg", "foo.bin") + public List<(string RelativePath, string Url)> CompanionFiles { get; } = new(); private bool _isSelected; public bool IsSelected diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index 0bae26a..e7636b5 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -877,7 +877,7 @@ await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { var incUrl = incFile["url"]?.GetValue() ?? string.Empty; if (!string.IsNullOrEmpty(incUrl)) - option.CompanionUrls.Add(incUrl); + option.CompanionFiles.Add((inc.Key, incUrl)); } } } @@ -943,14 +943,17 @@ async Task UI(System.Action a) => await DownloadFileWithProgressAsync(fmt.Url, destPath, pct => UI(() => { op.Progress = pct; })); - // Download companion files (e.g. .bin buffer for GLTF) + // Download companion files, preserving the relative path from the API + // (e.g. "textures/foo_diff_4k.jpg" → destDir/textures/foo_diff_4k.jpg) var destDir2 = System.IO.Path.GetDirectoryName(destPath)!; - foreach (var companionUrl in fmt.CompanionUrls) + foreach (var (relativePath, companionUrl) in fmt.CompanionFiles) { try { - var companionFileName = System.IO.Path.GetFileName(new Uri(companionUrl).LocalPath); - var companionDest = System.IO.Path.Combine(destDir2, companionFileName); + var companionDest = System.IO.Path.Combine( + destDir2, + relativePath.Replace('/', System.IO.Path.DirectorySeparatorChar)); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(companionDest)!); await DownloadFileWithProgressAsync(companionUrl, companionDest, _ => Task.CompletedTask); } catch { /* non-fatal */ } From 68b5d398f6ed4313697f8bca41bc638b58794614 Mon Sep 17 00:00:00 2001 From: JeanPhilippeKernel Date: Mon, 24 Aug 2026 12:28:41 +0900 Subject: [PATCH 2/4] fix(assets): migrate existing flat texture files into textures/ on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing downloaded assets have textures sitting flat next to the primary model file instead of in a textures/ subdirectory, breaking GLTF/FBX/Blend/USD texture references. MigrateAssetFoldersAsync runs once at startup in the background: it collects all flat image files (jpg/jpeg/png/webp/hdr/exr) under AppPaths.Assets, moves them into the textures/ subdirectory alongside their primary model, and notifies the user with toasts before and after. Dirs that are already correctly structured are skipped. Non-fatal — any individual move failure leaves the file in place. --- ViewModels/MainWindowViewModel.cs | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index e7636b5..aba0a68 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -441,6 +441,7 @@ public MainWindowViewModel(IProjectService projectService, IEngineService engine _ = RunBackgroundTask("Scan local assets", () => Task.Run(() => Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(ScanLocalAssets))); _ = RunBackgroundTask("Load projects", LoadProjectsAsync); + _ = Task.Run(MigrateAssetFoldersAsync); }); var canConfirm = this.WhenAnyValue( @@ -1000,6 +1001,49 @@ private void ScanLocalAssets() RefreshDownloadedStates(); } + private async Task MigrateAssetFoldersAsync() + { + var root = Models.AppPaths.Assets; + if (!Directory.Exists(root)) return; + + string[] imageExts = ["jpg", "jpeg", "png", "webp", "hdr", "exr"]; + + // Collect all flat image files that need moving + var toMove = new List<(string Src, string Dest)>(); + foreach (var assetDir in Directory.EnumerateDirectories(root)) + foreach (var resDir in Directory.EnumerateDirectories(assetDir)) + { + foreach (var file in Directory.EnumerateFiles(resDir)) + { + var ext = Path.GetExtension(file).TrimStart('.').ToLowerInvariant(); + if (!imageExts.Contains(ext)) continue; + var dest = Path.Combine(resDir, "textures", Path.GetFileName(file)); + if (!File.Exists(dest)) + toMove.Add((file, dest)); + } + } + + if (toMove.Count == 0) return; + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + _ = ShowToastAsync($"Migrating {toMove.Count} texture file(s) into textures/ folders…")); + + var moved = 0; + foreach (var (src, dest) in toMove) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.Move(src, dest, overwrite: true); + moved++; + } + catch { /* non-fatal — leave file in place */ } + } + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + _ = ShowToastAsync($"Asset folders updated — {moved} texture(s) moved to textures/")); + } + private void RefreshDownloadedStates() { var ids = LocalAssets.Select(a => a.AssetId) From 3720478417f88f6a91bb1c950ae6a96f881af56e Mon Sep 17 00:00:00 2001 From: JeanPhilippeKernel Date: Mon, 24 Aug 2026 12:48:09 +0900 Subject: [PATCH 3/4] feat(ui): rename sidebar title from Panzerfaust to Zodiac Hub --- Views/MainWindow.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml index ebd9fab..083fa29 100644 --- a/Views/MainWindow.axaml +++ b/Views/MainWindow.axaml @@ -78,7 +78,7 @@ mc:Ignorable="d" d:DesignWidth="1500" d:DesignHeight="800" From 7a442b3156652e9f6c2a6513a040d89dabf4c3b5 Mon Sep 17 00:00:00 2001 From: JeanPhilippeKernel Date: Mon, 24 Aug 2026 13:21:59 +0900 Subject: [PATCH 4/4] fix(engine): guard ScanInstalledEngines against UnauthorizedAccessException Directory.EnumerateFiles with SearchOption.AllDirectories throws when it walks into protected system paths (e.g. Photos Library.photoslibrary) if the user sets the engine install location to a broad directory like their home folder. Catch IOException and UnauthorizedAccessException at both the top-level directory scan and per-version file search so the scan completes gracefully instead of crashing the app. --- Service/EngineService.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Service/EngineService.cs b/Service/EngineService.cs index 44d9788..44e19a0 100644 --- a/Service/EngineService.cs +++ b/Service/EngineService.cs @@ -37,11 +37,22 @@ public IEnumerable ScanInstalledEngines(string installLocation) var binaryName = $"{_launcherCLIAppName}{EngineExtension}"; - foreach (var versionDir in Directory.EnumerateDirectories(installLocation)) + IEnumerable versionDirs; + try { versionDirs = Directory.EnumerateDirectories(installLocation).ToList(); } + catch (UnauthorizedAccessException) { yield break; } + catch (IOException) { yield break; } + + foreach (var versionDir in versionDirs) { - var binaryPath = Directory - .EnumerateFiles(versionDir, binaryName, SearchOption.AllDirectories) - .FirstOrDefault(); + string? binaryPath = null; + try + { + binaryPath = Directory + .EnumerateFiles(versionDir, binaryName, SearchOption.AllDirectories) + .FirstOrDefault(); + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } if (binaryPath != null) yield return new InstalledEngine