diff --git a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs index 818303b5..66bb44fb 100644 --- a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs +++ b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs @@ -8,18 +8,78 @@ namespace PlateauResoniteLink.Application.Importing; -public enum RawTexturePayloadFormat +public abstract class RawTexturePayload { - Rgba32 = 0, - RgbaFloat32 = 1, + private protected RawTexturePayload( + int width, + int height, + string? colorProfile, + byte[] bytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + ArgumentNullException.ThrowIfNull(bytes); + + Width = width; + Height = height; + ColorProfile = colorProfile; + Bytes = bytes; + } + + public int Width { get; } + + public int Height { get; } + + public string? ColorProfile { get; } + + public byte[] Bytes { get; } + + public abstract TResult Match( + Func rgba32, + Func rgbaFloat32); } -public sealed record RawTexturePayload( - int Width, - int Height, - string? ColorProfile, - byte[] Bytes, - RawTexturePayloadFormat Format = RawTexturePayloadFormat.Rgba32); +public sealed class Rgba32RawTexturePayload : RawTexturePayload +{ + public Rgba32RawTexturePayload( + int width, + int height, + string? colorProfile, + byte[] bytes) + : base(width, height, colorProfile, bytes) + { + } + + public override TResult Match( + Func rgba32, + Func rgbaFloat32) + { + ArgumentNullException.ThrowIfNull(rgba32); + ArgumentNullException.ThrowIfNull(rgbaFloat32); + return rgba32(this); + } +} + +public sealed class RgbaFloat32RawTexturePayload : RawTexturePayload +{ + public RgbaFloat32RawTexturePayload( + int width, + int height, + string? colorProfile, + byte[] bytes) + : base(width, height, colorProfile, bytes) + { + } + + public override TResult Match( + Func rgba32, + Func rgbaFloat32) + { + ArgumentNullException.ThrowIfNull(rgba32); + ArgumentNullException.ThrowIfNull(rgbaFloat32); + return rgbaFloat32(this); + } +} public interface ITextureImportSource { @@ -32,9 +92,14 @@ public interface ITextureImportSource long? EstimatedByteLength { get; } } -internal interface IRawTexturePayloadSource : ITextureImportSource +internal interface IRgba32RawTexturePayloadSource : ITextureImportSource { - ValueTask MaterializeRawAsync(CancellationToken cancellationToken); + ValueTask MaterializeRgba32Async(CancellationToken cancellationToken); +} + +internal interface IRgbaFloat32RawTexturePayloadSource : ITextureImportSource +{ + ValueTask MaterializeRgbaFloat32Async(CancellationToken cancellationToken); } internal static class TextureImportSourceMaterializer @@ -44,17 +109,50 @@ public static ValueTask MaterializeRawAsync( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(source); - if (source is not IRawTexturePayloadSource rawSource) + if (source is IRgba32RawTexturePayloadSource rgba32Source) + { + return new ValueTask(MaterializeRgba32AsRawAsync(rgba32Source, cancellationToken)); + } + + if (source is IRgbaFloat32RawTexturePayloadSource rgbaFloat32Source) + { + return new ValueTask(MaterializeRgbaFloat32AsRawAsync(rgbaFloat32Source, cancellationToken)); + } + + throw new InvalidOperationException( + $"Texture import source '{source.GetType().Name}' cannot materialize a raw texture payload."); + + static async Task MaterializeRgba32AsRawAsync( + IRgba32RawTexturePayloadSource source, + CancellationToken cancellationToken) + { + return await source.MaterializeRgba32Async(cancellationToken); + } + + static async Task MaterializeRgbaFloat32AsRawAsync( + IRgbaFloat32RawTexturePayloadSource source, + CancellationToken cancellationToken) + { + return await source.MaterializeRgbaFloat32Async(cancellationToken); + } + } + + public static ValueTask MaterializeRgba32Async( + ITextureImportSource source, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(source); + if (source is not IRgba32RawTexturePayloadSource rgba32Source) { throw new InvalidOperationException( - $"Texture import source '{source.GetType().Name}' cannot materialize a raw texture payload."); + $"Texture import source '{source.GetType().Name}' cannot materialize an RGBA32 texture payload."); } - return rawSource.MaterializeRawAsync(cancellationToken); + return rgba32Source.MaterializeRgba32Async(cancellationToken); } } -internal sealed class InMemoryRawTextureImportSource : IRawTexturePayloadSource +internal sealed class InMemoryRawTextureImportSource : IRgba32RawTexturePayloadSource { private readonly byte[] bytes; @@ -88,10 +186,10 @@ public InMemoryRawTextureImportSource( public long? EstimatedByteLength => bytes.Length; - public ValueTask MaterializeRawAsync(CancellationToken cancellationToken) + public ValueTask MaterializeRgba32Async(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - return ValueTask.FromResult(new RawTexturePayload( + return ValueTask.FromResult(new Rgba32RawTexturePayload( Width, Height, ColorProfile, @@ -99,7 +197,7 @@ public ValueTask MaterializeRawAsync(CancellationToken cancel } } -internal sealed class InMemoryEncodedTextureImportSource : IRawTexturePayloadSource +internal sealed class InMemoryEncodedTextureImportSource : IRgba32RawTexturePayloadSource { private readonly byte[] bytes; @@ -123,7 +221,7 @@ public InMemoryEncodedTextureImportSource( public long? EstimatedByteLength => bytes.Length; - public async ValueTask MaterializeRawAsync(CancellationToken cancellationToken) + public async ValueTask MaterializeRgba32Async(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); using MemoryStream stream = new(bytes, writable: false); @@ -138,7 +236,7 @@ internal sealed class DatasetTextureImportSource( IPlateauDatasetContentSource datasetSource, string relativePath, string? colorProfile, - string identity) : IRawTexturePayloadSource + string identity) : IRgba32RawTexturePayloadSource { public string Identity { get; } = identity; @@ -150,7 +248,7 @@ internal sealed class DatasetTextureImportSource( ? lengthSource.TryGetFileLength(relativePath) : null; - public async ValueTask MaterializeRawAsync(CancellationToken cancellationToken) + public async ValueTask MaterializeRgba32Async(CancellationToken cancellationToken) { await using Stream stream = await datasetSource.OpenReadAsync(relativePath, cancellationToken); using Image image = await Image.LoadAsync(stream, cancellationToken); @@ -161,7 +259,7 @@ public async ValueTask MaterializeRawAsync(CancellationToken internal sealed class FileTextureImportSource( string absolutePath, string colorProfile, - string identity) : IRawTexturePayloadSource + string identity) : IRgba32RawTexturePayloadSource { public string Identity { get; } = identity; @@ -188,19 +286,40 @@ public long? EstimatedByteLength } } - public async ValueTask MaterializeRawAsync(CancellationToken cancellationToken) + public async ValueTask MaterializeRgba32Async(CancellationToken cancellationToken) { using Image image = await Image.LoadAsync(absolutePath, cancellationToken); return TextureImportSourceFactory.CreateRawPayloadFromImage(image, ColorProfile); } } -internal sealed class GeneratedTextureImportSource( - Func> materializeRawAsync, +internal sealed class GeneratedRgba32TextureImportSource( + Func> materializeRgba32Async, + string identity, + string description, + string? colorProfile, + long? estimatedByteLength = null) : IRgba32RawTexturePayloadSource +{ + public string Identity { get; } = identity; + + public string Description { get; } = description; + + public string? ColorProfile { get; } = colorProfile; + + public long? EstimatedByteLength { get; } = estimatedByteLength; + + public ValueTask MaterializeRgba32Async(CancellationToken cancellationToken) + { + return materializeRgba32Async(cancellationToken); + } +} + +internal sealed class GeneratedRgbaFloat32TextureImportSource( + Func> materializeRgbaFloat32Async, string identity, string description, string? colorProfile, - long? estimatedByteLength = null) : IRawTexturePayloadSource + long? estimatedByteLength = null) : IRgbaFloat32RawTexturePayloadSource { public string Identity { get; } = identity; @@ -210,9 +329,9 @@ internal sealed class GeneratedTextureImportSource( public long? EstimatedByteLength { get; } = estimatedByteLength; - public ValueTask MaterializeRawAsync(CancellationToken cancellationToken) + public ValueTask MaterializeRgbaFloat32Async(CancellationToken cancellationToken) { - return materializeRawAsync(cancellationToken); + return materializeRgbaFloat32Async(cancellationToken); } } @@ -266,15 +385,30 @@ public static ITextureImportSource CreateFileImage( identity ?? $"file:{Path.GetFullPath(absolutePath)}:{colorProfile}"); } - public static ITextureImportSource CreateGeneratedImage( - Func> materializeRawAsync, + public static ITextureImportSource CreateGeneratedRgba32Image( + Func> materializeRgba32Async, + string identity, + string description, + string? colorProfile, + long? estimatedByteLength = null) + { + return new GeneratedRgba32TextureImportSource( + materializeRgba32Async, + identity, + description, + colorProfile, + estimatedByteLength); + } + + public static ITextureImportSource CreateGeneratedRgbaFloat32Image( + Func> materializeRgbaFloat32Async, string identity, string description, string? colorProfile, long? estimatedByteLength = null) { - return new GeneratedTextureImportSource( - materializeRawAsync, + return new GeneratedRgbaFloat32TextureImportSource( + materializeRgbaFloat32Async, identity, description, colorProfile, @@ -290,7 +424,7 @@ public static ITextureImportSource CreateGeneratedImageFromClone( ArgumentNullException.ThrowIfNull(image); Image retainedImage = image.Clone(); object gate = new(); - return CreateGeneratedImage( + return CreateGeneratedRgba32Image( cancellationToken => { cancellationToken.ThrowIfCancellationRequested(); @@ -305,14 +439,14 @@ public static ITextureImportSource CreateGeneratedImageFromClone( (long)image.Width * image.Height * 4); } - public static RawTexturePayload CreateRawPayloadFromImage( + public static Rgba32RawTexturePayload CreateRawPayloadFromImage( Image image, string? colorProfile) { ArgumentNullException.ThrowIfNull(image); byte[] rawBytes = new byte[image.Width * image.Height * 4]; image.CopyPixelDataTo(rawBytes); - return new RawTexturePayload( + return new Rgba32RawTexturePayload( image.Width, image.Height, colorProfile, diff --git a/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/SceneSinkRecordingClientCanonicalDump.cs b/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/SceneSinkRecordingClientCanonicalDump.cs index ce03c46d..1c064dee 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/SceneSinkRecordingClientCanonicalDump.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/SceneSinkRecordingClientCanonicalDump.cs @@ -139,8 +139,7 @@ private static JsonObject CreateImportNode(SceneSinkRecordingClient client) List textureNodes = []; for (int index = 0; index < client.ImportedTexturePayloads.Count; index++) { - RawTexturePayload texture = client.ImportedTexturePayloads[index]; - if (texture.Format != RawTexturePayloadFormat.Rgba32) + if (client.ImportedTexturePayloads[index] is not Rgba32RawTexturePayload texture) { continue; } @@ -164,8 +163,7 @@ private static JsonObject CreateImportNode(SceneSinkRecordingClient client) List hdrTextureNodes = []; for (int index = 0; index < client.ImportedTexturePayloads.Count; index++) { - RawTexturePayload texture = client.ImportedTexturePayloads[index]; - if (texture.Format != RawTexturePayloadFormat.RgbaFloat32) + if (client.ImportedTexturePayloads[index] is not RgbaFloat32RawTexturePayload texture) { continue; } @@ -398,16 +396,16 @@ private static string CreateMeshToken(ImportMeshRawData mesh) private static string CreateTextureToken(RawTexturePayload texture) { - return texture.Format == RawTexturePayloadFormat.RgbaFloat32 - ? string.Create( + return texture.Match( + rgba32 => string.Create( CultureInfo.InvariantCulture, - $"hdr-texture:{texture.Width}x{texture.Height}:{HashBytes(texture.Bytes)}") - : string.Create( + $"texture:{rgba32.Width}x{rgba32.Height}:{rgba32.ColorProfile}:{HashBytes(rgba32.Bytes)}"), + rgbaFloat32 => string.Create( CultureInfo.InvariantCulture, - $"texture:{texture.Width}x{texture.Height}:{texture.ColorProfile}:{HashBytes(texture.Bytes)}"); + $"hdr-texture:{rgbaFloat32.Width}x{rgbaFloat32.Height}:{HashBytes(rgbaFloat32.Bytes)}")); } - private static string CreateHdrTextureToken(RawTexturePayload texture) + private static string CreateHdrTextureToken(RgbaFloat32RawTexturePayload texture) { return string.Create( CultureInfo.InvariantCulture, diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteCityObjectPreparation.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteCityObjectPreparation.cs index f615bce3..ea1fe40d 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteCityObjectPreparation.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteCityObjectPreparation.cs @@ -211,7 +211,7 @@ material.TerrainOverlay is not null public static ITextureImportSource PrepareTerrainGridDisplacementTexture(ResoniteTerrainGridGeometry geometry) { - return TextureImportSourceFactory.CreateGeneratedImage( + return TextureImportSourceFactory.CreateGeneratedRgbaFloat32Image( cancellationToken => { cancellationToken.ThrowIfCancellationRequested(); @@ -223,7 +223,7 @@ public static ITextureImportSource PrepareTerrainGridDisplacementTexture(Resonit estimatedByteLength: checked((long)geometry.Width * geometry.Height * 4L * sizeof(float))); } - private static RawTexturePayload CreateTerrainGridDisplacementPayload(ResoniteTerrainGridGeometry geometry) + private static RgbaFloat32RawTexturePayload CreateTerrainGridDisplacementPayload(ResoniteTerrainGridGeometry geometry) { float[] rawPixels = new float[geometry.Width * geometry.Height * 4]; double heightRange = Math.Max(geometry.MaxHeight - geometry.MinHeight, 0.0); @@ -249,12 +249,11 @@ private static RawTexturePayload CreateTerrainGridDisplacementPayload(ResoniteTe byte[] rawBytes = new byte[rawPixels.Length * sizeof(float)]; Buffer.BlockCopy(rawPixels, 0, rawBytes, 0, rawBytes.Length); - return new RawTexturePayload( + return new RgbaFloat32RawTexturePayload( geometry.Width, geometry.Height, - ColorProfile: null, - rawBytes, - RawTexturePayloadFormat.RgbaFloat32); + colorProfile: null, + rawBytes); } public static string CreateTriangleMeshDiagnosticSummary( diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImageLoader.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImageLoader.cs index b4676a59..6ac92a93 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImageLoader.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImageLoader.cs @@ -25,14 +25,9 @@ private static async Task> LoadCoreAsync( ITextureImportSource textureSource, CancellationToken cancellationToken) { - RawTexturePayload rawPayload = await TextureImportSourceMaterializer.MaterializeRawAsync( + Rgba32RawTexturePayload rawPayload = await TextureImportSourceMaterializer.MaterializeRgba32Async( textureSource, cancellationToken); - if (rawPayload.Format != RawTexturePayloadFormat.Rgba32) - { - throw new InvalidOperationException( - $"Unsupported texture payload format '{rawPayload.Format}' for image loading."); - } return Image.LoadPixelData( rawPayload.Bytes, diff --git a/src/PlateauResoniteLink/Transport/ResoniteLink/ResoniteLinkClient.cs b/src/PlateauResoniteLink/Transport/ResoniteLink/ResoniteLinkClient.cs index 59e3fdd0..6b3b7a34 100644 --- a/src/PlateauResoniteLink/Transport/ResoniteLink/ResoniteLinkClient.cs +++ b/src/PlateauResoniteLink/Transport/ResoniteLink/ResoniteLinkClient.cs @@ -164,12 +164,9 @@ public async Task ImportTextureAsync(ITextureImportSource textureSource, Ca RawTexturePayload rawPayload = await TextureImportSourceMaterializer.MaterializeRawAsync( textureSource, cancellationToken); - AssetData result = rawPayload.Format switch - { - RawTexturePayloadFormat.Rgba32 => await ImportRawTextureAsync(rawPayload, cancellationToken), - RawTexturePayloadFormat.RgbaFloat32 => await ImportRawHdrTextureAsync(rawPayload, cancellationToken), - _ => throw new InvalidOperationException($"Unsupported texture payload format '{rawPayload.Format}'."), - }; + AssetData result = await rawPayload.Match( + rgba32 => ImportRawTextureAsync(rgba32, cancellationToken), + rgbaFloat32 => ImportRawHdrTextureAsync(rgbaFloat32, cancellationToken)); EnsureSuccess(result, "import texture"); return result.AssetURL ?? throw new InvalidOperationException("ResoniteLink returned a null texture asset URL."); @@ -253,7 +250,7 @@ public async Task AddSlotAsync(AddSlot requ "component"); } - private Task ImportRawTextureAsync(RawTexturePayload rawPayload, CancellationToken cancellationToken) + private Task ImportRawTextureAsync(Rgba32RawTexturePayload rawPayload, CancellationToken cancellationToken) { return ExecuteSerializedAsync( "import_texture", @@ -268,7 +265,7 @@ private Task ImportRawTextureAsync(RawTexturePayload rawPayload, Canc cancellationToken); } - private Task ImportRawHdrTextureAsync(RawTexturePayload rawPayload, CancellationToken cancellationToken) + private Task ImportRawHdrTextureAsync(RgbaFloat32RawTexturePayload rawPayload, CancellationToken cancellationToken) { return ExecuteSerializedAsync( "import_texture", diff --git a/tests/PlateauResoniteLink.Tests/Application/TextureImportSourceFactoryTests.cs b/tests/PlateauResoniteLink.Tests/Application/TextureImportSourceFactoryTests.cs index c77e970e..dcdc270d 100644 --- a/tests/PlateauResoniteLink.Tests/Application/TextureImportSourceFactoryTests.cs +++ b/tests/PlateauResoniteLink.Tests/Application/TextureImportSourceFactoryTests.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -25,6 +26,7 @@ public async Task CreateInMemoryRawRgbaRequiresDimensionsAtConstruction() source, CancellationToken.None); + Assert.IsType(payload); Assert.Equal(1, payload.Width); Assert.Equal(1, payload.Height); Assert.Equal([255, 255, 255, 255], payload.Bytes); @@ -48,9 +50,36 @@ public async Task CreateInMemoryEncodedImageOwnsDimensionsAfterDecode() source, CancellationToken.None); + Assert.IsType(payload); Assert.Equal(1, payload.Width); Assert.Equal(1, payload.Height); Assert.Equal([1, 2, 3, 255], payload.Bytes); } + [Fact] + public async Task CreateGeneratedRgbaFloat32ImageDoesNotMaterializeAsRgba32() + { + ITextureImportSource source = TextureImportSourceFactory.CreateGeneratedRgbaFloat32Image( + _ => ValueTask.FromResult(new RgbaFloat32RawTexturePayload( + width: 1, + height: 1, + colorProfile: null, + bytes: new byte[16])), + identity: "hdr", + description: "hdr", + colorProfile: null, + estimatedByteLength: 16); + + RawTexturePayload rawPayload = await TextureImportSourceMaterializer.MaterializeRawAsync( + source, + CancellationToken.None); + InvalidOperationException exception = await Assert.ThrowsAsync( + async () => await TextureImportSourceMaterializer.MaterializeRgba32Async( + source, + CancellationToken.None)); + + Assert.IsType(rawPayload); + Assert.Contains("cannot materialize an RGBA32 texture payload", exception.Message, System.StringComparison.Ordinal); + } + } diff --git a/tests/PlateauResoniteLink.Tests/TextureImportSourceTestFactory.cs b/tests/PlateauResoniteLink.Tests/TextureImportSourceTestFactory.cs index 62bc5c47..a521c9ae 100644 --- a/tests/PlateauResoniteLink.Tests/TextureImportSourceTestFactory.cs +++ b/tests/PlateauResoniteLink.Tests/TextureImportSourceTestFactory.cs @@ -24,25 +24,24 @@ public static ITextureImportSource CreateRawTextureSource( identity ?? $"test-rgba32:{width}:{height}:{Guid.NewGuid():N}"); } - public static IReadOnlyList ImportedRgba32Textures(SceneSinkRecordingClient client) + public static IReadOnlyList ImportedRgba32Textures(SceneSinkRecordingClient client) { return client.ImportedTexturePayloads - .Where(static payload => payload.Format == RawTexturePayloadFormat.Rgba32) + .OfType() .ToArray(); } - public static IReadOnlyList ImportedHdrTextures(SceneSinkRecordingClient client) + public static IReadOnlyList ImportedHdrTextures(SceneSinkRecordingClient client) { return client.ImportedTexturePayloads - .Where(static payload => payload.Format == RawTexturePayloadFormat.RgbaFloat32) + .OfType() .ToArray(); } - public static bool IsSolidColorTexture(RawTexturePayload texture, byte r, byte g, byte b) + public static bool IsSolidColorTexture(Rgba32RawTexturePayload texture, byte r, byte g, byte b) { byte[] expectedPixel = [r, g, b, 255]; - return texture.Format == RawTexturePayloadFormat.Rgba32 - && texture.Width == 2 + return texture.Width == 2 && texture.Height == 2 && texture.Bytes.Chunk(4).All(pixel => pixel.SequenceEqual(expectedPixel)); } diff --git a/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs b/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs index 2e8a057b..c2a3418f 100644 --- a/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs +++ b/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs @@ -378,7 +378,7 @@ public void Dispose() } } - private sealed class InstrumentedTextureImportSource : IRawTexturePayloadSource + private sealed class InstrumentedTextureImportSource : IRgba32RawTexturePayloadSource { public int MaterializeCallCount { get; private set; } @@ -390,11 +390,11 @@ private sealed class InstrumentedTextureImportSource : IRawTexturePayloadSource public long? EstimatedByteLength => 4; - public ValueTask MaterializeRawAsync(CancellationToken cancellationToken) + public ValueTask MaterializeRgba32Async(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); MaterializeCallCount++; - return ValueTask.FromResult(new RawTexturePayload( + return ValueTask.FromResult(new Rgba32RawTexturePayload( 1, 1, ResoniteTextureColorProfiles.Srgb,