diff --git a/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs b/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs index 402c4bd6..dc76fc9d 100644 --- a/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs +++ b/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs @@ -189,16 +189,14 @@ private void ApplyGeoreferencedTexture(XElement textureElement) if (!texturePayloadsByResolvedPath.TryGetValue(resolvedTexturePath, out TexturePayload? texturePayload)) { texturePayload = new TexturePayload( - null, - null, - "sRGB", - TextureImportSourceFactory.CreateDatasetEncodedImage( + width: null, + height: null, + colorProfile: "sRGB", + source: TextureImportSourceFactory.CreateDatasetEncodedImage( datasetSource, resolvedTexturePath, "sRGB", - $"dataset:{resolvedTexturePath}"), - $"dataset:{resolvedTexturePath}", - TexturePayloadFormat.EncodedImage); + $"dataset:{resolvedTexturePath}")); texturePayloadsByResolvedPath[resolvedTexturePath] = texturePayload; } diff --git a/src/PlateauResoniteLink/Application/Importing/MaterialGroupingPolicy.cs b/src/PlateauResoniteLink/Application/Importing/MaterialGroupingPolicy.cs index 1f189310..3f55a36e 100644 --- a/src/PlateauResoniteLink/Application/Importing/MaterialGroupingPolicy.cs +++ b/src/PlateauResoniteLink/Application/Importing/MaterialGroupingPolicy.cs @@ -18,7 +18,7 @@ internal static MaterialGroupingKey CreateKey( { return new MaterialGroupingKey( material.MaterialType, - TexturePayloadIdentity: null, + TextureSourceIdentity: null, material.TextureSourceKind, material.Projection, depthOffset, @@ -33,7 +33,7 @@ internal static MaterialGroupingKey CreateKey( return new MaterialGroupingKey( material.MaterialType, - material.TexturePayload?.Identity, + material.TexturePayload?.Source.Identity, material.TextureSourceKind, material.Projection, depthOffset, @@ -63,7 +63,7 @@ private static bool IsIdentityTextureScale(Float2? textureScale) internal sealed record MaterialGroupingKey( MaterialType MaterialType, - string? TexturePayloadIdentity, + TextureImportSourceIdentity? TextureSourceIdentity, TextureSourceKind TextureSourceKind, MaterialProjection Projection, MaterialDepthOffset? DepthOffset, diff --git a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs index 992999d7..5331f718 100644 --- a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs +++ b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs @@ -105,60 +105,73 @@ public enum TexturePayloadFormat public sealed record TexturePayload { public TexturePayload( - int? width, - int? height, + int width, + int height, string? colorProfile, byte[] binaryPayload, - string? identity = null, - TexturePayloadFormat format = TexturePayloadFormat.RawRgba32) + string? identity = null) { + ArgumentNullException.ThrowIfNull(binaryPayload); + RawTexturePayload.EnsureValidShape(width, height, binaryPayload.Length, RawTexturePayloadFormat.Rgba32); + ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); + TextureImportSourceIdentity effectiveIdentity = new(identity ?? Guid.NewGuid().ToString("N")); Width = width; Height = height; ColorProfile = colorProfile; - ArgumentNullException.ThrowIfNull(binaryPayload); - BinaryPayload = ImmutableArray.CreateRange(binaryPayload); - Identity = identity; - Format = format; - Source = TextureImportSourceFactory.CreateInMemory( + BinaryPayload = immutablePayload; + Format = TexturePayloadFormat.RawRgba32; + Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, - binaryPayload, - identity ?? Guid.NewGuid().ToString("N"), - format); + immutablePayload, + effectiveIdentity.Value); + } + + internal TexturePayload( + int width, + int height, + string? colorProfile, + IRawTexturePayloadSource source) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Identity.Value, nameof(source)); + RawTexturePayload.EnsureValidDimensions(width, height); + Width = width; + Height = height; + ColorProfile = colorProfile; + BinaryPayload = []; + Format = TexturePayloadFormat.RawRgba32; + Source = source; } public TexturePayload( int? width, int? height, string? colorProfile, - ITextureImportSource source, - string? identity = null, - TexturePayloadFormat format = TexturePayloadFormat.EncodedImage) + ITextureImportSource source) { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Identity.Value, nameof(source)); Width = width; Height = height; ColorProfile = colorProfile; - ArgumentNullException.ThrowIfNull(source); BinaryPayload = []; - Identity = identity ?? source.Identity; - Format = format; + Format = TexturePayloadFormat.EncodedImage; Source = source; } - public int? Width { get; init; } - - public int? Height { get; init; } + public int? Width { get; } - public string? ColorProfile { get; init; } + public int? Height { get; } - public ImmutableArray BinaryPayload { get; init; } + public string? ColorProfile { get; } - public string? Identity { get; init; } + public ImmutableArray BinaryPayload { get; } - public TexturePayloadFormat Format { get; init; } + public TexturePayloadFormat Format { get; } - public ITextureImportSource Source { get; init; } + public ITextureImportSource Source { get; } } public enum TextureSourceKind diff --git a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs index 6f90ff27..b484817e 100644 --- a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs +++ b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Immutable; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -14,16 +16,104 @@ public enum RawTexturePayloadFormat RgbaFloat32 = 1, } -public sealed record RawTexturePayload( - int Width, - int Height, - string? ColorProfile, - byte[] Bytes, - RawTexturePayloadFormat Format = RawTexturePayloadFormat.Rgba32); +public sealed record RawTexturePayload +{ + private RawTexturePayload( + int Width, + int Height, + string? ColorProfile, + ImmutableArray Bytes, + RawTexturePayloadFormat Format = RawTexturePayloadFormat.Rgba32) + { + if (Bytes.IsDefault) + { + throw new ArgumentException("Raw texture bytes must be initialized.", nameof(Bytes)); + } + + EnsureValidShape(Width, Height, Bytes.Length, Format); + this.Width = Width; + this.Height = Height; + this.ColorProfile = ColorProfile; + this.Bytes = Bytes; + this.Format = Format; + } + + internal static RawTexturePayload Create( + int width, + int height, + string? colorProfile, + ImmutableArray bytes, + RawTexturePayloadFormat format = RawTexturePayloadFormat.Rgba32) + { + return new RawTexturePayload(width, height, colorProfile, bytes, format); + } + + public RawTexturePayload( + int Width, + int Height, + string? ColorProfile, + byte[] Bytes, + RawTexturePayloadFormat Format = RawTexturePayloadFormat.Rgba32) + { + ArgumentNullException.ThrowIfNull(Bytes); + EnsureValidShape(Width, Height, Bytes.Length, Format); + this.Width = Width; + this.Height = Height; + this.ColorProfile = ColorProfile; + this.Bytes = ImmutableArray.CreateRange(Bytes); + this.Format = Format; + } + + public int Width { get; } + + public int Height { get; } + + public string? ColorProfile { get; } + + public ImmutableArray Bytes { get; } + + public RawTexturePayloadFormat Format { get; } + + internal static void EnsureValidShape( + int width, + int height, + int byteLength, + RawTexturePayloadFormat format) + { + EnsureValidDimensions(width, height); + + int bytesPerPixel = format switch + { + RawTexturePayloadFormat.Rgba32 => 4, + RawTexturePayloadFormat.RgbaFloat32 => 4 * sizeof(float), + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported raw texture payload format."), + }; + long expectedByteLength = checked((long)width * height * bytesPerPixel); + if (byteLength != expectedByteLength) + { + throw new ArgumentException( + $"Raw texture byte length must be width * height * {bytesPerPixel}.", + nameof(byteLength)); + } + } + + internal static void EnsureValidDimensions(int width, int height) + { + if (width <= 0) + { + throw new ArgumentOutOfRangeException(nameof(width), width, "Raw texture width must be positive."); + } + + if (height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(height), height, "Raw texture height must be positive."); + } + } +} public interface ITextureImportSource { - string Identity { get; } + TextureImportSourceIdentity Identity { get; } string Description { get; } @@ -54,33 +144,53 @@ public static ValueTask MaterializeRawAsync( } } -internal sealed class InMemoryTextureImportSource : IRawTexturePayloadSource +internal sealed class InMemoryRawTextureImportSource : IRawTexturePayloadSource { - private readonly byte[] bytes; + private readonly ImmutableArray bytes; - public InMemoryTextureImportSource( - int? width, - int? height, + public InMemoryRawTextureImportSource( + int width, + int height, string? colorProfile, byte[] bytes, - string identity, - TexturePayloadFormat sourceFormat) + string identity) { ArgumentNullException.ThrowIfNull(bytes); - ArgumentException.ThrowIfNullOrWhiteSpace(identity); + TextureImportSourceIdentity resolvedIdentity = new(identity); + RawTexturePayload.EnsureValidShape(width, height, bytes.Length, RawTexturePayloadFormat.Rgba32); Width = width; Height = height; ColorProfile = colorProfile; - this.bytes = (byte[])bytes.Clone(); - Identity = identity; - SourceFormat = sourceFormat; + this.bytes = ImmutableArray.CreateRange(bytes); + Identity = resolvedIdentity; } - public int? Width { get; } + public InMemoryRawTextureImportSource( + int width, + int height, + string? colorProfile, + ImmutableArray bytes, + string identity) + { + if (bytes.IsDefault) + { + throw new ArgumentException("Raw texture bytes must be initialized.", nameof(bytes)); + } - public int? Height { get; } + TextureImportSourceIdentity resolvedIdentity = new(identity); + RawTexturePayload.EnsureValidShape(width, height, bytes.Length, RawTexturePayloadFormat.Rgba32); + Width = width; + Height = height; + ColorProfile = colorProfile; + this.bytes = bytes; + Identity = resolvedIdentity; + } + + public int Width { get; } - public string Identity { get; } + public int Height { get; } + + public TextureImportSourceIdentity Identity { get; } public string Description => $"memory:{Identity}"; @@ -88,36 +198,45 @@ public InMemoryTextureImportSource( public long? EstimatedByteLength => bytes.Length; - public TexturePayloadFormat SourceFormat { get; } - - public async ValueTask MaterializeRawAsync(CancellationToken cancellationToken) + public ValueTask MaterializeRawAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - return SourceFormat switch - { - TexturePayloadFormat.RawRgba32 => CreateRawPayload(), - TexturePayloadFormat.EncodedImage => await DecodeEncodedImageAsync(cancellationToken), - _ => throw new InvalidOperationException($"Unsupported texture payload format '{SourceFormat}'."), - }; + return ValueTask.FromResult(RawTexturePayload.Create( + Width, + Height, + ColorProfile, + bytes)); } +} - private RawTexturePayload CreateRawPayload() - { - if (Width is null || Height is null) - { - throw new InvalidOperationException("Raw RGBA texture payload must include width and height."); - } +internal sealed class InMemoryEncodedTextureImportSource : IRawTexturePayloadSource +{ + private readonly ImmutableArray bytes; - return new RawTexturePayload( - Width.Value, - Height.Value, - ColorProfile, - (byte[])bytes.Clone()); + public InMemoryEncodedTextureImportSource( + string? colorProfile, + byte[] bytes, + string identity) + { + ArgumentNullException.ThrowIfNull(bytes); + TextureImportSourceIdentity resolvedIdentity = new(identity); + ColorProfile = colorProfile; + this.bytes = ImmutableArray.CreateRange(bytes); + Identity = resolvedIdentity; } - private async ValueTask DecodeEncodedImageAsync(CancellationToken cancellationToken) + public TextureImportSourceIdentity Identity { get; } + + public string Description => $"memory:{Identity}"; + + public string? ColorProfile { get; } + + public long? EstimatedByteLength => bytes.Length; + + public async ValueTask MaterializeRawAsync(CancellationToken cancellationToken) { - using MemoryStream stream = new(bytes, writable: false); + byte[] retainedBytes = ImmutableCollectionsMarshal.AsArray(bytes) ?? []; + using MemoryStream stream = new(retainedBytes, writable: false); using Image image = await Image.LoadAsync(stream, cancellationToken); return TextureImportSourceFactory.CreateRawPayloadFromImage( image, @@ -131,7 +250,7 @@ internal sealed class DatasetTextureImportSource( string? colorProfile, string identity) : IRawTexturePayloadSource { - public string Identity { get; } = identity; + public TextureImportSourceIdentity Identity { get; } = new(identity); public string Description => $"dataset:{relativePath}"; @@ -154,7 +273,7 @@ internal sealed class FileTextureImportSource( string colorProfile, string identity) : IRawTexturePayloadSource { - public string Identity { get; } = identity; + public TextureImportSourceIdentity Identity { get; } = new(identity); public string Description => $"file:{Path.GetFileName(absolutePath)}"; @@ -193,7 +312,7 @@ internal sealed class GeneratedTextureImportSource( string? colorProfile, long? estimatedByteLength = null) : IRawTexturePayloadSource { - public string Identity { get; } = identity; + public TextureImportSourceIdentity Identity { get; } = new(identity); public string Description { get; } = description; @@ -209,15 +328,32 @@ public ValueTask MaterializeRawAsync(CancellationToken cancel internal static class TextureImportSourceFactory { - public static ITextureImportSource CreateInMemory( - int? width, - int? height, + public static ITextureImportSource CreateRawRgba32InMemory( + int width, + int height, string? colorProfile, byte[] bytes, - string identity, - TexturePayloadFormat sourceFormat) + string identity) + { + return new InMemoryRawTextureImportSource(width, height, colorProfile, bytes, identity); + } + + internal static ITextureImportSource CreateRawRgba32InMemory( + int width, + int height, + string? colorProfile, + ImmutableArray bytes, + string identity) + { + return new InMemoryRawTextureImportSource(width, height, colorProfile, bytes, identity); + } + + public static ITextureImportSource CreateEncodedImageInMemory( + string? colorProfile, + byte[] bytes, + string identity) { - return new InMemoryTextureImportSource(width, height, colorProfile, bytes, identity, sourceFormat); + return new InMemoryEncodedTextureImportSource(colorProfile, bytes, identity); } public static ITextureImportSource CreateDatasetEncodedImage( diff --git a/src/PlateauResoniteLink/Application/Importing/TextureImportSourceIdentity.cs b/src/PlateauResoniteLink/Application/Importing/TextureImportSourceIdentity.cs new file mode 100644 index 00000000..b58f0b28 --- /dev/null +++ b/src/PlateauResoniteLink/Application/Importing/TextureImportSourceIdentity.cs @@ -0,0 +1,16 @@ +using System; + +namespace PlateauResoniteLink.Application.Importing; + +public readonly record struct TextureImportSourceIdentity +{ + public TextureImportSourceIdentity(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + Value = value; + } + + public string Value { get; } + + public override string ToString() => Value; +} diff --git a/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/CanonicalSceneDumpSink.cs b/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/CanonicalSceneDumpSink.cs index a7f99bc2..2a8d2551 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/CanonicalSceneDumpSink.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/CanonicalSceneDumpSink.cs @@ -153,13 +153,12 @@ public Task EnsureTextureAsync( cancellationToken.ThrowIfCancellationRequested(); TerrainTextureSource? usedSource = terrainTextureOverlay.GetRequiredPrimaryTileSource(); return Task.FromResult(new GeneratedTerrainTexture( - TextureImportSourceFactory.CreateInMemory( + TextureImportSourceFactory.CreateRawRgba32InMemory( 2, 2, ResoniteTextureColorProfiles.Srgb, RawTextureBytes, - "canonical-dump-terrain-texture", - TexturePayloadFormat.RawRgba32), + "canonical-dump-terrain-texture"), new ResoniteFloat2(1.0, 1.0), new ResoniteFloat2(0.0, 0.0), usedSource)); diff --git a/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/SceneSinkRecordingClientCanonicalDump.cs b/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/SceneSinkRecordingClientCanonicalDump.cs index ce03c46d..474b707d 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/SceneSinkRecordingClientCanonicalDump.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/Diagnostics/SceneSinkRecordingClientCanonicalDump.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Reflection; @@ -424,6 +425,13 @@ private static string HashBytes(byte[]? bytes) return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); } + private static string HashBytes(ImmutableArray bytes) + { + return bytes.IsDefaultOrEmpty + ? "empty" + : Convert.ToHexString(SHA256.HashData(bytes.AsSpan())).ToLowerInvariant(); + } + private static string FormatNumber(double value) { return value.ToString("G17", CultureInfo.InvariantCulture); diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImageLoader.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImageLoader.cs index b4676a59..f3b3f06c 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImageLoader.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImageLoader.cs @@ -35,7 +35,7 @@ private static async Task> LoadCoreAsync( } return Image.LoadPixelData( - rawPayload.Bytes, + rawPayload.Bytes.AsSpan().ToArray(), rawPayload.Width, rawPayload.Height); } diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs index fae3b488..6a2495eb 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs @@ -34,12 +34,11 @@ public static ResoniteTexturePayload CreatePayloadFromImage( RawTexturePayload rawPayload = TextureImportSourceFactory.CreateRawPayloadFromImage( image, colorProfile); - return new ResoniteTexturePayload( + return ResoniteTexturePayload.CreateRaw( image.Width, image.Height, colorProfile, rawPayload.Bytes, - identity ?? Guid.NewGuid().ToString("N"), - ResoniteTexturePayloadFormat.RawRgba32); + identity ?? Guid.NewGuid().ToString("N")); } } diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs index b7d0a11b..a613cb2c 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs @@ -13,59 +13,109 @@ public enum ResoniteTexturePayloadFormat public sealed record ResoniteTexturePayload { + private ResoniteTexturePayload( + int width, + int height, + string? colorProfile, + ImmutableArray binaryPayload, + string? identity = null) + { + if (binaryPayload.IsDefault) + { + throw new ArgumentException("Raw texture bytes must be initialized.", nameof(binaryPayload)); + } + + RawTexturePayload.EnsureValidShape(width, height, binaryPayload.Length, RawTexturePayloadFormat.Rgba32); + TextureImportSourceIdentity effectiveIdentity = new(identity ?? Guid.NewGuid().ToString("N")); + Width = width; + Height = height; + ColorProfile = colorProfile; + BinaryPayload = binaryPayload; + Format = ResoniteTexturePayloadFormat.RawRgba32; + Source = TextureImportSourceFactory.CreateRawRgba32InMemory( + width, + height, + colorProfile, + binaryPayload, + effectiveIdentity.Value); + } + + internal static ResoniteTexturePayload CreateRaw( + int width, + int height, + string? colorProfile, + ImmutableArray binaryPayload, + string? identity = null) + { + return new ResoniteTexturePayload(width, height, colorProfile, binaryPayload, identity); + } + public ResoniteTexturePayload( - int? width, - int? height, + int width, + int height, string? colorProfile, byte[] binaryPayload, - string? identity = null, - ResoniteTexturePayloadFormat format = ResoniteTexturePayloadFormat.RawRgba32) + string? identity = null) { + ArgumentNullException.ThrowIfNull(binaryPayload); + RawTexturePayload.EnsureValidShape(width, height, binaryPayload.Length, RawTexturePayloadFormat.Rgba32); + ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); + TextureImportSourceIdentity effectiveIdentity = new(identity ?? Guid.NewGuid().ToString("N")); Width = width; Height = height; ColorProfile = colorProfile; - ArgumentNullException.ThrowIfNull(binaryPayload); - BinaryPayload = ImmutableArray.CreateRange(binaryPayload); - Identity = identity; - Format = format; - Source = TextureImportSourceFactory.CreateInMemory( + BinaryPayload = immutablePayload; + Format = ResoniteTexturePayloadFormat.RawRgba32; + Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, - binaryPayload, - identity ?? Guid.NewGuid().ToString("N"), - (TexturePayloadFormat)format); + immutablePayload, + effectiveIdentity.Value); } public ResoniteTexturePayload( int? width, int? height, string? colorProfile, - ITextureImportSource source, - string? identity = null, - ResoniteTexturePayloadFormat format = ResoniteTexturePayloadFormat.EncodedImage) + ITextureImportSource source) { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Identity.Value, nameof(source)); Width = width; Height = height; ColorProfile = colorProfile; - ArgumentNullException.ThrowIfNull(source); BinaryPayload = []; - Identity = identity ?? source.Identity; - Format = format; + Format = ResoniteTexturePayloadFormat.EncodedImage; Source = source; } - public int? Width { get; init; } + internal ResoniteTexturePayload( + int width, + int height, + string? colorProfile, + IRawTexturePayloadSource source) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Identity.Value, nameof(source)); + RawTexturePayload.EnsureValidDimensions(width, height); + Width = width; + Height = height; + ColorProfile = colorProfile; + BinaryPayload = []; + Format = ResoniteTexturePayloadFormat.RawRgba32; + Source = source; + } - public int? Height { get; init; } + public int? Width { get; } - public string? ColorProfile { get; init; } + public int? Height { get; } - public ImmutableArray BinaryPayload { get; init; } + public string? ColorProfile { get; } - public string? Identity { get; init; } + public ImmutableArray BinaryPayload { get; } - public ResoniteTexturePayloadFormat Format { get; init; } + public ResoniteTexturePayloadFormat Format { get; } - public ITextureImportSource Source { get; init; } + public ITextureImportSource Source { get; } } diff --git a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs index fc379732..bcd1fad8 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs @@ -133,13 +133,30 @@ internal static ResoniteMaterialBinding ToInternal(MaterialBinding binding) private static ResoniteTexturePayload ToInternal(TexturePayload payload) { + return payload.Format switch + { + TexturePayloadFormat.EncodedImage => new ResoniteTexturePayload( + payload.Width, + payload.Height, + payload.ColorProfile, + payload.Source), + TexturePayloadFormat.RawRgba32 => ToInternalRawTexturePayload(payload), + _ => throw new ArgumentOutOfRangeException(nameof(payload), payload.Format, "Unsupported texture payload format."), + }; + } + + private static ResoniteTexturePayload ToInternalRawTexturePayload(TexturePayload payload) + { + if (payload.Source is not IRawTexturePayloadSource rawSource) + { + throw new ArgumentException("Raw texture payload must carry a raw texture import source.", nameof(payload)); + } + return new ResoniteTexturePayload( - payload.Width, - payload.Height, + payload.Width ?? throw new ArgumentException("Raw texture payload must include width.", nameof(payload)), + payload.Height ?? throw new ArgumentException("Raw texture payload must include height.", nameof(payload)), payload.ColorProfile, - payload.Source, - payload.Identity, - ToInternal(payload.Format)); + rawSource); } private static ResoniteMaterialType ToInternal(MaterialType materialType) @@ -173,15 +190,5 @@ private static ResoniteMaterialProjection ToInternal(MaterialProjection projecti }; } - private static ResoniteTexturePayloadFormat ToInternal(TexturePayloadFormat format) - { - return format switch - { - TexturePayloadFormat.RawRgba32 => ResoniteTexturePayloadFormat.RawRgba32, - TexturePayloadFormat.EncodedImage => ResoniteTexturePayloadFormat.EncodedImage, - _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported texture payload format."), - }; - } - private static ResoniteMaterialDepthOffset ToInternal(MaterialDepthOffset value) => new(value.Factor, value.Units); } diff --git a/src/PlateauResoniteLink/Transport/ResoniteLink/ResoniteLinkClient.cs b/src/PlateauResoniteLink/Transport/ResoniteLink/ResoniteLinkClient.cs index 59e3fdd0..383d7ca5 100644 --- a/src/PlateauResoniteLink/Transport/ResoniteLink/ResoniteLinkClient.cs +++ b/src/PlateauResoniteLink/Transport/ResoniteLink/ResoniteLinkClient.cs @@ -263,7 +263,7 @@ private Task ImportRawTextureAsync(RawTexturePayload rawPayload, Canc Width = rawPayload.Width, Height = rawPayload.Height, ColorProfile = rawPayload.ColorProfile ?? ResoniteTextureColorProfiles.Srgb, - RawBinaryPayload = rawPayload.Bytes, + RawBinaryPayload = rawPayload.Bytes.AsSpan().ToArray(), }), cancellationToken); } @@ -277,7 +277,7 @@ private Task ImportRawHdrTextureAsync(RawTexturePayload rawPayload, C { Width = rawPayload.Width, Height = rawPayload.Height, - RawBinaryPayload = rawPayload.Bytes, + RawBinaryPayload = rawPayload.Bytes.AsSpan().ToArray(), }), cancellationToken); } diff --git a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs index 5a657dca..24368a4b 100644 --- a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs @@ -1,5 +1,14 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + using PlateauResoniteLink.Application.Importing; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + namespace PlateauResoniteLink.Tests.Application; public sealed class TexturePayloadTests @@ -14,4 +23,122 @@ public void ConstructorCopiesBinaryPayloadBytes() Assert.Equal([1, 2, 3, 4], payload.BinaryPayload); } + + [Fact] + public async Task ConstructorCreatesDimensionedRawTextureSource() + { + TexturePayload payload = new(1, 1, "sRGB", [1, 2, 3, 4], "dataset:texture"); + + RawTexturePayload rawPayload = await TextureImportSourceMaterializer.MaterializeRawAsync( + payload.Source, + CancellationToken.None); + + Assert.Equal(1, rawPayload.Width); + Assert.Equal(1, rawPayload.Height); + Assert.Equal([1, 2, 3, 4], rawPayload.Bytes); + } + + [Fact] + public async Task RawSourceMaterializationReusesImmutablePayloadBytes() + { + TexturePayload payload = new(1, 1, "sRGB", [1, 2, 3, 4], "dataset:texture"); + + RawTexturePayload first = await TextureImportSourceMaterializer.MaterializeRawAsync( + payload.Source, + CancellationToken.None); + RawTexturePayload second = await TextureImportSourceMaterializer.MaterializeRawAsync( + payload.Source, + CancellationToken.None); + + Assert.Same( + ImmutableCollectionsMarshal.AsArray(first.Bytes), + ImmutableCollectionsMarshal.AsArray(second.Bytes)); + } + + [Fact] + public void RawConstructorCreatesGeneratedSourceIdentity() + { + TexturePayload payload = new(1, 1, "sRGB", [1, 2, 3, 4]); + + Assert.False(string.IsNullOrWhiteSpace(payload.Source.Identity.Value)); + } + + [Fact] + public async Task EncodedSourceCopiesInputBeforeMaterialization() + { + byte[] sourceBytes = CreateEncodedPixelBytes(new Rgba32(10, 20, 30, 255)); + ITextureImportSource source = TextureImportSourceFactory.CreateEncodedImageInMemory( + "sRGB", + sourceBytes, + "dataset:encoded-texture"); + Array.Fill(sourceBytes, 0); + + RawTexturePayload rawPayload = await TextureImportSourceMaterializer.MaterializeRawAsync( + source, + CancellationToken.None); + + Assert.Equal(1, rawPayload.Width); + Assert.Equal(1, rawPayload.Height); + Assert.Equal([10, 20, 30, 255], rawPayload.Bytes); + } + + [Theory] + [InlineData(0, 1, 4)] + [InlineData(1, 0, 4)] + [InlineData(1, 1, 3)] + [InlineData(1, 1, 5)] + public void RawConstructorRejectsInvalidRawShape(int width, int height, int byteLength) + { + byte[] bytes = new byte[byteLength]; + + Assert.ThrowsAny(() => new TexturePayload(width, height, "sRGB", bytes, "dataset:texture")); + } + + [Fact] + public void EncodedConstructorUsesSourceAsIdentityCarrier() + { + ITextureImportSource source = TextureImportSourceFactory.CreateEncodedImageInMemory( + "sRGB", + [1, 2, 3, 4], + "source:texture"); + + TexturePayload payload = new( + 1, + 1, + "sRGB", + source); + + Assert.Same(source, payload.Source); + Assert.Equal(new TextureImportSourceIdentity("source:texture"), payload.Source.Identity); + } + + [Fact] + public void SourceBackedConstructorRejectsDefaultSourceIdentity() + { + Assert.ThrowsAny(() => new TexturePayload( + 1, + 1, + "sRGB", + new DefaultIdentityTextureImportSource())); + } + + private static byte[] CreateEncodedPixelBytes(Rgba32 pixel) + { + using Image image = new(1, 1); + image[0, 0] = pixel; + using MemoryStream stream = new(); + image.SaveAsPng(stream); + return stream.ToArray(); + } + + private sealed class DefaultIdentityTextureImportSource : ITextureImportSource + { + public TextureImportSourceIdentity Identity => default; + + public string Description => "default identity"; + + public string? ColorProfile => "sRGB"; + + public long? EstimatedByteLength => null; + } } diff --git a/tests/PlateauResoniteLink.Tests/Formats/CityGmlAppearanceStoreTests.cs b/tests/PlateauResoniteLink.Tests/Formats/CityGmlAppearanceStoreTests.cs index 67d21bb9..6868467e 100644 --- a/tests/PlateauResoniteLink.Tests/Formats/CityGmlAppearanceStoreTests.cs +++ b/tests/PlateauResoniteLink.Tests/Formats/CityGmlAppearanceStoreTests.cs @@ -277,7 +277,7 @@ public async Task Resolve_DefersDatasetTextureReadUntilRawMaterialization() Assert.Equal(1, datasetSource.OpenReadCallCount); Assert.Equal(1, rawPayload.Width); Assert.Equal(1, rawPayload.Height); - Assert.Equal([255, 0, 0, 255], rawPayload.Bytes); + Assert.Equal([255, 0, 0, 255], rawPayload.Bytes.AsSpan().ToArray()); } private sealed class CountingDatasetContentSource(byte[] payload) : IPlateauDatasetContentSource diff --git a/tests/PlateauResoniteLink.Tests/Profiles/LocalCityGmlObjectProjectionTests.cs b/tests/PlateauResoniteLink.Tests/Profiles/LocalCityGmlObjectProjectionTests.cs index 5fd433a0..c9e75655 100644 --- a/tests/PlateauResoniteLink.Tests/Profiles/LocalCityGmlObjectProjectionTests.cs +++ b/tests/PlateauResoniteLink.Tests/Profiles/LocalCityGmlObjectProjectionTests.cs @@ -2072,10 +2072,10 @@ public void ProjectCityObjectCullsBottomBandBuildingSurfacesBySemanticOrDownward materialResolver: new DefaultMaterialResolver(CommonMaterialCatalog.Create())); Assert.Equal(3, projected.Materials.Count); - Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Identity == "ground"); - Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Identity == "ground-reversed"); - Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Identity == "outer-floor"); - Assert.Contains(projected.Materials, static material => material.TexturePayload?.Identity == "high-outer-floor"); + Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "ground"); + Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "ground-reversed"); + Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "outer-floor"); + Assert.Contains(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "high-outer-floor"); } [Fact] @@ -2112,7 +2112,7 @@ public void ProjectCityObjectKeepsNonBuildingDownwardHorizontalGroundSurface() materialResolver: new DefaultMaterialResolver(CommonMaterialCatalog.Create())); Assert.Single(projected.Materials); - Assert.Equal("tran-ground", projected.Materials[0].TexturePayload?.Identity); + Assert.Equal("tran-ground", projected.Materials[0].TexturePayload?.Source.Identity.Value); Assert.NotEmpty(projected.Mesh.Vertices); } @@ -2170,10 +2170,10 @@ public void ProjectCityObjectCullsBuildingLod1UnknownBottomBandSurfaces() materialResolver: new DefaultMaterialResolver(CommonMaterialCatalog.Create())); Assert.Equal(2, projected.Materials.Count); - Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Identity == "lod1-bottom"); - Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Identity == "lod1-bottom-reversed"); - Assert.Contains(projected.Materials, static material => material.TexturePayload?.Identity == "lod1-roof"); - Assert.Contains(projected.Materials, static material => material.TexturePayload?.Identity == "lod1-wall"); + Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "lod1-bottom"); + Assert.DoesNotContain(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "lod1-bottom-reversed"); + Assert.Contains(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "lod1-roof"); + Assert.Contains(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "lod1-wall"); } [Fact] @@ -2245,7 +2245,7 @@ public void ProjectCityObjectKeepsSingleDownwardHorizontalBuildingSurfaceWhenNoH materialResolver: new DefaultMaterialResolver(CommonMaterialCatalog.Create())); Assert.Single(projected.Materials); - Assert.Contains(projected.Materials, static material => material.TexturePayload?.Identity == "only-surface"); + Assert.Contains(projected.Materials, static material => material.TexturePayload?.Source.Identity.Value == "only-surface"); } [Fact] @@ -2377,7 +2377,7 @@ await service.ExecuteAsync( Assert.NotNull(explicitMaterial.TexturePayload); Assert.Contains( "udx/dem/53394525/appearance/mixed_surface.png", - explicitMaterial.TexturePayload!.Identity, + explicitMaterial.TexturePayload!.Source.Identity.Value, StringComparison.Ordinal); } diff --git a/tests/PlateauResoniteLink.Tests/Targets/NonDemCityObjectBakerTests.cs b/tests/PlateauResoniteLink.Tests/Targets/NonDemCityObjectBakerTests.cs index f6d4e4ad..2a33cbb1 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/NonDemCityObjectBakerTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/NonDemCityObjectBakerTests.cs @@ -122,7 +122,7 @@ await AssertBufferedAsync( ResoniteConstructionCityObject cityObject = Assert.Single(await baker.FlushAllAsync()); string?[] identities = cityObject.Materials - .Select(static material => material.TexturePayload?.Identity) + .Select(static material => material.TexturePayload?.Source.Identity.Value) .ToArray(); Assert.Collection( identities, @@ -151,7 +151,7 @@ await AssertBufferedAsync( Assert.Equal(CommonMaterialCatalog.Create().Generic.Uv, material.CommonMaterial); Assert.NotSame(payload, material.TexturePayload); Assert.NotNull(material.TexturePayload); - Assert.Contains("atlastex-", material.TexturePayload.Identity, StringComparison.Ordinal); + Assert.Contains("atlastex-", material.TexturePayload.Source.Identity.Value, StringComparison.Ordinal); Assert.Equal(ResoniteTexturePayloadFormat.RawRgba32, material.TexturePayload.Format); Assert.Null(material.TextureScale); Assert.Null(material.TextureOffset); @@ -193,7 +193,7 @@ await AssertBufferedAsync( double averageX = submesh.TriangleVertexIndices .Select(index => cityObject.Mesh.Vertices[index].Position.X) .Average(); - averageXByPayloadIdentity.Add(material.TexturePayload?.Identity ?? string.Empty, averageX); + averageXByPayloadIdentity.Add(material.TexturePayload?.Source.Identity.Value ?? string.Empty, averageX); } Assert.True(averageXByPayloadIdentity["textures/left.png"] < averageXByPayloadIdentity["textures/right.png"]); @@ -590,7 +590,7 @@ public async Task FlushAllAsyncFallsBackToOriginalCityObjectWhenSingleCandidateC Assert.Equal(oversizedCandidate.DisplayName, cityObject.DisplayName); Assert.Equal(oversizedCandidate.Materials.Count, cityObject.Materials.Count); Assert.All(cityObject.Materials, static material => Assert.NotNull(material.TexturePayload)); - Assert.DoesNotContain(cityObject.Materials, static material => material.TexturePayload?.Identity?.Contains("generated/lod2-atlas/", StringComparison.Ordinal) == true); + Assert.DoesNotContain(cityObject.Materials, static material => material.TexturePayload?.Source.Identity.Value?.Contains("generated/lod2-atlas/", StringComparison.Ordinal) == true); } [Fact] @@ -615,7 +615,7 @@ public async Task FlushAllAsyncFallsBackToNormalizedCityObjectWhenSingleCandidat Assert.Equal(new ResoniteFloat2(0.25, 0.75), cityObject.Mesh.Vertices[0].UV0); Assert.Equal(new ResoniteFloat2(2.25, 0.75), cityObject.Mesh.Vertices[1].UV0); Assert.Equal(new ResoniteFloat2(0.25, 1.25), cityObject.Mesh.Vertices[2].UV0); - Assert.DoesNotContain(cityObject.Materials, static candidate => candidate.TexturePayload?.Identity?.Contains("generated/lod2-atlas/", StringComparison.Ordinal) == true); + Assert.DoesNotContain(cityObject.Materials, static candidate => candidate.TexturePayload?.Source.Identity.Value?.Contains("generated/lod2-atlas/", StringComparison.Ordinal) == true); } [Fact] @@ -791,7 +791,7 @@ public async Task FlushAllAsyncFallsBackWhenSingleCandidateNeedsNonPowerOfTwoEdg ResoniteConstructionCityObject cityObject = Assert.Single(await baker.FlushAllAsync()); Assert.Equal(oversizedCandidate.SlotKey, cityObject.SlotKey); - Assert.DoesNotContain(cityObject.Materials, static material => material.TexturePayload?.Identity?.Contains("generated/lod2-atlas/", StringComparison.Ordinal) == true); + Assert.DoesNotContain(cityObject.Materials, static material => material.TexturePayload?.Source.Identity.Value?.Contains("generated/lod2-atlas/", StringComparison.Ordinal) == true); } [Fact] @@ -1025,7 +1025,7 @@ await AssertBufferedAsync( Assert.Equal("atlasbake-unit-a-bldg-lod2-3", cityObject.SlotKey); Assert.Equal( "atlastex-unit-a.gml-3", - Assert.IsType(Assert.Single(cityObject.Materials).TexturePayload).Identity); + Assert.IsType(Assert.Single(cityObject.Materials).TexturePayload).Source.Identity.Value); } private static NonDemCityObjectBaker CreateBaker( diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs index 9e434667..a859db36 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs @@ -541,13 +541,35 @@ private static TerrainGridSampleCoverage[] CreateMeasuredTerrainGridCoverage(int => Enumerable.Repeat(TerrainGridSampleCoverage.Measured, count).ToArray(); private static TexturePayload ToContractTexturePayload(ResoniteTexturePayload payload) - => new( - payload.Width, - payload.Height, - payload.ColorProfile, - payload.BinaryPayload.AsSpan().ToArray(), - payload.Identity, - (TexturePayloadFormat)payload.Format); + => payload.Format == ResoniteTexturePayloadFormat.RawRgba32 + ? ToContractRawTexturePayload(payload) + : new TexturePayload( + payload.Width, + payload.Height, + payload.ColorProfile, + payload.Source); + + private static TexturePayload ToContractRawTexturePayload(ResoniteTexturePayload payload) + { + int width = payload.Width ?? throw new ArgumentException("Raw texture payload must include width.", nameof(payload)); + int height = payload.Height ?? throw new ArgumentException("Raw texture payload must include height.", nameof(payload)); + if (!payload.BinaryPayload.IsDefaultOrEmpty) + { + return new TexturePayload( + width, + height, + payload.ColorProfile, + payload.BinaryPayload.AsSpan().ToArray(), + payload.Source.Identity.Value); + } + + if (payload.Source is not IRawTexturePayloadSource rawSource) + { + throw new ArgumentException("Raw texture payload must carry a raw texture import source.", nameof(payload)); + } + + return new TexturePayload(width, height, payload.ColorProfile, rawSource); + } } diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTests.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTests.cs index f4cd9aae..46b7776b 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTests.cs @@ -528,7 +528,7 @@ public async Task ExecuteAsyncSendsTerrainGridDisplacementAsHdrRawTextureAndCrea Assert.Equal(2, importedTexture.Width); Assert.Equal(2, importedTexture.Height); float[] pixels = new float[importedTexture.Bytes.Length / sizeof(float)]; - Buffer.BlockCopy(importedTexture.Bytes, 0, pixels, 0, importedTexture.Bytes.Length); + Buffer.BlockCopy(importedTexture.Bytes.AsSpan().ToArray(), 0, pixels, 0, importedTexture.Bytes.Length); Assert.Equal(0.0f, pixels[0]); Assert.Equal(0.0f, pixels[1]); Assert.Equal(3.0f, pixels[2]); diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs index 86a60af9..d9455604 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs @@ -1,3 +1,8 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using PlateauResoniteLink.Application.Importing; using PlateauResoniteLink.Targets.Resonite; namespace PlateauResoniteLink.Tests.Targets; @@ -14,4 +19,109 @@ public void ConstructorCopiesBinaryPayloadBytes() Assert.Equal([4, 3, 2, 1], payload.BinaryPayload); } + + [Fact] + public async Task ConstructorCreatesDimensionedRawTextureSource() + { + ResoniteTexturePayload payload = new(1, 1, "sRGB", [4, 3, 2, 1], "dataset:texture"); + + RawTexturePayload rawPayload = await TextureImportSourceMaterializer.MaterializeRawAsync( + payload.Source, + CancellationToken.None); + + Assert.Equal(1, rawPayload.Width); + Assert.Equal(1, rawPayload.Height); + Assert.Equal([4, 3, 2, 1], rawPayload.Bytes); + } + + [Fact] + public void RawConstructorCreatesGeneratedSourceIdentity() + { + ResoniteTexturePayload payload = new(1, 1, "sRGB", [4, 3, 2, 1]); + + Assert.False(string.IsNullOrWhiteSpace(payload.Source.Identity.Value)); + } + + [Theory] + [InlineData(0, 1, 4)] + [InlineData(1, 0, 4)] + [InlineData(1, 1, 3)] + [InlineData(1, 1, 5)] + public void RawConstructorRejectsInvalidRawShape(int width, int height, int byteLength) + { + byte[] bytes = new byte[byteLength]; + + Assert.ThrowsAny(() => new ResoniteTexturePayload(width, height, "sRGB", bytes, "dataset:texture")); + } + + [Fact] + public void EncodedConstructorUsesSourceAsIdentityCarrier() + { + ITextureImportSource source = TextureImportSourceFactory.CreateEncodedImageInMemory( + "sRGB", + [1, 2, 3, 4], + "source:texture"); + + ResoniteTexturePayload payload = new( + 1, + 1, + "sRGB", + source); + + Assert.Same(source, payload.Source); + Assert.Equal(new TextureImportSourceIdentity("source:texture"), payload.Source.Identity); + } + + [Fact] + public void SourceBackedConstructorRejectsDefaultSourceIdentity() + { + Assert.ThrowsAny(() => new ResoniteTexturePayload( + 1, + 1, + "sRGB", + new DefaultIdentityTextureImportSource())); + } + + [Fact] + public void ContractRoundTripPreservesSourceBackedRawPayload() + { + ITextureImportSource source = TextureImportSourceFactory.CreateRawRgba32InMemory( + 1, + 1, + "sRGB", + new byte[] { 4, 3, 2, 1 }, + "source:texture"); + ResoniteTexturePayload payload = new( + 1, + 1, + "sRGB", + Assert.IsAssignableFrom(source)); + ResoniteMaterialBinding binding = new( + BaseColor: new ResoniteColor(1.0, 1.0, 1.0, 1.0), + MaterialType: ResoniteMaterialType.Standard, + TexturePayload: payload, + TextureSourceKind: ResoniteTextureSourceKind.Dataset, + Projection: ResoniteMaterialProjection.Uv, + DepthOffset: null, + SubmeshIndices: [0]); + + MaterialBinding contractBinding = ResoniteLiveSceneImportTargetTestSupport.ToContractMaterial(binding); + + TexturePayload contractPayload = Assert.IsType(contractBinding.TexturePayload); + Assert.Equal(TexturePayloadFormat.RawRgba32, contractPayload.Format); + Assert.Same(source, contractPayload.Source); + Assert.Equal(source.Identity, contractPayload.Source.Identity); + Assert.True(contractPayload.BinaryPayload.IsDefaultOrEmpty); + } + + private sealed class DefaultIdentityTextureImportSource : ITextureImportSource + { + public TextureImportSourceIdentity Identity => default; + + public string Description => "default identity"; + + public string? ColorProfile => "sRGB"; + + public long? EstimatedByteLength => null; + } } diff --git a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs index 66df71e8..76f4cc3f 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs @@ -16,7 +16,11 @@ public void ToInternalMaterialBindingsPreservesNeutralContractFields() new( BaseColor: new ColorRgba(0.1, 0.2, 0.3, 0.4), MaterialType: MaterialType.Standard, - TexturePayload: new TexturePayload(2, 2, "sRGB", [1, 2, 3, 4], "dataset:texture", TexturePayloadFormat.EncodedImage), + TexturePayload: new TexturePayload( + 2, + 2, + "sRGB", + TextureImportSourceFactory.CreateEncodedImageInMemory("sRGB", [1, 2, 3, 4], "dataset:texture")), TextureSourceKind: TextureSourceKind.Dataset, Projection: MaterialProjection.Uv, DepthOffset: new MaterialDepthOffset(-1.5, 2.5), @@ -32,7 +36,7 @@ public void ToInternalMaterialBindingsPreservesNeutralContractFields() Assert.Equal(0.1, mapped.BaseColor.R, 9); Assert.Equal(0.2, mapped.BaseColor.G, 9); - Assert.Equal("dataset:texture", mapped.TexturePayload!.Identity); + Assert.Equal(new TextureImportSourceIdentity("dataset:texture"), mapped.TexturePayload!.Source.Identity); Assert.Equal(ResoniteTexturePayloadFormat.EncodedImage, mapped.TexturePayload.Format); Assert.Equal(-1.5, mapped.DepthOffset!.Factor, 9); Assert.Equal(2.5, mapped.DepthOffset.Units, 9); @@ -46,7 +50,6 @@ public void ToInternalMaterialBindingsPreservesNeutralContractFields() [InlineData(nameof(MaterialBinding.MaterialType))] [InlineData(nameof(MaterialBinding.TextureSourceKind))] [InlineData(nameof(MaterialBinding.Projection))] - [InlineData(nameof(TexturePayload.Format))] public void ToInternalMaterialBindingsRejectsUnsupportedContractEnumValues(string invalidField) { MaterialBinding binding = CreateValidBinding() with @@ -54,14 +57,45 @@ public void ToInternalMaterialBindingsRejectsUnsupportedContractEnumValues(strin MaterialType = invalidField == nameof(MaterialBinding.MaterialType) ? (MaterialType)999 : MaterialType.Standard, TextureSourceKind = invalidField == nameof(MaterialBinding.TextureSourceKind) ? (TextureSourceKind)999 : TextureSourceKind.Dataset, Projection = invalidField == nameof(MaterialBinding.Projection) ? (MaterialProjection)999 : MaterialProjection.Uv, - TexturePayload = invalidField == nameof(TexturePayload.Format) - ? new TexturePayload(2, 2, "sRGB", [1, 2, 3, 4], "dataset:texture", (TexturePayloadFormat)999) - : new TexturePayload(2, 2, "sRGB", [1, 2, 3, 4], "dataset:texture", TexturePayloadFormat.EncodedImage), }; Assert.Throws(() => SceneImportContractMapper.ToInternal(binding)); } + [Fact] + public void ToInternalMaterialBindingsReusesRawTextureSource() + { + TexturePayload texturePayload = new( + width: 2, + height: 2, + colorProfile: "linear", + binaryPayload: + [ + 0, 0, 0, 255, + 255, 0, 0, 255, + 0, 255, 0, 255, + 0, 0, 255, 255, + ]); + MaterialBinding[] bindings = + [ + new( + BaseColor: new ColorRgba(1.0, 1.0, 1.0, 1.0), + MaterialType: MaterialType.Standard, + TexturePayload: texturePayload, + TextureSourceKind: TextureSourceKind.Dataset, + Projection: MaterialProjection.Uv, + DepthOffset: null, + SubmeshIndices: [0]), + ]; + + ResoniteMaterialBinding mapped = Assert.Single(SceneImportContractMapper.ToInternal(bindings)); + + Assert.Equal(ResoniteTexturePayloadFormat.RawRgba32, mapped.TexturePayload!.Format); + Assert.Same(texturePayload.Source, mapped.TexturePayload.Source); + Assert.Equal(texturePayload.Source.Identity, mapped.TexturePayload.Source.Identity); + Assert.True(mapped.TexturePayload.BinaryPayload.IsDefaultOrEmpty); + } + [Fact] public void ToInternalMaterialBindingsKeepsTerrainOverlaySharedScopeIndependentFromProvider() { @@ -137,7 +171,11 @@ private static MaterialBinding CreateValidBinding() return new MaterialBinding( BaseColor: new ColorRgba(0.1, 0.2, 0.3, 0.4), MaterialType: MaterialType.Standard, - TexturePayload: new TexturePayload(2, 2, "sRGB", [1, 2, 3, 4], "dataset:texture", TexturePayloadFormat.EncodedImage), + TexturePayload: new TexturePayload( + 2, + 2, + "sRGB", + TextureImportSourceFactory.CreateEncodedImageInMemory("sRGB", [1, 2, 3, 4], "dataset:texture")), TextureSourceKind: TextureSourceKind.Dataset, Projection: MaterialProjection.Uv, DepthOffset: null, diff --git a/tests/PlateauResoniteLink.Tests/Targets/TerrainTextureAssetGeneratorTests.cs b/tests/PlateauResoniteLink.Tests/Targets/TerrainTextureAssetGeneratorTests.cs index 8c27037a..78cd55c2 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/TerrainTextureAssetGeneratorTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/TerrainTextureAssetGeneratorTests.cs @@ -225,7 +225,9 @@ public async Task EnsureTextureAsyncReusesPersistentTileCacheAcrossGeneratorInst Assert.Equal(Materialize(texture.TextureSource).Width, Materialize(repeatedTexture.TextureSource).Width); Assert.Equal(Materialize(texture.TextureSource).Height, Materialize(repeatedTexture.TextureSource).Height); - Assert.Equal(Materialize(texture.TextureSource).Bytes, Materialize(repeatedTexture.TextureSource).Bytes); + Assert.Equal( + Materialize(texture.TextureSource).Bytes.AsSpan().ToArray(), + Materialize(repeatedTexture.TextureSource).Bytes.AsSpan().ToArray()); } [Fact] @@ -600,7 +602,7 @@ private static TerrainTextureOverlay CreateFullCoverageOverlay(string urlTemplat private static Image LoadImage(ITextureImportSource texture) { RawTexturePayload rawPayload = Materialize(texture); - return Image.LoadPixelData(rawPayload.Bytes, rawPayload.Width, rawPayload.Height); + return Image.LoadPixelData(rawPayload.Bytes.AsSpan().ToArray(), rawPayload.Width, rawPayload.Height); } private static RawTexturePayload Materialize(ITextureImportSource texture) diff --git a/tests/PlateauResoniteLink.Tests/Targets/TerrainTextureGeoReferencedRasterSupportTests.cs b/tests/PlateauResoniteLink.Tests/Targets/TerrainTextureGeoReferencedRasterSupportTests.cs index 83225dab..da592d8f 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/TerrainTextureGeoReferencedRasterSupportTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/TerrainTextureGeoReferencedRasterSupportTests.cs @@ -275,7 +275,7 @@ public async Task EnsureTextureAsyncUsesGeoReferencedRasterSourceBeforeTileFallb Assert.Equal(0, handler.RequestCount); using Image outputImage = Image.LoadPixelData( - Materialize(texture.TextureSource).Bytes, + Materialize(texture.TextureSource).Bytes.AsSpan().ToArray(), Materialize(texture.TextureSource).Width, Materialize(texture.TextureSource).Height); Assert.Equal(new Rgba32(12, 34, 56, 255), outputImage[0, 0]); @@ -391,7 +391,7 @@ public async Task EnsureTextureAsyncKeepsDefaultThirdMeshDemOverlayPixelPerfectW (double)layout.CropHeight / Materialize(texture.TextureSource).Height), texture.OccupiedUvRect.ScaleValue); using Image outputImage = Image.LoadPixelData( - Materialize(texture.TextureSource).Bytes, + Materialize(texture.TextureSource).Bytes.AsSpan().ToArray(), Materialize(texture.TextureSource).Width, Materialize(texture.TextureSource).Height); int occupiedLeft = (outputImage.Width - layout.CropWidth) / 2; @@ -432,7 +432,7 @@ public async Task EnsureTextureAsyncFlattensTransparentGeoReferencedRasterPixels GeneratedTerrainTexture texture = await generator.EnsureTextureAsync(overlay, CancellationToken.None); using Image outputImage = Image.LoadPixelData( - Materialize(texture.TextureSource).Bytes, + Materialize(texture.TextureSource).Bytes.AsSpan().ToArray(), Materialize(texture.TextureSource).Width, Materialize(texture.TextureSource).Height); Assert.Equal(TerrainTextureAssetGenerator.DefaultDemGroundFillColor, outputImage[0, 0]); @@ -483,7 +483,7 @@ public async Task EnsureTextureAsyncFillsTransparentGeoReferencedRasterPixelsFro GeneratedTerrainTexture texture = await generator.EnsureTextureAsync(overlay, CancellationToken.None); using Image outputImage = Image.LoadPixelData( - Materialize(texture.TextureSource).Bytes, + Materialize(texture.TextureSource).Bytes.AsSpan().ToArray(), Materialize(texture.TextureSource).Width, Materialize(texture.TextureSource).Height); int occupiedTop = outputImage.Height - layout.CropHeight; diff --git a/tests/PlateauResoniteLink.Tests/TextureImportSourceTestFactory.cs b/tests/PlateauResoniteLink.Tests/TextureImportSourceTestFactory.cs index ffc71fb1..9ec467dd 100644 --- a/tests/PlateauResoniteLink.Tests/TextureImportSourceTestFactory.cs +++ b/tests/PlateauResoniteLink.Tests/TextureImportSourceTestFactory.cs @@ -16,13 +16,12 @@ public static ITextureImportSource CreateRawTextureSource( byte[] rawRgba32Bytes, string? identity = null) { - return TextureImportSourceFactory.CreateInMemory( + return TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, rawRgba32Bytes, - identity ?? $"test-rgba32:{width}:{height}:{Guid.NewGuid():N}", - TexturePayloadFormat.RawRgba32); + identity ?? $"test-rgba32:{width}:{height}:{Guid.NewGuid():N}"); } public static IReadOnlyList ImportedRgba32Textures(SceneSinkRecordingClient client) diff --git a/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs b/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs index 2e8a057b..12d5ac42 100644 --- a/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs +++ b/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs @@ -151,7 +151,7 @@ public async Task CreateSourceFromFileLoadsImageWhenMaterialized() Assert.Equal(1, importedTexture.Width); Assert.Equal(1, importedTexture.Height); Assert.Equal(ResoniteTextureColorProfiles.Srgb, importedTexture.ColorProfile); - Assert.Equal([255, 0, 0, 255], importedTexture.Bytes); + Assert.Equal([255, 0, 0, 255], importedTexture.Bytes.AsSpan().ToArray()); } [Fact] @@ -382,7 +382,7 @@ private sealed class InstrumentedTextureImportSource : IRawTexturePayloadSource { public int MaterializeCallCount { get; private set; } - public string Identity => "instrumented"; + public TextureImportSourceIdentity Identity { get; } = new("instrumented"); public string Description => "instrumented";