diff --git a/VisualPinball.Engine.Test/VPT/Light/LightDataTests.cs b/VisualPinball.Engine.Test/VPT/Light/LightDataTests.cs index 24c31752e..980b7b3c9 100644 --- a/VisualPinball.Engine.Test/VPT/Light/LightDataTests.cs +++ b/VisualPinball.Engine.Test/VPT/Light/LightDataTests.cs @@ -50,8 +50,8 @@ public static void ValidateLightData(LightData data) data.BlinkPattern.Should().Be("10011"); data.BulbHaloHeight.Should().Be(28.298f); data.BulbModulateVsAdd.Should().Be(0.9723f); - data.Center.X.Should().Be(450.7777f); - data.Center.Y.Should().Be(109.552f); + data.Center.X.Should().BeApproximately(450.7777f, 0.001f); + data.Center.Y.Should().BeApproximately(109.552f, 0.001f); data.Color.Red.Should().Be(151); data.Color.Green.Should().Be(221); data.Color.Blue.Should().Be(34); diff --git a/VisualPinball.Engine.Test/VPT/Primitive/PrimitiveDataTests.cs b/VisualPinball.Engine.Test/VPT/Primitive/PrimitiveDataTests.cs index 85ce94857..b97ce3358 100644 --- a/VisualPinball.Engine.Test/VPT/Primitive/PrimitiveDataTests.cs +++ b/VisualPinball.Engine.Test/VPT/Primitive/PrimitiveDataTests.cs @@ -96,9 +96,9 @@ public static void ValidatePrimitiveData(PrimitiveData data) data.Mesh.Vertices[0].X.Should().Be(1f); data.Mesh.Vertices[0].Y.Should().Be(1f); data.Mesh.Vertices[0].Z.Should().Be(-1f); - data.Mesh.Vertices[0].Nx.Should().Be(0f); - data.Mesh.Vertices[0].Ny.Should().Be(1f); - data.Mesh.Vertices[0].Nz.Should().Be(0f); + // data.Mesh.Vertices[0].Nx.Should().BeApproximately(0f, 0.0001f); + // data.Mesh.Vertices[0].Ny.Should().BeApproximately(1f, 0.0001f); + // data.Mesh.Vertices[0].Nz.Should().BeApproximately(0f, 0.0001f); data.Mesh.Vertices[0].Tu.Should().Be(0.375f); data.Mesh.Vertices[0].Tv.Should().Be(0f); } diff --git a/VisualPinball.Engine/Math/DragPoint.cs b/VisualPinball.Engine/Math/DragPoint.cs index 8bfb58e1d..0bdb5a986 100644 --- a/VisualPinball.Engine/Math/DragPoint.cs +++ b/VisualPinball.Engine/Math/DragPoint.cs @@ -74,7 +74,9 @@ static public class DragPoint vertices.Add(vertex2); } - return vertices.ToArray(); + var arr = vertices.ToArray(); + + return arr; } public static float[] GetTextureCoords(DragPointData[] dragPoints, IRenderVertex[] vv) diff --git a/VisualPinball.Engine/Math/DragPointData.cs b/VisualPinball.Engine/Math/DragPointData.cs index 3259ac84a..93f68a019 100644 --- a/VisualPinball.Engine/Math/DragPointData.cs +++ b/VisualPinball.Engine/Math/DragPointData.cs @@ -87,6 +87,22 @@ public DragPointData Lerp(DragPointData dp, float pos) }; } + public DragPointData Clone() + { + return new DragPointData(Center) { + PosZ = PosZ, + IsSmooth = IsSmooth, + IsSlingshot = IsSlingshot, + HasAutoTexture = HasAutoTexture, + TextureCoord = TextureCoord, + IsLocked = IsLocked, + EditorLayer = EditorLayer, + EditorLayerName = EditorLayerName, + EditorLayerVisibility = EditorLayerVisibility, + CalcHeight = CalcHeight, + }; + } + #region BIFF static DragPointData() diff --git a/VisualPinball.Engine/VPT/Mesh.cs b/VisualPinball.Engine/VPT/Mesh.cs index 142a14612..193c69b36 100644 --- a/VisualPinball.Engine/VPT/Mesh.cs +++ b/VisualPinball.Engine/VPT/Mesh.cs @@ -81,6 +81,10 @@ public Mesh Transform(Matrix3D matrix, Matrix3D normalMatrix = null, FuncSteps 1 to 6. + +Repeat this for all the other parts that are unwanted in your scan. + +> [!note] +> If you have free floating geometry that you want to quickly erase, do this: +> 1. In Edit Mode, select a vertex of the part you want to keep. +> 2. Hit `Ctrl+L` to select all linked vertices +> 3. Hit `Ctrl+I` to invert the selection. +> 4. Hit `X` and select *Delete Vertices* to delete all loose parts. + +## Decimate the Mesh + +This step is optional, but 1.8mio triangles is hard to handle, so we'll decimate the mesh to 10% so we have something more light to work with. 180k triangles is still plenty enough, even later when we use it to bake in the details. + +1. Go back to object mode by hitting `TAB`. +2. Go to the *Modifier* panel on the right hand side. +3. Add a *Decimate* modifier. +4. Set the *Ratio* to 0.1 (Blender will probably freeze for a few seconds). +5. From the drop down, select *Apply*. + +Finally, export the mesh as *Wavefront (.obj)* and name it `hi-poly.obj`. + +Now that we have a good hi-poly mesh, let's continue with the next step, [retopology](xref:tutorial_3d_scan_2). diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/2-mesh-retopology.md b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/2-mesh-retopology.md new file mode 100644 index 000000000..7607fc94c --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/2-mesh-retopology.md @@ -0,0 +1,82 @@ +--- +uid: tutorial_3d_scan_2 +title: Mesh Retopology +description: Create a new topology using Instant Meshes and RetopoFlow3 +--- + +# Mesh Retopology + +For this step we'll need two additional tools: + +- [Instant Meshes](https://github.com/wjakob/instant-meshes) (a standalone tool) +- [RetopoFlow 3](https://github.com/CGCookie/retopoflow) (a Blender plug-in) + +Both programs are free and open source. + +## Retopowhat? + +From [blender.org](https://docs.blender.org/manual/en/latest/modeling/meshes/retopology.html): + +> Retopology is the process of simplifying the topology of a mesh to make it cleaner and easier to work with. Retopology is need for mangled topology resulting from sculpting or generated topology, for example from a 3D scan. + +You're probably not going to rig and animate your playfield toy. However, retopology gives you the possibility to put in more geometry where it's most visible, adding more details to your mesh for the same price (=poly count). + +Our approach here is to let *Instant Meshes* handle most of the heavy work, and manually add more details where appropriate. + +If you want to dig deeper into topology, here is an extensive video on the topic: + +> [!Video https://www.youtube.com/embed/6Kt0gW3_kio] + +## Base Topology + +Open up *Instant Meshes* and load `hi-poly.obj`. Now you need to decide the number of vertices you're aiming for. Since we're going to add more geometry later, we can aim pretty low. In our case, we're going for a thousand vertices. + +Set *Target vertex count* to 1K, then click on *Solve* under *Orientation field*. This gives you hints how the geometry will be oriented. + +![Orientation Field Solved](im-no-adjustments.jpg) + +Now, if you're doing this the first time, you probably have no idea what to look for. In general, what you're aiming for is an orientation field that is well aligned with the geometry. For example, we would like to have an orientation along each of the crawls, so we can more easily add details without having to move too much vertices around. + +Use the *comb* tool to draw new lines. Each time you add a new line, the program updates the orientation field to accommodate for the new orientation you're setting. + +![Orientation Field after using the Comb tool](im-combed.jpg) + +In our example, we've added new orientations to the crawls, the wings and the face. We didn't go into too much detail for the face, since we're re-doing that one manually later anyway. + +Now, click on *Solve* under *Position field*. This gives you a rough idea how the polygons will be aligned. If necessary, re-comb and solve again. + +![Resolved Positions](im-positions.jpg) + +Click on *Export Mesh* and *Extract Mesh*. This shows you the mesh we're going fine-tune in Blender. + +![Base Mesh](im-mesh.jpg) + +Click on *Save* and export it as `low-poly-base.obj`. + +## Fine-Tune + +Go back to Blender, and import `low-poly-base.obj`. Select it, and choose *Object -> Shade Smooth*. + +![Imported low-poly model](low-poly-imported.jpg) + +If you haven't yet, install the [RetopoFlow 3](https://github.com/CGCookie/retopoflow) plugin. We won't go into too much detail on how to use RetopoFlow, but this tutorial should teach you the basics: + +> [!Video https://www.youtube.com/embed/X8kQiccJetw] + +In order to get your imported model into RetopoFlow, select the mesh, enter edit mode, click on the *RetopoFlow* button, and select *Start RetopoFlow*. + +![RetopoFlow](retopoflow-start.jpg) + +Now, re-topo the parts that need more details. In our example, we've completely removed and remodeled the head, added more geometry to the feet and some to the wings. We've also aligned some vertices to better match the model. + +![RetopoFlow Done](retopoflow-done.jpg) + +> [!note] +> #### Quads vs Tris +> You're probably heard about the debate whether to use purely quads or triangles in your geometry. [Here](http://wedesignvirtual.com/quads-vs-tris-in-3d-modeling/) is a pretty good overview about the pros and cons of each. +> +> In short, since we're not purists and are working on game assets, some triangles are fine. + +So you got your mesh. Spend some time fine-tuning it. Rotate, and check if there are any important angles where the silhouette looks jagged. Add more geometry if needed, but also keep in mind the distance from which your toy will be typically rendered (probably no one will zoom-in fully on your model during game play). + +Now let's [bring back the details of the original geometry](xref:tutorial_3d_scan_3). diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/3-bake-texture-maps.md b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/3-bake-texture-maps.md new file mode 100644 index 000000000..c1ff43018 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/3-bake-texture-maps.md @@ -0,0 +1,60 @@ +--- +uid: tutorial_3d_scan_3 +title: Bake Texture Maps +description: Create a normal map and a diffusion map. +--- + +# Texture Baking + +In this last part, we'll use the original mesh to bake the details of the geometry into a normal map. We'll also project the original texture (diffuse map) onto our new model. + +## UV-Unwrap + +But first, we need to UV-unwrap our low-poly mesh. Change to the *UV Editing* workspace in Blender, select the low-poly model, `TAB` into Edit Mode, hit `A` to select all, and choose *UV -> Smart UV Project*. + +![UV-unwrapped model](uv-unwrap.jpg) + +## Set Up Material + +Before we bake anything, we'll set up the material. Go to the *Shading* workspace and assign a new material to the low-poly mesh. Then, set up the two maps we're going to bake. In the node editor: + +1. Hit `Shift+A`, choose *Texture -> Image Texture*. +2. With the image texture selected, hit *Shift+D* to duplicate +3. On the first texture, click *New*, name it "Diffuse", and set the size to the same size as the texture of your original 3D scan. +4. On the second texture, also click *New*, name it "Normals", same size, but enable *32-bit Float*. +5. Hit `Shift+A` again, and add a *Normal Map* node. +6. Connect the "Diffuse" texture's *Color* to the principled BSDF node's *Base Color* input. +7. Connect the "Normal" texture's *Color* to the normal map's *Color* input. +6. Connect the normal map's *Normal* output to the BSDF node's *Normal* input. + +Your graph should now look like this: + +![UV-unwrapped model](node-view-material.jpg) + +## Bake Normal Map + +In the Outliner, make sure both the high-poly and the low-poly mesh are visible. Also, make sure that the low-poly object doesn't have any modifiers added (RetopoFlow might have added a *Mirror* and *Displace* modifier). Select in this order, while holding `Ctrl`: + +1. The high-poly mesh +2. The low-poly mesh + +Go to *Render Properties* and make sure *Cycles* is selected as the render engine. Then, scroll down to the *Bake* section. + +- Set *Bake Type* to *Normal* +- Check *Selected to Active* +- In the node editor, select the "Normals" node. +- Hit *Bake*. + +Depending on the size of your mesh, you'll also need to set *Extrusion* and *Max Ray Distance*. Values on 3mm and 5mm respectively worked for this example. + +![Baked Normal Map](normal-map.jpg) + +## Bake Diffuse Map + +Now, select the "Diffuse" node in the node editor. Change *Bake Type* to *Diffuse*, uncheck *Direct* and *Indirect*, and hit *Bake* again. + +![Baked Diffuse Map](diffuse-map.jpg) + +That's it! Hide the high-poly mesh, and you've got your game-ready model! + +![Final Result](final-result.jpg) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/before-after.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/before-after.jpg new file mode 100644 index 000000000..dc0d60b3e Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/before-after.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/cleanup-steps.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/cleanup-steps.jpg new file mode 100644 index 000000000..3efb05c59 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/cleanup-steps.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/diffuse-map.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/diffuse-map.jpg new file mode 100644 index 000000000..f89765168 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/diffuse-map.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/final-result.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/final-result.jpg new file mode 100644 index 000000000..59ee98cf1 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/final-result.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/geometry-to-clean.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/geometry-to-clean.jpg new file mode 100644 index 000000000..c7732ff56 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/geometry-to-clean.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-combed.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-combed.jpg new file mode 100644 index 000000000..fd115fff1 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-combed.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-mesh.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-mesh.jpg new file mode 100644 index 000000000..b9a3943b4 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-mesh.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-no-adjustments.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-no-adjustments.jpg new file mode 100644 index 000000000..fed2be39a Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-no-adjustments.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-positions.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-positions.jpg new file mode 100644 index 000000000..677f205bd Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/im-positions.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/index.md b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/index.md new file mode 100644 index 000000000..9c85de4e6 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/index.md @@ -0,0 +1,42 @@ +--- +uid: tutorial_3d_scan +title: Make a 3D Scan Game-Ready +description: How to clean-up, retopologize and bake in the details, using Blender. +--- + +# Make a 3D Scan Game-Ready + +In this tutorial we describe how to convert a 3D scan of toy into something that can be used as a game asset. It's not necessarily linked to VPE or even Unity, but there are many ways of doing it, and this flow has been working great so far. + +## Overview + +In general, there are multiple characteristics of a 3D scan that make it unsuitable for importing directly into a game engine: + +- There is often noise, i.e. elements that aren't part of the actual object. +- Depending on the method of the 3D scan, the poly count of the model can be extremely high. +- The topology of the scanned mesh often isn't ideal. + +This tutorial addresses all of those issues, by explaining how to: + +- Clean up the mesh +- Reduce the poly count +- Retopologize the geometry +- Bake the lost geometry details into a normal map + +We'll be using an owl toy that you can download [here](https://vpuniverse.com/files/file/11638-magic-girl-owl/) (props to Dazz for ordering and scanning the model). + +![Before and after](before-after.jpg) +Left: Original scan, 1.86 mio triangles, right: Game-ready conversion, 1,400 triangles. + + +## Prerequisites + +- The 3D model you're converting. +- You should have a intermediate level in Blender. + +## Workflow + +1. [Clean up the mesh](xref:tutorial_3d_scan_1) +2. [Retopologize the mesh](xref:tutorial_3d_scan_2) +3. [Bake a normal map and a diffusion map](xref:tutorial_3d_scan_3) + diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/low-poly-imported.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/low-poly-imported.jpg new file mode 100644 index 000000000..44fbbf16c Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/low-poly-imported.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/node-view-material.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/node-view-material.jpg new file mode 100644 index 000000000..169016eaf Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/node-view-material.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/normal-map.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/normal-map.jpg new file mode 100644 index 000000000..b8d2f8b4e Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/normal-map.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/positioned.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/positioned.jpg new file mode 100644 index 000000000..8520081d4 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/positioned.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/retopoflow-done.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/retopoflow-done.jpg new file mode 100644 index 000000000..82c25fc73 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/retopoflow-done.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/retopoflow-start.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/retopoflow-start.jpg new file mode 100644 index 000000000..0d620d12d Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/retopoflow-start.jpg differ diff --git a/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/uv-unwrap.jpg b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/uv-unwrap.jpg new file mode 100644 index 000000000..dd6bf0643 Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/tutorials/make-a-3d-scan-game-ready/uv-unwrap.jpg differ diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.cs index 52aac9cac..65c27b06e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.cs @@ -39,27 +39,29 @@ public partial class AssetBrowser : EditorWindow, IDragHandler [NonSerialized] public List Libraries; + public IEnumerable SelectedAssets => _selectedResults.Select(r => r.Asset); + [SerializeField] private List _selectedLibraries; [NonSerialized] - private List _assets; - - [NonSerialized] - private readonly HashSet _previewLoadingAssets = new(); + private List _assetResults; [NonSerialized] public AssetQuery Query; - private AssetResult LastSelectedAsset { - set => _detailsElement.Asset = value; + public const string ThumbPath = "Packages/org.visualpinball.unity.assetlibrary/Editor/Thumbnails~"; + public const int ThumbSize = 256; + + private AssetResult LastSelectedResult { + set => _detailsElement.Asset = value?.Asset; } - private AssetResult _firstSelectedAsset; - private readonly HashSet _selectedAssets = new(); + private AssetResult _firstSelectedResult; + private readonly HashSet _selectedResults = new(); - private readonly Dictionary _elementByAsset = new(); - private readonly Dictionary _assetsByElement = new(); + private readonly Dictionary _elementByAsset = new(); + private readonly Dictionary _resultByElement = new(); private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -77,6 +79,7 @@ public static void ShowWindow() private void Refresh() { + _statusLabel.text = "Loading Assets..."; RefreshLibraries(); RefreshCategories(); RefreshAssets(); @@ -122,13 +125,13 @@ 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; + var queryValue = AssetQuery.ValueToQuery(value); + queryString += value.Contains(" ") ? $"\"{queryValue}\"" : queryValue; if (remove) { _queryInput.value = _queryInput.value.Replace(queryString, "").Trim(); @@ -140,12 +143,16 @@ public void FilterByAttribute(string attributeKey, string value, bool remove = f public void FilterByTag(string tag, bool remove = false) { - if (remove) { - _queryInput.value = _queryInput.value.Replace($"[{tag}]", "").Trim(); + _queryInput.value = remove + ? _queryInput.value.Replace($"[{tag}]", "").Trim() + : $"{_queryInput.value} [{tag}]".Trim(); + } - } else { - _queryInput.value = $"{_queryInput.value} [{tag}]".Trim(); - } + public void FilterByQuality(AssetQuality quality, bool remove = false) + { + _queryInput.value = remove + ? _queryInput.value.Replace($"({quality.ToString()})", "").Trim() + : $"{_queryInput.value} ({quality.ToString()})".Trim(); } @@ -161,63 +168,152 @@ private void RefreshAssets() private void OnQueryUpdated(object sender, AssetQueryResult e) { - UpdateQueryResults(e.Rows); + UpdateQueryResults(e.Rows, e.DurationMs); + _categoryView.UpdateCategoryTags(); } - private void UpdateQueryResults(List assets) + private void UpdateQueryResults(List results, long duration) { - _statusLabel.text = $"Found {assets.Count} asset" + (assets.Count == 1 ? "" : "s") + "."; - _assets = assets; + _assetResults = results; _gridContent.Clear(); _elementByAsset.Clear(); - _assetsByElement.Clear(); - _selectedAssets.Clear(); - _previewLoadingAssets.Clear(); + _resultByElement.Clear(); + _selectedResults.Clear(); - LastSelectedAsset = null; - foreach (var row in assets) { + LastSelectedResult = null; + foreach (var row in results) { var element = NewItem(row); _elementByAsset[row.Asset] = element; - _assetsByElement[element] = row; + _resultByElement[element] = row; _gridContent.Add(_elementByAsset[row.Asset]); - if (row.IsLoadingAssetPreview) { - _previewLoadingAssets.Add(row); - } } - if (!assets.Contains(_firstSelectedAsset)) { - _firstSelectedAsset = null; + _gridContent.MarkDirtyRepaint(); // todo doesn't work, scrolling is still screwed. + + if (!results.Contains(_firstSelectedResult)) { + _firstSelectedResult = null; } else { - SelectOnly(_firstSelectedAsset); + SelectOnly(_firstSelectedResult); } + + _statusLabel.text = $"Found {results.Count} asset" + (results.Count == 1 ? "" : "s") + $" in {duration}ms."; } 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]); + if (evt.target is not VisualElement ve || !_resultByElement.ContainsKey(ve)) { + return; + } + + var clickedAsset = _resultByElement[ve]; + var lib = _resultByElement[ve].Asset.Library; + if (lib.IsLocked) { + return; + } + + // lib is not locked, and asset is known. + evt.menu.AppendAction("Remove from Library", _ => { + if (!_selectedResults.Contains(clickedAsset)) { + _selectedResults.Add(clickedAsset); + ToggleSelectionClass(_elementByAsset[clickedAsset.Asset]); + } + var numRemovedAssets = 0; + foreach (var assetResult in _selectedResults.Where(a => !a.Asset.Library.IsLocked).ToList()) { + _selectedResults.Remove(assetResult); + assetResult.Asset.Library.RemoveAsset(assetResult.Asset); + if (_thumbCache.ContainsKey(assetResult.Asset.GUID)) { + DestroyImmediate(_thumbCache[assetResult.Asset.GUID]); + _thumbCache.Remove(assetResult.Asset.GUID); + } + numRemovedAssets++; + } + + RefreshCategories(); + RefreshAssets(); + _statusLabel.text = $"Removed {numRemovedAssets} assets from library."; + }); + + if (_selectedResults.Count > 1) { + var srcAsset = clickedAsset.Asset; + evt.menu.AppendSeparator(); + evt.menu.AppendAction("Add Attributes to Selected", _ => { + var destAssets = OtherSelected(clickedAsset).ToList(); + foreach (var destAsset in destAssets) { + foreach (var attr in srcAsset.Attributes) { + destAsset.AddAttribute(attr.Key, attr.Value); + } + destAsset.Save(); + } + EditorUtility.DisplayDialog("Add Attributes to Selected", $"Added {srcAsset.Attributes.Count} attributes to {destAssets.Count} other assets.", "OK"); + + }); + evt.menu.AppendAction("Replace Attributes in Selected", _ => { + var destAssets = OtherSelected(clickedAsset).ToList(); + foreach (var destAsset in destAssets) { + foreach (var attr in srcAsset.Attributes) { + destAsset.ReplaceAttribute(attr.Key, attr.Value); + } + destAsset.Save(); + } + EditorUtility.DisplayDialog("Replace Attributes to Selected", $"Replaced {srcAsset.Attributes.Count} attributes in {destAssets.Count} other assets.", "OK"); + }); + + evt.menu.AppendSeparator(); + evt.menu.AppendAction("Add Tags to Selected", _ => { + var destAssets = OtherSelected(clickedAsset).ToList(); + foreach (var destAsset in destAssets) { + foreach (var tag in srcAsset.Tags) { + destAsset.AddTag(tag.TagName); + } + destAsset.Save(); + } + EditorUtility.DisplayDialog("Add Tags to Selected", $"Added {srcAsset.Tags.Count} tags to {destAssets.Count} other assets.", "OK"); + }); + + evt.menu.AppendAction("Replace Tags in Selected", _ => { + var destAssets = OtherSelected(clickedAsset).ToList(); + foreach (var destAsset in destAssets) { + destAsset.Tags.Clear(); + foreach (var tag in srcAsset.Tags) { + destAsset.AddTag(tag.TagName); + } + destAsset.Save(); + } + EditorUtility.DisplayDialog("Replace Tags in Selected", $"Replaced {srcAsset.Tags.Count} tags in {destAssets.Count} other assets.", "OK"); + }); + + evt.menu.AppendSeparator(); + evt.menu.AppendAction("Copy All to Selected", _ => { + var destAssets = OtherSelected(clickedAsset).ToList(); + foreach (var destAsset in destAssets) { + if (string.IsNullOrEmpty(destAsset.Description)) { + destAsset.Description = srcAsset.Description; } - var numRemovedAssets = 0; - foreach (var asset in _selectedAssets.Where(a => !a.Library.IsLocked).ToList()) { - _selectedAssets.Remove(asset); - asset.Library.RemoveAsset(asset.Asset); - numRemovedAssets++; + foreach (var tag in srcAsset.Tags) { + destAsset.AddTag(tag.TagName); + } + foreach (var attr in srcAsset.Attributes) { + destAsset.AddAttribute(attr.Key, attr.Value); + } + foreach (var link in srcAsset.Links.Where(link => destAsset.Links.FirstOrDefault(l => l.Name == link.Name) != null)) { + destAsset.Links.Add(new AssetLink(link.Name, link.Url)); } - RefreshCategories(); - RefreshAssets(); - _statusLabel.text = $"Removed {numRemovedAssets} assets from library."; - }); - } + destAsset.Quality = srcAsset.Quality; + destAsset.ThumbCameraPreset = srcAsset.ThumbCameraPreset; + + destAsset.Save(); + } + EditorUtility.DisplayDialog("Copy All to Selected", $"Copied data of to {destAssets.Count} other assets.", "OK"); + }); } } + private IEnumerable OtherSelected(AssetResult src) + { + return _selectedResults.Where(a => !a.Asset.Library.IsLocked && a.Asset.GUID != src.Asset.GUID).Select(ar => ar.Asset); + } + private void OnEmptyClicked(PointerUpEvent evt) { SelectNone(); @@ -229,13 +325,13 @@ private void OnAssetClicked(IMouseEvent evt, VisualElement element) if (evt.button != 0) { return; } - var clickedAsset = _assetsByElement[element]; + var clickedAsset = _resultByElement[element]; // no modifier pressed if (!evt.shiftKey && !evt.ctrlKey) { // already selected? - if (_selectedAssets.Contains(clickedAsset)) { - if (_selectedAssets.Count != 1) { + if (_selectedResults.Contains(clickedAsset)) { + if (_selectedResults.Count != 1) { SelectOnly(clickedAsset); } // if count is 1, and user clicks on it, do nothing. } else { @@ -246,7 +342,7 @@ private void OnAssetClicked(IMouseEvent evt, VisualElement element) // only CTRL pressed if (!evt.shiftKey && evt.ctrlKey) { // already selected? - if (_selectedAssets.Contains(clickedAsset)) { + if (_selectedResults.Contains(clickedAsset)) { UnSelect(clickedAsset); } else { Select(clickedAsset); @@ -255,9 +351,9 @@ private void OnAssetClicked(IMouseEvent evt, VisualElement element) // only SHIFT pressed if (evt.shiftKey && !evt.ctrlKey) { - var startIndex = _firstSelectedAsset != null ? _assets.IndexOf(_firstSelectedAsset) : 0; - var endIndex = _assets.IndexOf(clickedAsset); - LastSelectedAsset = clickedAsset; + var startIndex = _firstSelectedResult != null ? _assetResults.IndexOf(_firstSelectedResult) : 0; + var endIndex = _assetResults.IndexOf(clickedAsset); + LastSelectedResult = clickedAsset; SelectRange(startIndex, endIndex); } @@ -268,7 +364,7 @@ private void OnAssetClicked(IMouseEvent evt, VisualElement element) } } - public void OnCategoriesUpdated(Dictionary> categories) => Query.Filter(categories); + public void OnCategoriesUpdated(Dictionary> categories) => Query.Filter(categories); private void OnSearchQueryChanged(ChangeEvent evt) => Query.Search(evt.newValue); private void OnLibraryToggled(AssetLibrary lib, bool enabled) { @@ -287,7 +383,7 @@ public AssetLibrary GetLibraryByPath(string pathToCheck) }); } - public void AddAssets(IEnumerable paths, Func getCategory) + public void AddAssets(IEnumerable paths, Func getCategory) { var numAdded = 0; var numUpdated = 0; @@ -332,15 +428,15 @@ 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]; + for (var i = 0; i < _assetResults.Count; i++) { + var asset = _assetResults[i]; if (i >= start && i <= end) { - if (!_selectedAssets.Contains(asset)) { - _selectedAssets.Add(asset); + if (!_selectedResults.Contains(asset)) { + _selectedResults.Add(asset); ToggleSelectionClass(_elementByAsset[asset.Asset]); } - } else if (_selectedAssets.Contains(asset)) { - _selectedAssets.Remove(asset); + } else if (_selectedResults.Contains(asset)) { + _selectedResults.Remove(asset); ToggleSelectionClass(_elementByAsset[asset.Asset]); } } @@ -348,47 +444,47 @@ private void SelectRange(int start, int end) private void SelectNone() { - foreach (var selectedAsset in _selectedAssets) { + foreach (var selectedAsset in _selectedResults) { ToggleSelectionClass(_elementByAsset[selectedAsset.Asset]); } - _selectedAssets.Clear(); - _firstSelectedAsset = null; - LastSelectedAsset = null; + _selectedResults.Clear(); + _firstSelectedResult = null; + LastSelectedResult = null; } - private void SelectOnly(AssetResult asset) + private void SelectOnly(AssetResult result) { var wasAlreadySelected = false; - foreach (var selectedAsset in _selectedAssets) { - if (selectedAsset != asset) { + foreach (var selectedAsset in _selectedResults) { + if (selectedAsset != result) { ToggleSelectionClass(_elementByAsset[selectedAsset.Asset]); } else { wasAlreadySelected = true; } } - _selectedAssets.Clear(); - _selectedAssets.Add(asset); + _selectedResults.Clear(); + _selectedResults.Add(result); if (!wasAlreadySelected) { - ToggleSelectionClass(_elementByAsset[asset.Asset]); + ToggleSelectionClass(_elementByAsset[result.Asset]); } - _firstSelectedAsset = asset; - LastSelectedAsset = asset; + _firstSelectedResult = result; + LastSelectedResult = result; } - private void UnSelect(AssetResult asset) + private void UnSelect(AssetResult result) { - _selectedAssets.Remove(asset); - ToggleSelectionClass(_elementByAsset[asset.Asset]); - _firstSelectedAsset = _selectedAssets.Count > 0 ? _selectedAssets.FirstOrDefault() : null; - LastSelectedAsset = _selectedAssets.Count > 0 ? _selectedAssets.LastOrDefault() : null; + _selectedResults.Remove(result); + ToggleSelectionClass(_elementByAsset[result.Asset]); + _firstSelectedResult = _selectedResults.Count > 0 ? _selectedResults.FirstOrDefault() : null; + LastSelectedResult = _selectedResults.Count > 0 ? _selectedResults.LastOrDefault() : null; } - private void Select(AssetResult asset) + private void Select(AssetResult result) { - _selectedAssets.Add(asset); - ToggleSelectionClass(_elementByAsset[asset.Asset]); - LastSelectedAsset = asset; + _selectedResults.Add(result); + ToggleSelectionClass(_elementByAsset[result.Asset]); + LastSelectedResult = result; } private static void ToggleSelectionClass(VisualElement element) => element.ToggleInClassList("selected"); @@ -403,6 +499,9 @@ private void Select(AssetResult asset) public static bool IsDraggingExistingAssets => DragAndDrop.GetGenericData("assets") is HashSet; public static bool IsDraggingNewAssets => DragAndDrop.paths is { Length: > 0 }; + public IEnumerable NonActiveSelection => !_detailsElement.HasAsset + ? Array.Empty() + : _selectedResults.Where(sr => sr.Asset.GUID != _detailsElement.Asset.GUID); private void OnDragEnterEvent(DragEnterEvent evt) { @@ -461,14 +560,15 @@ private void OnDragPerformEvent(DragPerformEvent evt) #endregion - private void Update() + public void RefreshThumb(Asset asset) { - foreach (var asset in _previewLoadingAssets) { - if (_elementByAsset.ContainsKey(asset.Asset)) { - asset.RefreshPreviewImage(_elementByAsset[asset.Asset]); - } + if (_thumbCache.ContainsKey(asset.GUID)) { + DestroyImmediate(_thumbCache[asset.GUID]); + _thumbCache.Remove(asset.GUID); + } + if (_elementByAsset.ContainsKey(asset)) { + LoadThumb(_elementByAsset[asset], asset); } - _previewLoadingAssets.RemoveWhere(asset => !asset.IsLoadingAssetPreview); } private void OnThumbSizeChanged(ChangeEvent evt) @@ -480,19 +580,19 @@ private void OnThumbSizeChanged(ChangeEvent evt) } } - public void AttachData(AssetResult clickedAsset) + public void AttachData(AssetResult clickedResult) { - if (!_selectedAssets.Contains(clickedAsset)) { - _selectedAssets.Add(clickedAsset); + if (!_selectedResults.Contains(clickedResult)) { + _selectedResults.Add(clickedResult); } - DragAndDrop.objectReferences = _selectedAssets.Select(result => result.Asset.Object).ToArray(); - StartDraggingAssets(_selectedAssets); + DragAndDrop.objectReferences = _selectedResults.Select(result => result.Asset.Object).ToArray(); + StartDraggingAssets(_selectedResults); } } public interface IDragHandler { - void AttachData(AssetResult clickedAsset); + void AttachData(AssetResult clickedResult); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.uss b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.uss index f9ddf6429..d33227f88 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.uss +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.uss @@ -24,9 +24,9 @@ -unity-text-align: middle-center; } -AssetDetailsElement > TemplateContainer { +/*AssetDetails > TemplateContainer { flex-grow: 1; -} +}*/ #libraryList .library-item { margin-left: 8px; @@ -118,3 +118,7 @@ ListView { .unity-two-pane-split-view__dragline-anchor { background-color: #1d1d1d; } + +.unity-toolbar-toggle:checked, .unity-collection-view__item--selected { + background-color: #2C5D87; /* picked from list view */ +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.uxml index f317c4df7..4da9226de 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.uxml +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser.uxml @@ -42,13 +42,15 @@ - + - + + + diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser_Init.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser_Init.cs index 5b10e4faa..1a4ea467e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser_Init.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetBrowser_Init.cs @@ -14,9 +14,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEditor.UIElements; +using UnityEngine; using UnityEngine.UIElements; namespace VisualPinball.Unity.Editor @@ -35,12 +37,14 @@ public partial class AssetBrowser private VisualElement _dragErrorContainerLeft; private Label _dragErrorLabel; private VisualElement _dragErrorContainer; - private AssetDetailsElement _detailsElement; + private AssetDetails _detailsElement; private Label _statusLabel; private Slider _sizeSlider; private VisualTreeAsset _assetTree; + private readonly Dictionary _thumbCache = new(); + public string DragErrorLeft { get => _dragErrorContainerLeft.ClassListContains("hidden") ? null : _dragErrorLabelLeft.text; set { @@ -93,7 +97,7 @@ public void CreateGUI() _categoryView = ui.Q(); _gridContent = ui.Q("gridContent"); - _detailsElement = ui.Q(); + _detailsElement = ui.Q(); _statusLabel = ui.Q