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
38 changes: 29 additions & 9 deletions LibreMetaverse.Tests/GLTFMaterialUploadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@
*/

using System;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using LibreMetaverse.Assets;
using LibreMetaverse.StructuredData;
using LibreMetaverse.Tests.TestHelpers;
using NUnit.Framework;

Expand All @@ -38,9 +41,9 @@ namespace LibreMetaverse.Tests
/// Verified against the reference viewer (LLMaterialEditor::updateInventoryItem in
/// llmaterialeditor.cpp): a two-phase upload using the same LLBufferedAssetUploadInfo shape as
/// UpdateNotecardAgentInventory/UpdateNotecardTaskInventory -- POST {"item_id"} (plus "task_id"
/// for the task variant) to the capability, which returns an "uploader" URL that the material's
/// GLTF JSON is then POSTed to; a final "state":"complete" response carries the new asset UUID
/// under "new_asset".
/// for the task variant) to the capability, which returns an "uploader" URL. The second POST
/// sends a binary LLSD map containing version, type, and the material JSON in data; a final
/// "state":"complete" response carries the new asset UUID under "new_asset".
/// </summary>
[TestFixture]
public class GLTFMaterialUploadTests
Expand All @@ -66,7 +69,7 @@ public void TearDown()
}

[Test]
public async Task RequestUpdateMaterialAgentInventoryAsync_HappyPath_PostsMetadataThenJsonAndReturnsNewAsset()
public async Task RequestUpdateMaterialAgentInventoryAsync_HappyPath_PostsWrappedAssetAndReturnsNewAsset()
{
var itemId = UUID.Random();
var newAsset = UUID.Random();
Expand Down Expand Up @@ -129,7 +132,7 @@ public void RequestUpdateMaterialAgentInventoryAsync_NoCapability_Throws()
}

[Test]
public async Task RequestUpdateMaterialAgentInventoryAsync_UploadedJsonRoundTripsMaterial()
public async Task RequestUpdateMaterialAgentInventoryAsync_UploadedAssetIsBinaryLlsdWrappedGltf()
{
var itemId = UUID.Random();
var newAsset = UUID.Random();
Expand All @@ -139,15 +142,32 @@ public async Task RequestUpdateMaterialAgentInventoryAsync_UploadedJsonRoundTrip
_client.AddHttpResponse(new Uri(UploaderUrl), HttpStatusCode.OK,
$"{{\"state\":\"complete\",\"new_asset\":\"{newAsset}\"}}", "application/json");

var material = new AssetMaterial { Name = "My Material" };
var material = new AssetMaterial { Name = "My Material \u0394" };
material.SetBaseColorFactor(new Color4(0.2f, 0.4f, 0.6f, 1f));
var expectedJson = material.ToJson();
await _client.Inventory.RequestUpdateMaterialAgentInventoryAsync(material, itemId);

var uploadedJson = _client.CapturedRequests[1].Body;
var uploadedAsset = _client.CapturedRequestBodies[1];
var binaryHeader = Encoding.ASCII.GetBytes("<?llsd/binary?>\n");
Assert.That(uploadedAsset.Take(binaryHeader.Length), Is.EqualTo(binaryHeader));
Assert.That(Encoding.ASCII.GetString(uploadedAsset).IndexOf(
"<?llsd/binary?>", binaryHeader.Length, StringComparison.Ordinal), Is.EqualTo(-1));

var asset = OSDParser.DeserializeLLSDBinary(uploadedAsset);
Assert.That(asset, Is.TypeOf<OSDMap>());
var assetMap = (OSDMap)asset;
Assert.That(assetMap.Count, Is.EqualTo(3));
Assert.That(assetMap["version"].Type, Is.EqualTo(OSDType.String));
Assert.That(assetMap["version"].AsString(), Is.EqualTo("1.1"));
Assert.That(assetMap["type"].Type, Is.EqualTo(OSDType.String));
Assert.That(assetMap["type"].AsString(), Is.EqualTo("GLTF 2.0"));
Assert.That(assetMap["data"].Type, Is.EqualTo(OSDType.String));
Assert.That(assetMap["data"].AsString(), Is.EqualTo(expectedJson));

var roundTripped = new AssetMaterial(UUID.Random(),
System.Text.Encoding.UTF8.GetBytes(uploadedJson));
Encoding.UTF8.GetBytes(assetMap["data"].AsString()));

Assert.That(roundTripped.Name, Is.EqualTo("My Material"));
Assert.That(roundTripped.Name, Is.EqualTo("My Material \u0394"));
Assert.That(roundTripped.BaseColorFactor, Is.EqualTo(material.BaseColorFactor));
}
}
Expand Down
18 changes: 18 additions & 0 deletions LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ internal class FakeHttpMessageHandler : HttpMessageHandler
/// <summary>All requests received, in order.</summary>
public List<(HttpMethod Method, Uri Uri, string Body)> CapturedRequests { get; } = new List<(HttpMethod, Uri, string)>();

/// <summary>Raw request bodies received, in the same order as <see cref="CapturedRequests"/>.</summary>
public List<byte[]> CapturedRequestBodies { get; } = new List<byte[]>();

/// <summary>Register a response matched by exact URI string.</summary>
public void AddResponse(Uri uri, HttpStatusCode status, string content, string mediaType = "application/json")
{
Expand All @@ -34,11 +37,23 @@ public void AddResponseForPath(string path, HttpStatusCode status, string conten
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var body = string.Empty;
var bodyBytes = Array.Empty<byte>();
if (request?.Content != null)
{
#if NET5_0_OR_GREATER
body = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
bodyBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
#else
body = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
bodyBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
#endif
}

if (request?.RequestUri != null)
{
CapturedRequests.Add((request.Method, request.RequestUri, body));
CapturedRequestBodies.Add(bodyBytes);
}

// Exact match
if (request?.RequestUri != null && _responses.TryGetValue(request.RequestUri.ToString(), out var entry))
Expand Down Expand Up @@ -103,6 +118,9 @@ public void AddHttpResponseForPath(string path, HttpStatusCode status, string co
/// <summary>All HTTP requests received by this client, in order.</summary>
public List<(HttpMethod Method, Uri Uri, string Body)> CapturedRequests => _fakeHandler.CapturedRequests;

/// <summary>Raw HTTP request bodies received, in the same order as <see cref="CapturedRequests"/>.</summary>
public List<byte[]> CapturedRequestBodies => _fakeHandler.CapturedRequestBodies;

/// <summary>
/// Injects an arbitrary named capability URI into CurrentSim's Caps, creating a fake
/// CurrentSim/Caps pair first if none exists yet. Use for capabilities not covered by
Expand Down
28 changes: 19 additions & 9 deletions LibreMetaverse/Inventory/InventoryManager.Async.cs
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,10 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol
/// Saves a GLTF material to an existing agent inventory item via the
/// UpdateMaterialAgentInventory capability. Mirrors
/// LLMaterialEditor::updateInventoryItem's agent-inventory branch (llmaterialeditor.cpp): a
/// two-phase upload where the metadata POST (item_id) returns an "uploader" URL that the
/// material's minified GLTF JSON is then POSTed to.
/// two-phase upload where the metadata POST (item_id) returns an "uploader" URL. The second
/// POST sends a binary LLSD map containing version, type, and the material JSON in data.
/// </summary>
/// <param name="material">The material to save; its JSON encoding (<see cref="AssetMaterial.ToJson"/>)
/// is what gets uploaded</param>
/// <param name="material">The material to save</param>
/// <param name="materialItemID">UUID of the existing inventory item to update</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="progress">Optional upload progress reporter</param>
Expand All @@ -319,7 +318,7 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol
if (cap == null)
throw new InvalidOperationException("UpdateMaterialAgentInventory capability is not currently available");

var data = System.Text.Encoding.UTF8.GetBytes(material.ToJson());
var data = EncodeMaterialAsset(material);
var query = new OSDMap { { "item_id", OSD.FromUUID(materialItemID) } };
try
{
Expand All @@ -333,10 +332,10 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol
/// <summary>
/// Saves a GLTF material to an existing task (object) inventory item via the
/// UpdateMaterialTaskInventory capability. Mirrors
/// LLMaterialEditor::updateInventoryItem's task-inventory branch (llmaterialeditor.cpp).
/// LLMaterialEditor::updateInventoryItem's task-inventory branch (llmaterialeditor.cpp) and
/// uses the same two-phase binary LLSD material envelope as the agent-inventory path.
/// </summary>
/// <param name="material">The material to save; its JSON encoding (<see cref="AssetMaterial.ToJson"/>)
/// is what gets uploaded</param>
/// <param name="material">The material to save</param>
/// <param name="materialItemID">UUID of the existing task-inventory item to update</param>
/// <param name="taskID">UUID of the object (task) containing the item</param>
/// <param name="cancellationToken">Cancellation token</param>
Expand All @@ -350,7 +349,7 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol
if (cap == null)
throw new InvalidOperationException("UpdateMaterialTaskInventory capability is not currently available");

var data = System.Text.Encoding.UTF8.GetBytes(material.ToJson());
var data = EncodeMaterialAsset(material);
var query = new OSDMap { { "item_id", OSD.FromUUID(materialItemID) }, { "task_id", OSD.FromUUID(taskID) } };
try
{
Expand All @@ -361,6 +360,17 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol
catch (Exception ex) { return (false, ex.Message, UUID.Zero, UUID.Zero); }
}

private static byte[] EncodeMaterialAsset(AssetMaterial material)
{
var asset = new OSDMap
{
["version"] = OSD.FromString("1.1"),
["type"] = OSD.FromString("GLTF 2.0"),
["data"] = OSD.FromString(material.ToJson())
};
return OSDParser.SerializeLLSDBinary(asset);
}

public async Task<(bool uploadSuccess, string uploadStatus, bool compileSuccess, List<string>? compileMessages, UUID itemID, UUID assetID)> RequestUpdateScriptAgentInventoryAsync(
byte[] data, UUID itemID, bool mono, CancellationToken cancellationToken = default, IProgress<ProgressReport>? progress = null)
{
Expand Down
Loading