Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions editor/Assets/AssetBrowserView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,31 @@ public AssetBrowserView() {

AssetBrowserViewModel? subscribed = null;
DataContextChanged += (_, _) => {
if (subscribed is not null) subscribed.PropertyChanged -= OnViewModelPropertyChanged;
if (subscribed is not null) {
subscribed.PropertyChanged -= OnViewModelPropertyChanged;
subscribed.ItemRevealed -= OnItemRevealed;
}

subscribed = DataContext as AssetBrowserViewModel;
if (subscribed is not null) subscribed.PropertyChanged += OnViewModelPropertyChanged;
if (subscribed is not null) {
subscribed.PropertyChanged += OnViewModelPropertyChanged;
subscribed.ItemRevealed += OnItemRevealed;
}

ApplyCardSize();
};
}

private AssetBrowserViewModel Vm => (AssetBrowserViewModel)DataContext!;

private void OnItemRevealed(object item) {
Avalonia.Threading.Dispatcher.UIThread.Post(() => {
var index = Vm.CurrentItems.IndexOf(item);
if (index < 0) return;
AssetRepeater.GetOrCreateElement(index).BringIntoView();
}, Avalonia.Threading.DispatcherPriority.Loaded);
}

private void OnSelectTemplateKey(object? sender, SelectTemplateEventArgs e) {
e.TemplateKey = e.DataContext is AssetFolder ? "folder" : "file";
}
Expand Down
6 changes: 6 additions & 0 deletions editor/Assets/AssetBrowserViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -338,12 +338,18 @@ private void OnThumbnailUpdated(string uid) {

public new event PropertyChangedEventHandler? PropertyChanged;

public event Action<object>? ItemRevealed;

public void RevealAsset(string uid) {
foreach (var root in Folders)
foreach (var file in GetAllFiles(root)) {
if (file.Uid != uid) continue;
SearchText = "";
SelectedFolder = FindParentFolder(root, file);
var card = CurrentItems.OfType<AssetFile>().FirstOrDefault(f => f.Uid == uid);
if (card is null) return;
SelectItem(card, KeyModifiers.None);
ItemRevealed?.Invoke(card);
return;
}
}
Expand Down
50 changes: 36 additions & 14 deletions editor/Assets/AssetDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,17 @@ public static bool IsUpToDate(string sourceVirtualPath, string hash) {
}

public static void UpdateArtworkDatabase(
string sourceVirtualPath, string hash, IEnumerable<string> outputUids) {
string sourceVirtualPath, string hash, IEnumerable<ArtworkOutput> artworkOutputs) {
var db = LoadArtworkDatabase();

var outputs = new JsonArray();
foreach (var uid in outputUids) outputs.Add(uid);
foreach (var output in artworkOutputs)
outputs.Add(new JsonObject {
["uid"] = output.Uid,
["name"] = output.Name,
["hash"] = output.Hash,
["uri"] = output.Uri
});

db[sourceVirtualPath] = new JsonObject {
["last_hash"] = hash,
Expand All @@ -62,6 +68,27 @@ public static void UpdateArtworkDatabase(
SaveArtworkDatabase(db);
}

public static IReadOnlyList<ArtworkOutput> GetArtworkOutputs(string sourceVirtualPath) {
if (LoadArtworkDatabase()[sourceVirtualPath] is not JsonObject entry) return [];
return ReadOutputs(entry);
}

private static List<ArtworkOutput> ReadOutputs(JsonObject entry) {
var result = new List<ArtworkOutput>();
if (entry["outputs"] is not JsonArray outputs) return result;

foreach (var node in outputs)
if (node is JsonObject obj && obj["uid"]?.GetValue<string>() is { } uid)
result.Add(new ArtworkOutput(uid, obj["name"]?.GetValue<string>() ?? "", obj["hash"]?.GetValue<string>(),
obj["uri"]?.GetValue<string>() ?? ""));
else if (node?.GetValue<string>() is { } legacyUid) {
var uri = TryResolve(legacyUid, out var path, out _) ? path : "";
result.Add(new ArtworkOutput(legacyUid, Path.GetFileName(uri), null, uri));
}

return result;
}

// full re-scan of all .meta files; writes one <db>.json per content database + core.json
public static void RebuildAssetDatabase(bool notifyListeners = true) {
var now = DateTime.UtcNow.ToString("o");
Expand Down Expand Up @@ -357,9 +384,8 @@ public static async Task Reimport(
return;
}

var uids = (entry["outputs"] as JsonArray)?
.Select(n => n!.GetValue<string>())
.ToList() ?? [];
var previous = ReadOutputs(entry);
var uids = previous.Select(o => o.Uid).ToList();

// find where the existing outputs live + the settings they were imported with
var (destDir, textureSection, psdSection, gltfSection, voxSection) = LocateOutputs(uids);
Expand All @@ -379,15 +405,10 @@ public static async Task Reimport(
_ => new TextureImporter(textureSettings)
};

var ctx = new ImportContext {
DestDir = destDir ?? ProjectContext.AssetsPath,
SourceVirtualPath = sourceVirtualPath,
ReuseUids = uids
};

log($"Reimporting {sourceVirtualPath}...");
var newUids = await importer.Import(realSource, ctx, log, progress);
UpdateArtworkDatabase(sourceVirtualPath, ComputeHash(realSource), newUids);
var outputs = await ArtworkImport.RunAsync(importer, realSource, sourceVirtualPath,
destDir ?? ProjectContext.AssetsPath, previous, log, progress);
UpdateArtworkDatabase(sourceVirtualPath, ComputeHash(realSource), outputs);
log("Reimport done.");
}

Expand Down Expand Up @@ -615,7 +636,8 @@ public static void RemoveArtworkOutputs(IEnumerable<string> uids) {
if (db[key] is not JsonObject entry) continue;
if (entry["outputs"] is not JsonArray outputs) continue;
for (var i = outputs.Count - 1; i >= 0; i--) {
if (outputs[i]?.GetValue<string>() is not { } output || !targets.Contains(output)) continue;
var output = outputs[i] is JsonObject record ? record["uid"]?.GetValue<string>() : outputs[i]?.GetValue<string>();
if (output is null || !targets.Contains(output)) continue;
outputs.RemoveAt(i);
changed = true;
}
Expand Down
143 changes: 143 additions & 0 deletions editor/Assets/Importers/ArtworkImport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//
// ArtworkImport.cs by Xein
//

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

namespace editor.Assets.Importers;

public sealed record ArtworkOutput(string Uid, string Name, string? Hash, string Uri);

public static class ArtworkImport {
public static async Task<IReadOnlyList<ArtworkOutput>> RunAsync(
IAssetImporter importer, string realSource, string sourceVirtual, string destDir,
IReadOnlyList<ArtworkOutput> previous, Action<string> log, Action<double>? progress = null) {
var writes = new List<(string Uid, string Path)>();
using var scope = MetaFile.Capture(writes);

if (previous.Count == 0) {
var ctx = new ImportContext { DestDir = destDir, SourceVirtualPath = sourceVirtual };
await importer.Import(realSource, ctx, log, progress);
return Describe(Produced(writes, null));
}

var stageRoot = Path.Combine(ProjectContext.CachePath, "import_staging", Guid.NewGuid().ToString("N"));
// Named like the real folder
var stageDest = Path.Combine(stageRoot, new DirectoryInfo(destDir).Name);
Directory.CreateDirectory(stageDest);
try {
var ctx = new ImportContext { DestDir = stageDest, SourceVirtualPath = sourceVirtual };
await importer.Import(realSource, ctx, log, progress);
return Reconcile(Produced(writes, stageRoot), previous, stageDest, destDir, log);
} finally {
try {
Directory.Delete(stageRoot, true);
} catch {
/* the cache is disposable */
}
}
}

private static List<(string Uid, string Path)> Produced(List<(string Uid, string Path)> writes, string? root) {
var byUid = new Dictionary<string, string>();
foreach (var (uid, path) in writes) byUid[uid] = path;
return byUid
.Where(kv => File.Exists(kv.Value) && (root is null || kv.Value.StartsWith(root, StringComparison.OrdinalIgnoreCase)))
.Select(kv => (kv.Key, kv.Value))
.ToList();
}

private static IReadOnlyList<ArtworkOutput> Describe(IEnumerable<(string Uid, string Path)> produced) {
return produced.Select(p => Describe(p.Uid, p.Path)).ToList();
}

private static ArtworkOutput Describe(string uid, string realPath) {
return new ArtworkOutput(uid, Path.GetFileName(realPath), AssetDatabase.ComputeHash(realPath),
ProjectContext.ToVirtual(realPath) ?? realPath);
}

private static IReadOnlyList<ArtworkOutput> Reconcile(
List<(string Uid, string Path)> produced, IReadOnlyList<ArtworkOutput> previous,
string stageDest, string destDir, Action<string> log) {
var fresh = produced.Select(p => (p.Uid, p.Path, Name: Path.GetFileName(p.Path),
Hash: AssetDatabase.ComputeHash(p.Path))).ToList();

var free = previous.ToList();
var matches = new Dictionary<string, ArtworkOutput>();

void Match(Func<ArtworkOutput, (string Uid, string Path, string Name, string Hash), bool> same) {
foreach (var f in fresh) {
if (matches.ContainsKey(f.Uid)) continue;
var old = free.FirstOrDefault(o => same(o, f));
if (old is null) continue;
matches[f.Uid] = old;
free.Remove(old);
}
}

Match((o, f) => o.Hash == f.Hash);
Match((o, f) => string.Equals(o.Name, f.Name, StringComparison.OrdinalIgnoreCase));

foreach (var f in fresh) {
if (matches.ContainsKey(f.Uid)) continue;
var old = free.FirstOrDefault(o => o.Hash is null);
if (old is null) break;
matches[f.Uid] = old;
free.Remove(old);
}

var uidMap = matches.ToDictionary(m => m.Key, m => m.Value.Uid);
if (uidMap.Count > 0)
foreach (var file in Directory.EnumerateFiles(stageDest, "*", SearchOption.AllDirectories))
ReplaceUids(file, uidMap);

var outputs = new List<ArtworkOutput>();
var moved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var f in fresh) {
string target;
if (matches.TryGetValue(f.Uid, out var old)) {
var uri = AssetDatabase.TryResolve(old.Uid, out var current, out _) ? current : old.Uri;
target = ProjectContext.Resolve(uri);
} else {
target = Path.Combine(destDir, Path.GetRelativePath(stageDest, f.Path));
}

var tags = MetaFile.ReadHeader(target)?.Tags;
Place(f.Path, target);
Place(f.Path + ".meta", target + ".meta");
if (tags is { Count: > 0 }) MetaFile.SetTags(target, tags);

moved.Add(f.Path);
moved.Add(f.Path + ".meta");
var uid = matches.TryGetValue(f.Uid, out var kept) ? kept.Uid : f.Uid;
outputs.Add(Describe(uid, target));
log($"{(kept is null ? "New" : "Replaced")} {ProjectContext.ToVirtual(target) ?? target}");
}

foreach (var file in Directory.EnumerateFiles(stageDest, "*", SearchOption.AllDirectories))
if (!moved.Contains(file))
Place(file, Path.Combine(destDir, Path.GetRelativePath(stageDest, file)));

return outputs;
}

private static void Place(string from, string to) {
if (!File.Exists(from)) return;
Directory.CreateDirectory(Path.GetDirectoryName(to)!);
File.Move(from, to, true);
}

private static void ReplaceUids(string file, IReadOnlyDictionary<string, string> uidMap) {
var bytes = File.ReadAllBytes(file);
if (Array.IndexOf(bytes, (byte)0, 0, Math.Min(bytes.Length, 8192)) >= 0) return;

var text = System.Text.Encoding.UTF8.GetString(bytes);
var changed = text;
foreach (var (from, to) in uidMap) changed = changed.Replace(from, to, StringComparison.Ordinal);
if (changed != text) File.WriteAllText(file, changed);
}
}
6 changes: 3 additions & 3 deletions editor/Assets/Importers/CompactImportWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,9 @@ private async Task ImportFile(string filePath, string? artworkDir, Action<string
return;
}

var ctx = new ImportContext { DestDir = destDir, SourceVirtualPath = sourceVirtual };
var uids = await importer.Import(realSourcePath, ctx, log, progress);
AssetDatabase.UpdateArtworkDatabase(sourceVirtual, hash, uids);
var outputs = await ArtworkImport.RunAsync(importer, realSourcePath, sourceVirtual, destDir,
AssetDatabase.GetArtworkOutputs(sourceVirtual), log, progress);
AssetDatabase.UpdateArtworkDatabase(sourceVirtual, hash, outputs);
} else {
log("Note: source outside project — reimport unavailable.");
var fakeVirtual = "artwork://" + Path.GetFileName(realSourcePath);
Expand Down
4 changes: 4 additions & 0 deletions editor/Assets/Importers/ImportWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,10 @@
StrokeWidth="2.5" />
</Button>
</Grid>
<CheckBox IsChecked="{Binding KeepFolderStructure}"
IsVisible="{Binding !IsExternal}"
Content="Keep folder structure"
ToolTip.Tip="Recreates the artwork folders below the first folder the selected files share, with the destination standing in for that folder" />
</StackPanel>

<!-- Bottom buttons -->
Expand Down
43 changes: 33 additions & 10 deletions editor/Assets/Importers/ImportWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ public partial class ImportWindowViewModel : ViewModelBase {
[ObservableProperty] private string m_externalSourcePath = string.Empty;

[ObservableProperty] private bool m_isExternal;

[ObservableProperty] private bool m_keepFolderStructure;
[ObservableProperty] private string m_locationPath = "assets://";
[ObservableProperty] private bool m_moveToArtwork = true;
[ObservableProperty] private string m_searchText = string.Empty;
Expand Down Expand Up @@ -486,10 +486,16 @@ private async Task ImportFromTree() {

var tasks = new List<LoaderTask>();

foreach (var file in freshFiles)
var baseDir = ProjectContext.Resolve(LocationPath);
var commonDir = KeepFolderStructure ? CommonDirectory(freshFiles) : null;
foreach (var file in freshFiles) {
var fileDir = commonDir is null
? baseDir
: Path.GetFullPath(Path.Combine(baseDir, Path.GetRelativePath(commonDir, Path.GetDirectoryName(file)!)));
tasks.Add(LoaderTask.DoWithProgress(
Path.GetFileName(file),
(log, progress) => ImportSingleFile(file, log, progress)));
(log, progress) => ImportSingleFile(file, fileDir, log, progress)));
}

foreach (var file in reimportFiles) {
var sourceVirtual = ProjectContext.ToVirtual(file);
Expand Down Expand Up @@ -523,7 +529,7 @@ private async Task ImportExternal() {

var task = LoaderTask.DoWithProgress(Path.GetFileName(realSourcePath), async (log, progress) => {
if (MoveToArtwork) {
await ImportSingleFile(realSourcePath, log, progress);
await ImportSingleFile(realSourcePath, ProjectContext.Resolve(LocationPath), log, progress);
} else {
// Import in-place without artwork-DB tracking
log("Note: source is outside the project — reimport won't be available.");
Expand Down Expand Up @@ -552,7 +558,8 @@ private void Cancel() {
}


private async Task ImportSingleFile(string realSourcePath, Action<string> log, Action<double>? progress = null) {
private async Task ImportSingleFile(
string realSourcePath, string destDir, Action<string> log, Action<double>? progress = null) {
try {
var sourceVirtual = ProjectContext.ToVirtual(realSourcePath)
?? throw new InvalidOperationException(
Expand All @@ -564,12 +571,10 @@ private async Task ImportSingleFile(string realSourcePath, Action<string> log, A
return;
}

var destDir = ProjectContext.Resolve(LocationPath);
var ext = Path.GetExtension(realSourcePath).ToLowerInvariant();
var importer = m_importers.First(i => i.CanHandle(realSourcePath));
var ctx = new ImportContext { DestDir = destDir, SourceVirtualPath = sourceVirtual };
var uids = await importer.Import(realSourcePath, ctx, log, progress);
AssetDatabase.UpdateArtworkDatabase(sourceVirtual, hash, uids);
var outputs = await ArtworkImport.RunAsync(importer, realSourcePath, sourceVirtual, destDir,
AssetDatabase.GetArtworkOutputs(sourceVirtual), log, progress);
AssetDatabase.UpdateArtworkDatabase(sourceVirtual, hash, outputs);
log("Done.");
} catch (Exception ex) {
log($"ERROR: {ex.Message}");
Expand Down Expand Up @@ -612,6 +617,24 @@ private static HashSet<string> LoadImportedFiles(string dbPath) {
return result;
}

private static string? CommonDirectory(IReadOnlyList<string> files) {
if (files.Count == 0) return null;
var common = Path.GetDirectoryName(files[0])!;
foreach (var file in files.Skip(1))
while (!IsInside(common, Path.GetDirectoryName(file)!)) {
var parent = Path.GetDirectoryName(common);
if (parent is null) return null;
common = parent;
}

return common;
}

private static bool IsInside(string folder, string path) {
var relative = Path.GetRelativePath(folder, path);
return relative == "." || (!relative.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relative));
}

private static (List<string> fresh, List<string> reimport) GetSelectedFiles(IEnumerable<ImportNodeViewModel> nodes) {
var fresh = new List<string>();
var reimport = new List<string>();
Expand Down
Loading
Loading