diff --git a/CHANGELOG.md b/CHANGELOG.md
index fad6802c2..285f1fd1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
Built with Unity 2021.2.
### Added
+- Asset Browser ([#412](https://github.com/freezy/VisualPinball.Engine/pull/412))
- Trigger meshes can now be easily scaled ([#374](https://github.com/freezy/VisualPinball.Engine/pull/374))
- We got a new game item called *Metal Wire Guide* (thanks @Cupiii, [#366](https://github.com/freezy/VisualPinball.Engine/pull/366))
- A *Collision Switch* component ([#344](https://github.com/freezy/VisualPinball.Engine/pull/344), [Documentation](https://docs.visualpinball.org/creators-guide/manual/mechanisms/collision-switches.html)).
@@ -36,6 +37,7 @@ Built with Unity 2021.2.
- Native trough component ([#229](https://github.com/freezy/VisualPinball.Engine/pull/229), [#248](https://github.com/freezy/VisualPinball.Engine/pull/248), [#256](https://github.com/freezy/VisualPinball.Engine/pull/256), [Documentation](https://docs.visualpinball.org/creators-guide/manual/mechanisms/troughs.html)).
### Changed
+- Removed internal ID in gamelogic engine API ([#408](https://github.com/freezy/VisualPinball.Engine/pull/408))
- When importing, meshes are now saved as easily editable `.fbx` files instead of Unity's internal format ([#387](https://github.com/freezy/VisualPinball.Engine/pull/387)).
- Revised rubber mesh generation ([#384](https://github.com/freezy/VisualPinball.Engine/pull/384)).
- APIs for RGB lamps and Visual Scripting ([#382](https://github.com/freezy/VisualPinball.Engine/pull/382)).
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/asset_library.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/asset_library.png
new file mode 100644
index 000000000..ae4e037b6
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/asset_library.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/asset_library.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/asset_library.png.meta
new file mode 100644
index 000000000..2257dbe31
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/asset_library.png.meta
@@ -0,0 +1,122 @@
+fileFormatVersion: 2
+guid: 8c50266a2f53dc348adc049e274bc88e
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 11
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 1
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMasterTextureLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 2
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ platformSettings:
+ - serializedVersion: 3
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 512
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 0
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 3
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 3
+ buildTarget: Server
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ nameFileIdTable: {}
+ spritePackingTag:
+ pSDRemoveMatte: 0
+ pSDShowRemoveMatteOption: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/calendar.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/calendar.png
new file mode 100644
index 000000000..afba2ab67
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/calendar.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/calendar.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/calendar.png.meta
new file mode 100644
index 000000000..afa9536d7
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/calendar.png.meta
@@ -0,0 +1,122 @@
+fileFormatVersion: 2
+guid: 3091f9ca1ca324e41bcd564e610e4c68
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 11
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMasterTextureLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 2
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ platformSettings:
+ - serializedVersion: 3
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 512
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 3
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 3
+ buildTarget: Server
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ nameFileIdTable: {}
+ spritePackingTag:
+ pSDRemoveMatte: 0
+ pSDShowRemoveMatteOption: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/asset_library.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/asset_library.png
new file mode 100644
index 000000000..6e650529b
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/asset_library.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/asset_library.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/asset_library.png.meta
new file mode 100644
index 000000000..35c11e137
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/asset_library.png.meta
@@ -0,0 +1,122 @@
+fileFormatVersion: 2
+guid: 27a2eddcb7f17fb46b2757a26cdd28d4
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 11
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMasterTextureLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 2
+ aniso: 1
+ mipBias: 0
+ wrapU: 1
+ wrapV: 1
+ wrapW: 0
+ nPOTScale: 0
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 2
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ platformSettings:
+ - serializedVersion: 3
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 64
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 0
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 3
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 3
+ buildTarget: Server
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ nameFileIdTable: {}
+ spritePackingTag:
+ pSDRemoveMatte: 0
+ pSDShowRemoveMatteOption: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/calendar.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/calendar.png
new file mode 100644
index 000000000..aeaa20649
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/calendar.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/calendar.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/calendar.png.meta
new file mode 100644
index 000000000..0376b80b1
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/calendar.png.meta
@@ -0,0 +1,122 @@
+fileFormatVersion: 2
+guid: cf7326f171ba0fc4c900f98980dc421a
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 11
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMasterTextureLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 2
+ aniso: 1
+ mipBias: 0
+ wrapU: 1
+ wrapV: 1
+ wrapW: 0
+ nPOTScale: 0
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 2
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ platformSettings:
+ - serializedVersion: 3
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 64
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 0
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 3
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 3
+ buildTarget: Server
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ nameFileIdTable: {}
+ spritePackingTag:
+ pSDRemoveMatte: 0
+ pSDShowRemoveMatteOption: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser.meta
new file mode 100644
index 000000000..3d46f7101
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 72b6e71ef81848578f7a444569738193
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.cs
new file mode 100644
index 000000000..5c016e9e2
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.cs
@@ -0,0 +1,494 @@
+// Visual Pinball Engine
+// Copyright (C) 2022 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ public partial class AssetBrowserX : EditorWindow, IDragHandler
+ {
+ [SerializeField]
+ private int _thumbnailSize = 150;
+
+ [SerializeField]
+ public string ActiveLibraryForCategories;
+
+ [NonSerialized]
+ public List Libraries;
+
+ [SerializeField]
+ private List _selectedLibraries;
+
+ [NonSerialized]
+ private List _assets;
+
+ [NonSerialized]
+ private readonly HashSet _previewLoadingAssets = new();
+
+ [NonSerialized]
+ public AssetQuery Query;
+
+ private AssetResult LastSelectedAsset {
+ set => _detailsElement.Asset = value;
+ }
+
+ private AssetResult _firstSelectedAsset;
+ private readonly HashSet _selectedAssets = new();
+
+ private readonly Dictionary _elementByAsset = new();
+ private readonly Dictionary _assetsByElement = new();
+
+ [MenuItem("Visual Pinball/Asset Browser")]
+ public static void ShowWindow()
+ {
+ var wnd = GetWindow();
+ wnd.titleContent = new GUIContent("Asset Browser", Icons.AssetLibrary(IconSize.Small));
+
+ // Limit size of the window
+ wnd.minSize = new Vector2(640, 240);
+ }
+
+ #region Data
+
+ private void Refresh()
+ {
+ RefreshLibraries();
+ RefreshCategories();
+ RefreshAssets();
+ }
+
+ private void RefreshLibraries()
+ {
+ var selectedLibraries = _selectedLibraries == null ? null : new HashSet(_selectedLibraries);
+
+ if (Libraries != null) {
+ foreach (var lib in Libraries) {
+ lib.OnChange -= OnLibraryChanged;
+ }
+ }
+
+ // find library assets
+ Libraries = AssetDatabase.FindAssets($"t:{typeof(AssetLibrary)}")
+ .Select(AssetDatabase.GUIDToAssetPath)
+ .Select(AssetDatabase.LoadAssetAtPath)
+ .Where(asset => asset != null).ToList();
+
+ // toggle label
+ if (Libraries.Count == 0) {
+ _noLibrariesLabel.RemoveFromClassList("hidden");
+ } else {
+ _noLibrariesLabel.AddToClassList("hidden");
+ }
+
+ // setup query
+ Query = new AssetQuery(Libraries.Where(lib => lib.IsActive).ToList());
+ Query.OnQueryUpdated += OnQueryUpdated;
+
+ // update left column and subscribe
+ _libraryList.Clear();
+ foreach (var lib in Libraries) {
+ lib.IsActive = selectedLibraries?.Contains(lib.Id) ?? true;
+ _libraryList.Add(NewAssetLibrary(lib));
+ lib.OnChange += OnLibraryChanged;
+ }
+ }
+
+ private void OnLibraryChanged(object sender, EventArgs e)
+ {
+ RefreshLibraries();
+ RefreshCategories();
+ _detailsElement.UpdateDetails();
+ }
+
+ public void FilterByAttribute(string attributeKey, string value, bool remove = false)
+ {
+ var queryString = attributeKey.Contains(" ") ? $"\"{attributeKey}\":" : $"{attributeKey}:";
+ queryString += value.Contains(" ") ? $"\"{value}\"" : value;
+
+ if (remove) {
+ _queryInput.value = _queryInput.value.Replace(queryString, "").Trim();
+
+ } else {
+ _queryInput.value = $"{_queryInput.value} {queryString}".Trim();
+ }
+ }
+
+ public void FilterByTag(string tag, bool remove = false)
+ {
+ if (remove) {
+ _queryInput.value = _queryInput.value.Replace($"[{tag}]", "").Trim();
+
+ } else {
+ _queryInput.value = $"{_queryInput.value} [{tag}]".Trim();
+ }
+ }
+
+
+ private void RefreshCategories()
+ {
+ _categoryView.Refresh(this);
+ }
+
+ private void RefreshAssets()
+ {
+ Query.Search(_queryInput.value);
+ }
+
+ private void OnQueryUpdated(object sender, AssetQueryResult e)
+ {
+ UpdateQueryResults(e.Rows);
+ }
+
+ private void UpdateQueryResults(List assets)
+ {
+ _statusLabel.text = $"Found {assets.Count} asset" + (assets.Count == 1 ? "" : "s") + ".";
+ _assets = assets;
+ _gridContent.Clear();
+ _elementByAsset.Clear();
+ _assetsByElement.Clear();
+ _selectedAssets.Clear();
+ _previewLoadingAssets.Clear();
+
+ LastSelectedAsset = null;
+ foreach (var row in assets) {
+ var element = NewItem(row);
+ _elementByAsset[row.Asset] = element;
+ _assetsByElement[element] = row;
+ _gridContent.Add(_elementByAsset[row.Asset]);
+ if (row.IsLoadingAssetPreview) {
+ _previewLoadingAssets.Add(row);
+ }
+ }
+
+ if (!assets.Contains(_firstSelectedAsset)) {
+ _firstSelectedAsset = null;
+ } else {
+ SelectOnly(_firstSelectedAsset);
+ }
+ }
+
+ private void AddAssetContextMenu(ContextualMenuPopulateEvent evt)
+ {
+ if (evt.target is VisualElement ve && _assetsByElement.ContainsKey(ve)) {
+ var clickedAsset = _assetsByElement[ve];
+ var lib = _assetsByElement[ve].Library;
+ if (!lib.IsLocked) {
+ evt.menu.AppendAction("Remove from Library", _ => {
+ if (!_selectedAssets.Contains(clickedAsset)) {
+ _selectedAssets.Add(clickedAsset);
+ ToggleSelectionClass(_elementByAsset[clickedAsset.Asset]);
+ }
+ var numRemovedAssets = 0;
+ foreach (var asset in _selectedAssets.Where(a => !a.Library.IsLocked).ToList()) {
+ _selectedAssets.Remove(asset);
+ asset.Library.RemoveAsset(asset.Asset);
+ numRemovedAssets++;
+ }
+
+ RefreshCategories();
+ RefreshAssets();
+ _statusLabel.text = $"Removed {numRemovedAssets} assets from library.";
+ });
+ }
+ }
+ }
+
+ private void OnEmptyClicked(PointerUpEvent evt)
+ {
+ SelectNone();
+ }
+
+ private void OnAssetClicked(IMouseEvent evt, VisualElement element)
+ {
+ // only interested in left click here
+ if (evt.button != 0) {
+ return;
+ }
+ var clickedAsset = _assetsByElement[element];
+
+ // no modifier pressed
+ if (!evt.shiftKey && !evt.ctrlKey) {
+ // already selected?
+ if (_selectedAssets.Contains(clickedAsset)) {
+ if (_selectedAssets.Count != 1) {
+ SelectOnly(clickedAsset);
+ } // if count is 1, and user clicks on it, do nothing.
+ } else {
+ SelectOnly(clickedAsset);
+ }
+ }
+
+ // only CTRL pressed
+ if (!evt.shiftKey && evt.ctrlKey) {
+ // already selected?
+ if (_selectedAssets.Contains(clickedAsset)) {
+ UnSelect(clickedAsset);
+ } else {
+ Select(clickedAsset);
+ }
+ }
+
+ // only SHIFT pressed
+ if (evt.shiftKey && !evt.ctrlKey) {
+ var startIndex = _firstSelectedAsset != null ? _assets.IndexOf(_firstSelectedAsset) : 0;
+ var endIndex = _assets.IndexOf(clickedAsset);
+ LastSelectedAsset = clickedAsset;
+ SelectRange(startIndex, endIndex);
+ }
+
+
+ // both SHIFT and CTRL pressed
+ if (evt.shiftKey && evt.ctrlKey) {
+ // todo
+ }
+ }
+
+ public void OnCategoriesUpdated(Dictionary> categories) => Query.Filter(categories);
+ private void OnSearchQueryChanged(ChangeEvent evt) => Query.Search(evt.newValue);
+ private void OnLibraryToggled(AssetLibrary lib, bool enabled)
+ {
+ lib.IsActive = enabled;
+ Query.Toggle(lib);
+ _selectedLibraries = Libraries.Where(l => l.IsActive).Select(l => l.Id).ToList();
+ RefreshCategories();
+ }
+
+ public AssetLibrary GetLibraryByPath(string pathToCheck)
+ {
+ pathToCheck = pathToCheck.Replace('\\', '/');
+ return Libraries.FirstOrDefault(assetLibrary => {
+ var libraryPath = assetLibrary.LibraryRoot.Replace('\\', '/');
+ return pathToCheck.StartsWith(libraryPath);
+ });
+ }
+
+ public void AddAssets(IEnumerable paths, Func getCategory)
+ {
+ var numAdded = 0;
+ var numUpdated = 0;
+ AssetLibrary updatedLibrary = null;
+ foreach (var path in paths) {
+ var assetLibrary = GetLibraryByPath(path);
+ if (assetLibrary == null) {
+ continue;
+ }
+ var obj = AssetDatabase.LoadAssetAtPath(path, AssetDatabase.GetMainAssetTypeAtPath(path));
+ var category = getCategory(assetLibrary);
+
+ if (assetLibrary.AddAsset(obj, category)) {
+ Debug.Log($"{Path.GetFileName(path)} added to library {assetLibrary.Name}.");
+ numAdded++;
+ } else {
+ Debug.Log($"{Path.GetFileName(path)} updated in library {assetLibrary.Name}.");
+ numUpdated++;
+ }
+ updatedLibrary = assetLibrary;
+ }
+
+ _categoryView.Refresh(this);
+
+ if (numAdded > 0 && numUpdated == 0) {
+ _statusLabel.text = $"{numAdded} asset" + (numAdded == 1 ? "" : "s") + $" added to library {updatedLibrary!.Name}.";
+ } else if (numAdded == 0 && numUpdated > 0) {
+ _statusLabel.text = $"{numUpdated} asset" + (numUpdated == 1 ? "" : "s") + $" updated in library {updatedLibrary!.Name}.";
+ } else if (numAdded > 0 && numUpdated > 0) {
+ _statusLabel.text = $"{numAdded} asset" + (numAdded == 1 ? "" : "s") + $" added and {numUpdated} asset" + (numUpdated == 1 ? "" : "s") + $" updated in library {updatedLibrary!.Name}.";
+ } else {
+ _statusLabel.text = "No assets added to library.";
+ }
+ }
+
+ #endregion
+
+ #region Selection
+
+ private void SelectRange(int start, int end)
+ {
+ if (start > end) {
+ (start, end) = (end, start);
+ }
+ for (var i = 0; i < _assets.Count; i++) {
+ var asset = _assets[i];
+ if (i >= start && i <= end) {
+ if (!_selectedAssets.Contains(asset)) {
+ _selectedAssets.Add(asset);
+ ToggleSelectionClass(_elementByAsset[asset.Asset]);
+ }
+ } else if (_selectedAssets.Contains(asset)) {
+ _selectedAssets.Remove(asset);
+ ToggleSelectionClass(_elementByAsset[asset.Asset]);
+ }
+ }
+ }
+
+ private void SelectNone()
+ {
+ foreach (var selectedAsset in _selectedAssets) {
+ ToggleSelectionClass(_elementByAsset[selectedAsset.Asset]);
+ }
+ _selectedAssets.Clear();
+ _firstSelectedAsset = null;
+ LastSelectedAsset = null;
+ }
+
+ private void SelectOnly(AssetResult asset)
+ {
+ var wasAlreadySelected = false;
+ foreach (var selectedAsset in _selectedAssets) {
+ if (selectedAsset != asset) {
+ ToggleSelectionClass(_elementByAsset[selectedAsset.Asset]);
+ } else {
+ wasAlreadySelected = true;
+ }
+ }
+ _selectedAssets.Clear();
+ _selectedAssets.Add(asset);
+ if (!wasAlreadySelected) {
+ ToggleSelectionClass(_elementByAsset[asset.Asset]);
+ }
+ _firstSelectedAsset = asset;
+ LastSelectedAsset = asset;
+ }
+
+ private void UnSelect(AssetResult asset)
+ {
+ _selectedAssets.Remove(asset);
+ ToggleSelectionClass(_elementByAsset[asset.Asset]);
+ _firstSelectedAsset = _selectedAssets.Count > 0 ? _selectedAssets.FirstOrDefault() : null;
+ LastSelectedAsset = _selectedAssets.Count > 0 ? _selectedAssets.LastOrDefault() : null;
+ }
+
+
+ private void Select(AssetResult asset)
+ {
+ _selectedAssets.Add(asset);
+ ToggleSelectionClass(_elementByAsset[asset.Asset]);
+ LastSelectedAsset = asset;
+ }
+
+ private static void ToggleSelectionClass(VisualElement element) => element.ToggleInClassList("selected");
+
+ #endregion Selection
+
+ #region Drag and Drop
+
+ private static void StartDraggingAssets(HashSet data) => DragAndDrop.SetGenericData("assets", data);
+ public static void StopDraggingAssets() => DragAndDrop.SetGenericData("assets", null);
+
+ public static bool IsDraggingExistingAssets => DragAndDrop.GetGenericData("assets") is HashSet;
+
+ public static bool IsDraggingNewAssets => DragAndDrop.paths is { Length: > 0 };
+
+ private void OnDragEnterEvent(DragEnterEvent evt)
+ {
+ DragError = null;
+
+ if (!IsDraggingNewAssets) {
+ return;
+ }
+
+ if (_categoryView.NumCategories == 0) {
+ DragError = "Unknown category. Seems there are no categories in the database, so you'll need to create one first.";
+ return;
+ }
+
+ if (_categoryView.NumSelectedCategories != 1) {
+ DragError = "Unknown category. You have to filter by one single category when dragging into the grid view. But you can also drag onto the category directly on the left directly.";
+ return;
+ }
+
+ foreach (var path in DragAndDrop.paths) {
+ var assetLibrary = GetLibraryByPath(path);
+
+ if (assetLibrary == null) {
+ DragError = "Unknown library. Your assets must be under the root of a library, and at least one of the assets you're dragging is not.";
+ break;
+ }
+
+ if (assetLibrary.IsLocked) {
+ DragError = "Access Error. The library you're trying to add assets to is locked.";
+ break;
+ }
+ }
+ }
+
+ private static void OnDragUpdatedEvent(DragUpdatedEvent evt)
+ {
+ DragAndDrop.visualMode = IsDraggingNewAssets ? DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected;
+ }
+
+ private void OnDragLeaveEvent(DragLeaveEvent evt)
+ {
+ // hide error panel
+ DragError = null;
+ }
+
+ private void OnDragPerformEvent(DragPerformEvent evt)
+ {
+ if (DragError != null) {
+ DragError = null;
+ return;
+ }
+
+ DragAndDrop.AcceptDrag();
+ AddAssets(DragAndDrop.paths, assetLibrary => _categoryView.GetOrCreateSelected(assetLibrary));
+ }
+
+ #endregion
+
+ private void Update()
+ {
+ foreach (var asset in _previewLoadingAssets) {
+ if (_elementByAsset.ContainsKey(asset.Asset)) {
+ asset.RefreshPreviewImage(_elementByAsset[asset.Asset]);
+ }
+ }
+ _previewLoadingAssets.RemoveWhere(asset => !asset.IsLoadingAssetPreview);
+ }
+
+ private void OnThumbSizeChanged(ChangeEvent evt)
+ {
+ _thumbnailSize = (int)evt.newValue;
+ foreach (var e in _elementByAsset.Values) {
+ e.style.width = _thumbnailSize;
+ e.style.height = _thumbnailSize;
+ }
+ }
+
+ public void AttachData(AssetResult clickedAsset)
+ {
+ if (!_selectedAssets.Contains(clickedAsset)) {
+ _selectedAssets.Add(clickedAsset);
+ }
+ DragAndDrop.objectReferences = _selectedAssets.Select(result => result.Asset.Object).ToArray();
+ StartDraggingAssets(_selectedAssets);
+ }
+ }
+
+ public interface IDragHandler
+ {
+ void AttachData(AssetResult clickedAsset);
+ }
+
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Common/UnitPropertyDrawer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.cs.meta
similarity index 83%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/Common/UnitPropertyDrawer.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.cs.meta
index a922a58e7..5771d2a01 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Common/UnitPropertyDrawer.cs.meta
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 1accad5d5db00244284c9de6406ebaff
+guid: 681ba8a5561dfea4497a01e5b5e1b801
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uss b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uss
new file mode 100644
index 000000000..f9ddf6429
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uss
@@ -0,0 +1,120 @@
+/* generic stuff */
+/* ------------- */
+.hidden {
+ display: none;
+}
+
+/* left panel */
+/* ---------- */
+
+#leftPane {
+ padding: 5px;
+}
+
+.left-title {
+ -unity-font-style: bold;
+ margin-top: 10px;
+ margin-bottom: 3px;
+}
+
+.left-empty {
+ margin: 16px;
+ white-space: normal;
+ -unity-font-style: italic;
+ -unity-text-align: middle-center;
+}
+
+AssetDetailsElement > TemplateContainer {
+ flex-grow: 1;
+}
+
+#libraryList .library-item {
+ margin-left: 8px;
+}
+#libraryList .library-item Image {
+ width: 16px;
+ height: 16px;
+}
+
+LibraryCategoryView {
+ flex-grow: 1;
+}
+
+#dragErrorContainerLeft {
+ margin: -5px;
+}
+
+/* "grid" */
+/* ------ */
+#gridContent {
+ flex-grow: 1;
+ background-color: #333333; /* sampled from project window */
+}
+
+#gridContent .unity-scroll-view__content-container {
+ flex-direction: row;
+ flex-wrap: wrap;
+ padding: 10px;
+}
+
+#gridContent .unity-image {
+ width: 150px;
+ height: 150px;
+ margin: 10px 10px 5px 10px;
+}
+
+#dragErrorContainer, #dragErrorContainerLeft {
+ background-color: darkred;
+ padding: 2px 0 0 5px;
+ flex-wrap: wrap;
+}
+
+#dragErrorContainer Label, #dragErrorContainerLeft Label {
+ white-space: normal;
+}
+
+/* thumb item */
+/* ---------- */
+.thumb-item {
+ align-items: center;
+ overflow: scroll;
+}
+.thumb-item #thumbnail {
+ flex: 1;
+}
+.thumb-item #label {
+ margin-top: 0;
+ margin-bottom: 5px;
+ font-size: 80%;
+}
+.selected .thumb-item {
+ background-color: #2C5D87; /* picked from list view */
+}
+
+/* bottom toolbar */
+/* -------------- */
+#bottomToolbar {
+ justify-content: space-between;
+ border-top-width: 1px;
+ border-bottom-width: 0;
+ padding: 0 5px;
+ background-color: #383838; /* sampled from background */
+}
+
+#bottomLabel { margin-top: 2px; }
+#sizeSlider { width: 75px; }
+
+
+/* fixes and overridden colors */
+/* --------------------------- */
+ListView {
+ flex-grow: 1;
+}
+
+.unity-two-pane-split-view {
+ flex-grow: 1;
+}
+
+.unity-two-pane-split-view__dragline-anchor {
+ background-color: #1d1d1d;
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uss.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uss.meta
new file mode 100644
index 000000000..ed016fa45
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uss.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 98b5afa4f7306e04fb160371138d6662
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
+ disableValidation: 0
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uxml
new file mode 100644
index 000000000..f317c4df7
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uxml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uxml.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uxml.meta
new file mode 100644
index 000000000..f50e51df0
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uxml.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: 466b5f13746a85b4ba3ed622cfc5198c
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX_Init.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX_Init.cs
new file mode 100644
index 000000000..8d185d408
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX_Init.cs
@@ -0,0 +1,178 @@
+// Visual Pinball Engine
+// Copyright (C) 2022 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System.IO;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ public partial class AssetBrowserX
+ {
+ private ToolbarButton _refreshButton;
+ private ToolbarSearchField _queryInput;
+
+ private LibraryCategoryView _categoryView;
+ private VisualElement _libraryList;
+ private Label _noLibrariesLabel;
+
+ private VisualElement _gridContent;
+ private Label _dragErrorLabelLeft;
+ private VisualElement _dragErrorContainerLeft;
+ private Label _dragErrorLabel;
+ private VisualElement _dragErrorContainer;
+ private AssetDetailsElement _detailsElement;
+ private Label _statusLabel;
+ private Slider _sizeSlider;
+
+ private VisualTreeAsset _assetTree;
+
+ public string DragErrorLeft {
+ get => _dragErrorContainerLeft.ClassListContains("hidden") ? null : _dragErrorLabelLeft.text;
+ set {
+ if (value == null) {
+ if (!_dragErrorContainerLeft.ClassListContains("hidden")) {
+ _dragErrorContainerLeft.AddToClassList("hidden");
+ }
+ return;
+ }
+
+ _dragErrorContainerLeft.RemoveFromClassList("hidden");
+ _dragErrorLabelLeft.text = value;
+ }
+ }
+
+ private string DragError {
+ get => _dragErrorContainer.ClassListContains("hidden") ? null : _dragErrorLabel.text;
+ set {
+ if (value == null) {
+ if (!_dragErrorContainer.ClassListContains("hidden")) {
+ _dragErrorContainer.AddToClassList("hidden");
+ }
+ return;
+ }
+
+ _dragErrorContainer.RemoveFromClassList("hidden");
+ _dragErrorLabel.text = value;
+ }
+ }
+
+ ///
+ /// Setup the UI. Data is already set up at this point. We'll just trigger a refresh once the UI is set up.
+ ///
+ public void CreateGUI()
+ {
+ // import UXML
+ var visualTree = AssetDatabase.LoadAssetAtPath("Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uxml");
+ visualTree.CloneTree(rootVisualElement);
+ _assetTree = AssetDatabase.LoadAssetAtPath("Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/LibraryAssetElement.uxml");
+
+ var ui = rootVisualElement;
+
+ // import style sheet
+ var styleSheet = AssetDatabase.LoadAssetAtPath("Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowserX.uss");
+ ui.styleSheets.Add(styleSheet);
+
+ // libraries
+ _libraryList = ui.Q("libraryList");
+ _noLibrariesLabel = ui.Q