From cf14ae8c3b55cb91a3f229d6043bf379ad646027 Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:07:10 +0900 Subject: [PATCH 1/9] Split raw and encoded texture payload sources --- .../Importing/CityGmlAppearanceStore.cs | 3 +- .../Importing/SceneImportContractTypes.cs | 19 ++--- .../Importing/TextureImportSource.cs | 85 +++++++++++-------- .../Diagnostics/CanonicalSceneDumpSink.cs | 5 +- .../Resonite/ResoniteTextureImportFactory.cs | 3 +- .../Resonite/ResoniteTexturePayload.cs | 19 ++--- .../Resonite/SceneImportContractMapper.cs | 20 +++-- .../Application/TexturePayloadTests.cs | 17 ++++ ...esoniteLiveSceneImportTargetTestSupport.cs | 20 +++-- .../Targets/ResoniteTexturePayloadTests.cs | 18 ++++ .../Targets/SceneImportContractMapperTests.cs | 7 +- .../TextureImportSourceTestFactory.cs | 5 +- 12 files changed, 138 insertions(+), 83 deletions(-) diff --git a/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs b/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs index 402c4bd6..ff4914b0 100644 --- a/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs +++ b/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs @@ -197,8 +197,7 @@ private void ApplyGeoreferencedTexture(XElement textureElement) resolvedTexturePath, "sRGB", $"dataset:{resolvedTexturePath}"), - $"dataset:{resolvedTexturePath}", - TexturePayloadFormat.EncodedImage); + $"dataset:{resolvedTexturePath}"); texturePayloadsByResolvedPath[resolvedTexturePath] = texturePayload; } diff --git a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs index 992999d7..5fd0e88c 100644 --- a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs +++ b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs @@ -105,12 +105,11 @@ 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) { Width = width; Height = height; @@ -118,14 +117,13 @@ public TexturePayload( ArgumentNullException.ThrowIfNull(binaryPayload); BinaryPayload = ImmutableArray.CreateRange(binaryPayload); Identity = identity; - Format = format; - Source = TextureImportSourceFactory.CreateInMemory( + Format = TexturePayloadFormat.RawRgba32; + Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, binaryPayload, - identity ?? Guid.NewGuid().ToString("N"), - format); + identity ?? Guid.NewGuid().ToString("N")); } public TexturePayload( @@ -133,8 +131,7 @@ public TexturePayload( int? height, string? colorProfile, ITextureImportSource source, - string? identity = null, - TexturePayloadFormat format = TexturePayloadFormat.EncodedImage) + string? identity = null) { Width = width; Height = height; @@ -142,7 +139,7 @@ public TexturePayload( ArgumentNullException.ThrowIfNull(source); BinaryPayload = []; Identity = identity ?? source.Identity; - Format = format; + Format = TexturePayloadFormat.EncodedImage; Source = source; } diff --git a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs index 6f90ff27..5e7e102e 100644 --- a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs +++ b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs @@ -54,17 +54,16 @@ public static ValueTask MaterializeRawAsync( } } -internal sealed class InMemoryTextureImportSource : IRawTexturePayloadSource +internal sealed class InMemoryRawTextureImportSource : IRawTexturePayloadSource { private readonly byte[] 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); @@ -73,12 +72,11 @@ public InMemoryTextureImportSource( ColorProfile = colorProfile; this.bytes = (byte[])bytes.Clone(); Identity = identity; - SourceFormat = sourceFormat; } - public int? Width { get; } + public int Width { get; } - public int? Height { get; } + public int Height { get; } public string Identity { get; } @@ -88,34 +86,42 @@ 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(new RawTexturePayload( + Width, + Height, + ColorProfile, + (byte[])bytes.Clone())); } +} - 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 byte[] bytes; - return new RawTexturePayload( - Width.Value, - Height.Value, - ColorProfile, - (byte[])bytes.Clone()); + public InMemoryEncodedTextureImportSource( + string? colorProfile, + byte[] bytes, + string identity) + { + ArgumentNullException.ThrowIfNull(bytes); + ArgumentException.ThrowIfNullOrWhiteSpace(identity); + ColorProfile = colorProfile; + this.bytes = (byte[])bytes.Clone(); + Identity = identity; } - private async ValueTask DecodeEncodedImageAsync(CancellationToken cancellationToken) + public string 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); using Image image = await Image.LoadAsync(stream, cancellationToken); @@ -209,15 +215,22 @@ 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); + } + + 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/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/ResoniteTextureImportFactory.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs index fae3b488..6fe495e5 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs @@ -39,7 +39,6 @@ public static ResoniteTexturePayload CreatePayloadFromImage( 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..77054a21 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs @@ -14,12 +14,11 @@ public enum ResoniteTexturePayloadFormat public sealed record ResoniteTexturePayload { public ResoniteTexturePayload( - int? width, - int? height, + int width, + int height, string? colorProfile, byte[] binaryPayload, - string? identity = null, - ResoniteTexturePayloadFormat format = ResoniteTexturePayloadFormat.RawRgba32) + string? identity = null) { Width = width; Height = height; @@ -27,14 +26,13 @@ public ResoniteTexturePayload( ArgumentNullException.ThrowIfNull(binaryPayload); BinaryPayload = ImmutableArray.CreateRange(binaryPayload); Identity = identity; - Format = format; - Source = TextureImportSourceFactory.CreateInMemory( + Format = ResoniteTexturePayloadFormat.RawRgba32; + Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, binaryPayload, - identity ?? Guid.NewGuid().ToString("N"), - (TexturePayloadFormat)format); + identity ?? Guid.NewGuid().ToString("N")); } public ResoniteTexturePayload( @@ -42,8 +40,7 @@ public ResoniteTexturePayload( int? height, string? colorProfile, ITextureImportSource source, - string? identity = null, - ResoniteTexturePayloadFormat format = ResoniteTexturePayloadFormat.EncodedImage) + string? identity = null) { Width = width; Height = height; @@ -51,7 +48,7 @@ public ResoniteTexturePayload( ArgumentNullException.ThrowIfNull(source); BinaryPayload = []; Identity = identity ?? source.Identity; - Format = format; + Format = ResoniteTexturePayloadFormat.EncodedImage; Source = source; } diff --git a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs index 0ba5b200..36a07532 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs @@ -133,13 +133,19 @@ internal static ResoniteMaterialBinding ToInternal(MaterialBinding binding) private static ResoniteTexturePayload ToInternal(TexturePayload payload) { - return new ResoniteTexturePayload( - payload.Width, - payload.Height, - payload.ColorProfile, - payload.Source, - payload.Identity, - (ResoniteTexturePayloadFormat)payload.Format); + return payload.Format == TexturePayloadFormat.RawRgba32 + ? new ResoniteTexturePayload( + 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.BinaryPayload.AsSpan().ToArray(), + payload.Identity) + : new ResoniteTexturePayload( + payload.Width, + payload.Height, + payload.ColorProfile, + payload.Source, + payload.Identity); } private static ResoniteMaterialDepthOffset ToInternal(MaterialDepthOffset value) => new(value.Factor, value.Units); diff --git a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs index 5a657dca..a846bd97 100644 --- a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs @@ -1,3 +1,6 @@ +using System.Threading; +using System.Threading.Tasks; + using PlateauResoniteLink.Application.Importing; namespace PlateauResoniteLink.Tests.Application; @@ -14,4 +17,18 @@ 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); + } } diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs index 9e434667..3ef50158 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs @@ -541,13 +541,19 @@ 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 + ? new TexturePayload( + 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.BinaryPayload.AsSpan().ToArray(), + payload.Identity) + : new TexturePayload( + payload.Width, + payload.Height, + payload.ColorProfile, + payload.Source, + payload.Identity); } diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs index 86a60af9..9857476e 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs @@ -1,3 +1,7 @@ +using System.Threading; +using System.Threading.Tasks; + +using PlateauResoniteLink.Application.Importing; using PlateauResoniteLink.Targets.Resonite; namespace PlateauResoniteLink.Tests.Targets; @@ -14,4 +18,18 @@ 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); + } } diff --git a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs index c6d75e5e..b2b17460 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs @@ -14,7 +14,12 @@ 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"), + "dataset:texture"), TextureSourceKind: TextureSourceKind.Dataset, Projection: MaterialProjection.Uv, DepthOffset: new MaterialDepthOffset(-1.5, 2.5), 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) From c752ca184b251a7aef03a64d5995ad6af51aabc7 Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:26:46 +0900 Subject: [PATCH 2/9] Reuse raw texture source in contract mapper --- .../Resonite/ResoniteTexturePayload.cs | 17 ++++++++++ .../Resonite/SceneImportContractMapper.cs | 24 ++++++++----- .../Targets/SceneImportContractMapperTests.cs | 34 +++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs index 77054a21..da2c61e2 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs @@ -52,6 +52,23 @@ public ResoniteTexturePayload( Source = source; } + internal ResoniteTexturePayload( + int width, + int height, + string? colorProfile, + IRawTexturePayloadSource source, + string? identity = null) + { + Width = width; + Height = height; + ColorProfile = colorProfile; + ArgumentNullException.ThrowIfNull(source); + BinaryPayload = []; + Identity = identity ?? source.Identity; + Format = ResoniteTexturePayloadFormat.RawRgba32; + Source = source; + } + public int? Width { get; init; } public int? Height { get; init; } diff --git a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs index 36a07532..e5ba0bb0 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs @@ -133,19 +133,27 @@ internal static ResoniteMaterialBinding ToInternal(MaterialBinding binding) private static ResoniteTexturePayload ToInternal(TexturePayload payload) { - return payload.Format == TexturePayloadFormat.RawRgba32 - ? new ResoniteTexturePayload( - 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.BinaryPayload.AsSpan().ToArray(), - payload.Identity) - : new ResoniteTexturePayload( + if (payload.Format != TexturePayloadFormat.RawRgba32) + { + return new ResoniteTexturePayload( payload.Width, payload.Height, payload.ColorProfile, payload.Source, payload.Identity); + } + + 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 ?? 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, + rawSource, + payload.Identity); } private static ResoniteMaterialDepthOffset ToInternal(MaterialDepthOffset value) => new(value.Factor, value.Units); diff --git a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs index b2b17460..7402626a 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs @@ -45,6 +45,40 @@ public void ToInternalMaterialBindingsPreservesNeutralContractFields() Assert.Equal(3, mapped.BundledVariantIndex); } + [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, + ], + identity: "raw:texture"); + 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.True(mapped.TexturePayload.BinaryPayload.IsDefaultOrEmpty); + } + [Fact] public void ToInternalMaterialBindingsKeepsTerrainOverlaySharedScopeIndependentFromProvider() { From b5ed73092de8e3b21ff8c7d2d69a4a381e36bb5e Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:56:50 +0900 Subject: [PATCH 3/9] Share immutable raw texture payload bytes --- .../Importing/SceneImportContractTypes.cs | 5 ++- .../Importing/TextureImportSource.cs | 37 +++++++++++++++++-- .../Resonite/ResoniteTexturePayload.cs | 5 ++- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs index 5fd0e88c..63e77ce4 100644 --- a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs +++ b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs @@ -115,14 +115,15 @@ public TexturePayload( Height = height; ColorProfile = colorProfile; ArgumentNullException.ThrowIfNull(binaryPayload); - BinaryPayload = ImmutableArray.CreateRange(binaryPayload); + ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); + BinaryPayload = immutablePayload; Identity = identity; Format = TexturePayloadFormat.RawRgba32; Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, - binaryPayload, + immutablePayload, identity ?? Guid.NewGuid().ToString("N")); } diff --git a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs index 5e7e102e..ab10e5a9 100644 --- a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs +++ b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Immutable; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -56,7 +57,7 @@ public static ValueTask MaterializeRawAsync( internal sealed class InMemoryRawTextureImportSource : IRawTexturePayloadSource { - private readonly byte[] bytes; + private readonly ImmutableArray bytes; public InMemoryRawTextureImportSource( int width, @@ -70,7 +71,27 @@ public InMemoryRawTextureImportSource( Width = width; Height = height; ColorProfile = colorProfile; - this.bytes = (byte[])bytes.Clone(); + this.bytes = ImmutableArray.CreateRange(bytes); + Identity = identity; + } + + 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)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(identity); + Width = width; + Height = height; + ColorProfile = colorProfile; + this.bytes = bytes; Identity = identity; } @@ -93,7 +114,7 @@ public ValueTask MaterializeRawAsync(CancellationToken cancel Width, Height, ColorProfile, - (byte[])bytes.Clone())); + bytes.AsSpan().ToArray())); } } @@ -225,6 +246,16 @@ public static ITextureImportSource CreateRawRgba32InMemory( 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, diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs index da2c61e2..aa75dae0 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs @@ -24,14 +24,15 @@ public ResoniteTexturePayload( Height = height; ColorProfile = colorProfile; ArgumentNullException.ThrowIfNull(binaryPayload); - BinaryPayload = ImmutableArray.CreateRange(binaryPayload); + ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); + BinaryPayload = immutablePayload; Identity = identity; Format = ResoniteTexturePayloadFormat.RawRgba32; Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, - binaryPayload, + immutablePayload, identity ?? Guid.NewGuid().ToString("N")); } From 0d3070a44917e516278ba2b1a60e824b25801a7a Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:27:20 +0900 Subject: [PATCH 4/9] Reject unsupported texture payload formats --- .../Targets/Resonite/SceneImportContractMapper.cs | 13 +++++++++---- .../Targets/SceneImportContractMapperTests.cs | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs index 609d3a4d..f87472a9 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs @@ -133,16 +133,21 @@ internal static ResoniteMaterialBinding ToInternal(MaterialBinding binding) private static ResoniteTexturePayload ToInternal(TexturePayload payload) { - if (payload.Format != TexturePayloadFormat.RawRgba32) + return payload.Format switch { - return new ResoniteTexturePayload( + TexturePayloadFormat.EncodedImage => new ResoniteTexturePayload( payload.Width, payload.Height, payload.ColorProfile, payload.Source, - payload.Identity); - } + payload.Identity), + 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)); diff --git a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs index 465d99e6..d76d11b2 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs @@ -63,6 +63,21 @@ public void ToInternalMaterialBindingsRejectsUnsupportedContractEnumValues(strin Assert.Throws(() => SceneImportContractMapper.ToInternal(binding)); } + [Fact] + public void ToInternalMaterialBindingsRejectsUnsupportedTexturePayloadFormat() + { + MaterialBinding validBinding = CreateValidBinding(); + MaterialBinding binding = validBinding with + { + TexturePayload = validBinding.TexturePayload! with + { + Format = (TexturePayloadFormat)999, + }, + }; + + Assert.Throws(() => SceneImportContractMapper.ToInternal(binding)); + } + [Fact] public void ToInternalMaterialBindingsReusesRawTextureSource() { From 1e5c385b59cd5466cb54bceaf3fad67f5fd0632e Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:44:19 +0900 Subject: [PATCH 5/9] Align raw texture payload identities --- .../Importing/SceneImportContractTypes.cs | 5 ++-- .../Resonite/ResoniteTexturePayload.cs | 10 +++---- .../Resonite/SceneImportContractMapper.cs | 3 +- .../Application/TexturePayloadTests.cs | 9 ++++++ .../Targets/ResoniteTexturePayloadTests.cs | 9 ++++++ .../Targets/SceneImportContractMapperTests.cs | 29 +++++++++++++++++-- 6 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs index 63e77ce4..a42a84c4 100644 --- a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs +++ b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs @@ -116,15 +116,16 @@ public TexturePayload( ColorProfile = colorProfile; ArgumentNullException.ThrowIfNull(binaryPayload); ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); + string effectiveIdentity = identity ?? Guid.NewGuid().ToString("N"); BinaryPayload = immutablePayload; - Identity = identity; + Identity = effectiveIdentity; Format = TexturePayloadFormat.RawRgba32; Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, immutablePayload, - identity ?? Guid.NewGuid().ToString("N")); + effectiveIdentity); } public TexturePayload( diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs index aa75dae0..d8cb3a92 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs @@ -25,15 +25,16 @@ public ResoniteTexturePayload( ColorProfile = colorProfile; ArgumentNullException.ThrowIfNull(binaryPayload); ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); + string effectiveIdentity = identity ?? Guid.NewGuid().ToString("N"); BinaryPayload = immutablePayload; - Identity = identity; + Identity = effectiveIdentity; Format = ResoniteTexturePayloadFormat.RawRgba32; Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, immutablePayload, - identity ?? Guid.NewGuid().ToString("N")); + effectiveIdentity); } public ResoniteTexturePayload( @@ -57,15 +58,14 @@ internal ResoniteTexturePayload( int width, int height, string? colorProfile, - IRawTexturePayloadSource source, - string? identity = null) + IRawTexturePayloadSource source) { Width = width; Height = height; ColorProfile = colorProfile; ArgumentNullException.ThrowIfNull(source); BinaryPayload = []; - Identity = identity ?? source.Identity; + Identity = source.Identity; Format = ResoniteTexturePayloadFormat.RawRgba32; Source = source; } diff --git a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs index f87472a9..998fa9dd 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs @@ -157,8 +157,7 @@ private static ResoniteTexturePayload ToInternalRawTexturePayload(TexturePayload 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, - rawSource, - payload.Identity); + rawSource); } private static ResoniteMaterialType ToInternal(MaterialType materialType) diff --git a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs index a846bd97..c7085533 100644 --- a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs @@ -31,4 +31,13 @@ public async Task ConstructorCreatesDimensionedRawTextureSource() Assert.Equal(1, rawPayload.Height); Assert.Equal([1, 2, 3, 4], rawPayload.Bytes); } + + [Fact] + public void RawConstructorKeepsGeneratedIdentityConsistentWithSource() + { + TexturePayload payload = new(1, 1, "sRGB", [1, 2, 3, 4]); + + Assert.False(string.IsNullOrWhiteSpace(payload.Identity)); + Assert.Equal(payload.Identity, payload.Source.Identity); + } } diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs index 9857476e..b1a572c9 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs @@ -32,4 +32,13 @@ public async Task ConstructorCreatesDimensionedRawTextureSource() Assert.Equal(1, rawPayload.Height); Assert.Equal([4, 3, 2, 1], rawPayload.Bytes); } + + [Fact] + public void RawConstructorKeepsGeneratedIdentityConsistentWithSource() + { + ResoniteTexturePayload payload = new(1, 1, "sRGB", [4, 3, 2, 1]); + + Assert.False(string.IsNullOrWhiteSpace(payload.Identity)); + Assert.Equal(payload.Identity, payload.Source.Identity); + } } diff --git a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs index d76d11b2..ac7f024e 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs @@ -91,8 +91,7 @@ public void ToInternalMaterialBindingsReusesRawTextureSource() 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, - ], - identity: "raw:texture"); + ]); MaterialBinding[] bindings = [ new( @@ -109,9 +108,35 @@ public void ToInternalMaterialBindingsReusesRawTextureSource() Assert.Equal(ResoniteTexturePayloadFormat.RawRgba32, mapped.TexturePayload!.Format); Assert.Same(texturePayload.Source, mapped.TexturePayload.Source); + Assert.Equal(texturePayload.Source.Identity, mapped.TexturePayload.Identity); Assert.True(mapped.TexturePayload.BinaryPayload.IsDefaultOrEmpty); } + [Fact] + public void ToInternalMaterialBindingsUsesRawSourceIdentityWhenContractIdentityDiverges() + { + TexturePayload texturePayload = new TexturePayload( + width: 1, + height: 1, + colorProfile: "linear", + binaryPayload: [0, 0, 0, 255]) with + { + Identity = "stale-contract-identity", + }; + MaterialBinding[] bindings = + [ + CreateValidBinding() with + { + TexturePayload = texturePayload, + }, + ]; + + ResoniteMaterialBinding mapped = Assert.Single(SceneImportContractMapper.ToInternal(bindings)); + + Assert.Same(texturePayload.Source, mapped.TexturePayload!.Source); + Assert.Equal(texturePayload.Source.Identity, mapped.TexturePayload.Identity); + } + [Fact] public void ToInternalMaterialBindingsKeepsTerrainOverlaySharedScopeIndependentFromProvider() { From a062d537ce46a363ca8e26402b9cfad57c1c7f81 Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:58:37 +0900 Subject: [PATCH 6/9] Preserve texture payload source identity --- .../Importing/SceneImportContractTypes.cs | 33 +++++---- .../Importing/TextureImportSource.cs | 72 +++++++++++++++++-- .../Resonite/ResoniteTexturePayload.cs | 36 ++++++---- .../Application/TexturePayloadTests.cs | 32 +++++++++ .../Targets/ResoniteTexturePayloadTests.cs | 32 +++++++++ .../Targets/SceneImportContractMapperTests.cs | 40 ----------- 6 files changed, 174 insertions(+), 71 deletions(-) diff --git a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs index a42a84c4..3f584479 100644 --- a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs +++ b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs @@ -111,12 +111,13 @@ public TexturePayload( byte[] binaryPayload, string? identity = null) { - Width = width; - Height = height; - ColorProfile = colorProfile; ArgumentNullException.ThrowIfNull(binaryPayload); + RawTexturePayload.EnsureValidShape(width, height, binaryPayload.Length, RawTexturePayloadFormat.Rgba32); ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); string effectiveIdentity = identity ?? Guid.NewGuid().ToString("N"); + Width = width; + Height = height; + ColorProfile = colorProfile; BinaryPayload = immutablePayload; Identity = effectiveIdentity; Format = TexturePayloadFormat.RawRgba32; @@ -135,29 +136,37 @@ public TexturePayload( ITextureImportSource source, string? identity = null) { + ArgumentNullException.ThrowIfNull(source); + string effectiveIdentity = identity ?? source.Identity; + if (!string.Equals(effectiveIdentity, source.Identity, StringComparison.Ordinal)) + { + throw new ArgumentException( + "Texture payload identity must match the texture import source identity.", + nameof(identity)); + } + Width = width; Height = height; ColorProfile = colorProfile; - ArgumentNullException.ThrowIfNull(source); BinaryPayload = []; - Identity = identity ?? source.Identity; + Identity = effectiveIdentity; Format = TexturePayloadFormat.EncodedImage; Source = source; } - public int? Width { get; init; } + public int? Width { get; } - public int? Height { get; init; } + public int? Height { get; } - public string? ColorProfile { get; init; } + public string? ColorProfile { get; } - public ImmutableArray BinaryPayload { get; init; } + public ImmutableArray BinaryPayload { get; } - public string? Identity { get; init; } + public string Identity { 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 ab10e5a9..a58719b0 100644 --- a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs +++ b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs @@ -15,12 +15,70 @@ 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 +{ + 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 = Bytes; + this.Format = Format; + } + + public int Width { get; } + + public int Height { get; } + + public string? ColorProfile { get; } + + public byte[] 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 { @@ -68,6 +126,7 @@ public InMemoryRawTextureImportSource( { ArgumentNullException.ThrowIfNull(bytes); ArgumentException.ThrowIfNullOrWhiteSpace(identity); + RawTexturePayload.EnsureValidShape(width, height, bytes.Length, RawTexturePayloadFormat.Rgba32); Width = width; Height = height; ColorProfile = colorProfile; @@ -88,6 +147,7 @@ public InMemoryRawTextureImportSource( } ArgumentException.ThrowIfNullOrWhiteSpace(identity); + RawTexturePayload.EnsureValidShape(width, height, bytes.Length, RawTexturePayloadFormat.Rgba32); Width = width; Height = height; ColorProfile = colorProfile; diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs index d8cb3a92..95d22355 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs @@ -20,12 +20,13 @@ public ResoniteTexturePayload( byte[] binaryPayload, string? identity = null) { - Width = width; - Height = height; - ColorProfile = colorProfile; ArgumentNullException.ThrowIfNull(binaryPayload); + RawTexturePayload.EnsureValidShape(width, height, binaryPayload.Length, RawTexturePayloadFormat.Rgba32); ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); string effectiveIdentity = identity ?? Guid.NewGuid().ToString("N"); + Width = width; + Height = height; + ColorProfile = colorProfile; BinaryPayload = immutablePayload; Identity = effectiveIdentity; Format = ResoniteTexturePayloadFormat.RawRgba32; @@ -44,12 +45,20 @@ public ResoniteTexturePayload( ITextureImportSource source, string? identity = null) { + ArgumentNullException.ThrowIfNull(source); + string effectiveIdentity = identity ?? source.Identity; + if (!string.Equals(effectiveIdentity, source.Identity, StringComparison.Ordinal)) + { + throw new ArgumentException( + "Texture payload identity must match the texture import source identity.", + nameof(identity)); + } + Width = width; Height = height; ColorProfile = colorProfile; - ArgumentNullException.ThrowIfNull(source); BinaryPayload = []; - Identity = identity ?? source.Identity; + Identity = effectiveIdentity; Format = ResoniteTexturePayloadFormat.EncodedImage; Source = source; } @@ -60,27 +69,28 @@ internal ResoniteTexturePayload( string? colorProfile, IRawTexturePayloadSource source) { + ArgumentNullException.ThrowIfNull(source); + RawTexturePayload.EnsureValidDimensions(width, height); Width = width; Height = height; ColorProfile = colorProfile; - ArgumentNullException.ThrowIfNull(source); BinaryPayload = []; Identity = source.Identity; Format = ResoniteTexturePayloadFormat.RawRgba32; Source = source; } - public int? Width { get; init; } + public int? Width { get; } - public int? Height { get; init; } + public int? Height { get; } - public string? ColorProfile { get; init; } + public string? ColorProfile { get; } - public ImmutableArray BinaryPayload { get; init; } + public ImmutableArray BinaryPayload { get; } - public string? Identity { get; init; } + public string Identity { get; } - public ResoniteTexturePayloadFormat Format { get; init; } + public ResoniteTexturePayloadFormat Format { get; } - public ITextureImportSource Source { get; init; } + public ITextureImportSource Source { get; } } diff --git a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs index c7085533..803e4b81 100644 --- a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; @@ -40,4 +41,35 @@ public void RawConstructorKeepsGeneratedIdentityConsistentWithSource() Assert.False(string.IsNullOrWhiteSpace(payload.Identity)); Assert.Equal(payload.Identity, payload.Source.Identity); } + + [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 EncodedConstructorRejectsPayloadIdentityThatDiffersFromSourceIdentity() + { + ITextureImportSource source = TextureImportSourceFactory.CreateEncodedImageInMemory( + "sRGB", + [1, 2, 3, 4], + "source:texture"); + + ArgumentException exception = Assert.Throws(() => + new TexturePayload( + 1, + 1, + "sRGB", + source, + "payload:texture")); + + Assert.Contains("identity must match", exception.Message, System.StringComparison.Ordinal); + } } diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs index b1a572c9..477598f2 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; @@ -41,4 +42,35 @@ public void RawConstructorKeepsGeneratedIdentityConsistentWithSource() Assert.False(string.IsNullOrWhiteSpace(payload.Identity)); Assert.Equal(payload.Identity, payload.Source.Identity); } + + [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 EncodedConstructorRejectsPayloadIdentityThatDiffersFromSourceIdentity() + { + ITextureImportSource source = TextureImportSourceFactory.CreateEncodedImageInMemory( + "sRGB", + [1, 2, 3, 4], + "source:texture"); + + ArgumentException exception = Assert.Throws(() => + new ResoniteTexturePayload( + 1, + 1, + "sRGB", + source, + "payload:texture")); + + Assert.Contains("identity must match", exception.Message, System.StringComparison.Ordinal); + } } diff --git a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs index ac7f024e..b3fc8204 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs @@ -63,21 +63,6 @@ public void ToInternalMaterialBindingsRejectsUnsupportedContractEnumValues(strin Assert.Throws(() => SceneImportContractMapper.ToInternal(binding)); } - [Fact] - public void ToInternalMaterialBindingsRejectsUnsupportedTexturePayloadFormat() - { - MaterialBinding validBinding = CreateValidBinding(); - MaterialBinding binding = validBinding with - { - TexturePayload = validBinding.TexturePayload! with - { - Format = (TexturePayloadFormat)999, - }, - }; - - Assert.Throws(() => SceneImportContractMapper.ToInternal(binding)); - } - [Fact] public void ToInternalMaterialBindingsReusesRawTextureSource() { @@ -112,31 +97,6 @@ public void ToInternalMaterialBindingsReusesRawTextureSource() Assert.True(mapped.TexturePayload.BinaryPayload.IsDefaultOrEmpty); } - [Fact] - public void ToInternalMaterialBindingsUsesRawSourceIdentityWhenContractIdentityDiverges() - { - TexturePayload texturePayload = new TexturePayload( - width: 1, - height: 1, - colorProfile: "linear", - binaryPayload: [0, 0, 0, 255]) with - { - Identity = "stale-contract-identity", - }; - MaterialBinding[] bindings = - [ - CreateValidBinding() with - { - TexturePayload = texturePayload, - }, - ]; - - ResoniteMaterialBinding mapped = Assert.Single(SceneImportContractMapper.ToInternal(bindings)); - - Assert.Same(texturePayload.Source, mapped.TexturePayload!.Source); - Assert.Equal(texturePayload.Source.Identity, mapped.TexturePayload.Identity); - } - [Fact] public void ToInternalMaterialBindingsKeepsTerrainOverlaySharedScopeIndependentFromProvider() { From 5836861c9ff23d98b38ea7fc1d4b31339cf891f9 Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:09:24 +0900 Subject: [PATCH 7/9] Preserve source-backed raw texture payloads --- .../Importing/SceneImportContractTypes.cs | 17 ++++++++++ ...esoniteLiveSceneImportTargetTestSupport.cs | 29 +++++++++++++---- .../Targets/ResoniteTexturePayloadTests.cs | 32 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs index 3f584479..2cee4cf9 100644 --- a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs +++ b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs @@ -129,6 +129,23 @@ public TexturePayload( effectiveIdentity); } + internal TexturePayload( + int width, + int height, + string? colorProfile, + IRawTexturePayloadSource source) + { + ArgumentNullException.ThrowIfNull(source); + RawTexturePayload.EnsureValidDimensions(width, height); + Width = width; + Height = height; + ColorProfile = colorProfile; + BinaryPayload = []; + Identity = source.Identity; + Format = TexturePayloadFormat.RawRgba32; + Source = source; + } + public TexturePayload( int? width, int? height, diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs index 3ef50158..2ef51f36 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs @@ -542,12 +542,7 @@ private static TerrainGridSampleCoverage[] CreateMeasuredTerrainGridCoverage(int private static TexturePayload ToContractTexturePayload(ResoniteTexturePayload payload) => payload.Format == ResoniteTexturePayloadFormat.RawRgba32 - ? new TexturePayload( - 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.BinaryPayload.AsSpan().ToArray(), - payload.Identity) + ? ToContractRawTexturePayload(payload) : new TexturePayload( payload.Width, payload.Height, @@ -555,6 +550,28 @@ private static TexturePayload ToContractTexturePayload(ResoniteTexturePayload pa payload.Source, payload.Identity); + 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.Identity); + } + + 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); + } + } internal sealed class RecordingTerrainTextureAssetGenerator( diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs index 477598f2..2d71d98b 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs @@ -73,4 +73,36 @@ public void EncodedConstructorRejectsPayloadIdentityThatDiffersFromSourceIdentit Assert.Contains("identity must match", exception.Message, System.StringComparison.Ordinal); } + + [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.Identity); + Assert.True(contractPayload.BinaryPayload.IsDefaultOrEmpty); + } } From ebf09ba215da5eb107990878a09ffba93d51b13f Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:37:33 +0900 Subject: [PATCH 8/9] Reuse immutable raw texture payload bytes --- .../Importing/TextureImportSource.cs | 46 ++++++++++++++--- .../SceneSinkRecordingClientCanonicalDump.cs | 8 +++ .../Resonite/ResoniteTextureImageLoader.cs | 2 +- .../Resonite/ResoniteTextureImportFactory.cs | 2 +- .../Resonite/ResoniteTexturePayload.cs | 38 ++++++++++++++ .../ResoniteLink/ResoniteLinkClient.cs | 4 +- .../Application/TexturePayloadTests.cs | 50 +++++++++++++++++++ .../Formats/CityGmlAppearanceStoreTests.cs | 2 +- .../ResoniteLiveSceneImportTargetTests.cs | 2 +- .../TerrainTextureAssetGeneratorTests.cs | 6 ++- ...nTextureGeoReferencedRasterSupportTests.cs | 8 +-- .../Transport/ResoniteLinkClientTests.cs | 2 +- 12 files changed, 150 insertions(+), 20 deletions(-) diff --git a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs index a58719b0..a66b9531 100644 --- a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs +++ b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Immutable; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -17,6 +18,36 @@ public enum RawTexturePayloadFormat 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, @@ -29,7 +60,7 @@ public RawTexturePayload( this.Width = Width; this.Height = Height; this.ColorProfile = ColorProfile; - this.Bytes = Bytes; + this.Bytes = ImmutableArray.CreateRange(Bytes); this.Format = Format; } @@ -39,7 +70,7 @@ public RawTexturePayload( public string? ColorProfile { get; } - public byte[] Bytes { get; } + public ImmutableArray Bytes { get; } public RawTexturePayloadFormat Format { get; } @@ -170,17 +201,17 @@ public InMemoryRawTextureImportSource( public ValueTask MaterializeRawAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - return ValueTask.FromResult(new RawTexturePayload( + return ValueTask.FromResult(RawTexturePayload.Create( Width, Height, ColorProfile, - bytes.AsSpan().ToArray())); + bytes)); } } internal sealed class InMemoryEncodedTextureImportSource : IRawTexturePayloadSource { - private readonly byte[] bytes; + private readonly ImmutableArray bytes; public InMemoryEncodedTextureImportSource( string? colorProfile, @@ -190,7 +221,7 @@ public InMemoryEncodedTextureImportSource( ArgumentNullException.ThrowIfNull(bytes); ArgumentException.ThrowIfNullOrWhiteSpace(identity); ColorProfile = colorProfile; - this.bytes = (byte[])bytes.Clone(); + this.bytes = ImmutableArray.CreateRange(bytes); Identity = identity; } @@ -204,7 +235,8 @@ public InMemoryEncodedTextureImportSource( 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, 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 6fe495e5..6a2495eb 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTextureImportFactory.cs @@ -34,7 +34,7 @@ public static ResoniteTexturePayload CreatePayloadFromImage( RawTexturePayload rawPayload = TextureImportSourceFactory.CreateRawPayloadFromImage( image, colorProfile); - return new ResoniteTexturePayload( + return ResoniteTexturePayload.CreateRaw( image.Width, image.Height, colorProfile, diff --git a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs index 95d22355..116a4d71 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs @@ -13,6 +13,44 @@ 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); + string effectiveIdentity = identity ?? Guid.NewGuid().ToString("N"); + Width = width; + Height = height; + ColorProfile = colorProfile; + BinaryPayload = binaryPayload; + Identity = effectiveIdentity; + Format = ResoniteTexturePayloadFormat.RawRgba32; + Source = TextureImportSourceFactory.CreateRawRgba32InMemory( + width, + height, + colorProfile, + binaryPayload, + effectiveIdentity); + } + + 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, 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 803e4b81..511bff4e 100644 --- a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs @@ -1,9 +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 @@ -33,6 +38,23 @@ public async Task ConstructorCreatesDimensionedRawTextureSource() 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 RawConstructorKeepsGeneratedIdentityConsistentWithSource() { @@ -42,6 +64,25 @@ public void RawConstructorKeepsGeneratedIdentityConsistentWithSource() Assert.Equal(payload.Identity, payload.Source.Identity); } + [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)] @@ -72,4 +113,13 @@ public void EncodedConstructorRejectsPayloadIdentityThatDiffersFromSourceIdentit Assert.Contains("identity must match", exception.Message, System.StringComparison.Ordinal); } + + 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(); + } } 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/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/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/Transport/ResoniteLinkClientTests.cs b/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs index 2e8a057b..7815afec 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] From 5f18bee79bca3968b27756f5ff40dd451007f873 Mon Sep 17 00:00:00 2001 From: esnya <2088693+esnya@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:15:57 +0900 Subject: [PATCH 9/9] Remove payload texture identity --- .../Importing/CityGmlAppearanceStore.cs | 11 +++-- .../Importing/MaterialGroupingPolicy.cs | 6 +-- .../Importing/SceneImportContractTypes.cs | 22 +++------ .../Importing/TextureImportSource.cs | 24 +++++----- .../Importing/TextureImportSourceIdentity.cs | 16 +++++++ .../Resonite/ResoniteTexturePayload.cs | 27 +++-------- .../Resonite/SceneImportContractMapper.cs | 3 +- .../Application/TexturePayloadTests.cs | 43 +++++++++++++----- .../LocalCityGmlObjectProjectionTests.cs | 22 ++++----- .../Targets/NonDemCityObjectBakerTests.cs | 14 +++--- ...esoniteLiveSceneImportTargetTestSupport.cs | 5 +-- .../Targets/ResoniteTexturePayloadTests.cs | 45 +++++++++++++------ .../Targets/SceneImportContractMapperTests.cs | 10 ++--- .../Transport/ResoniteLinkClientTests.cs | 2 +- 14 files changed, 137 insertions(+), 113 deletions(-) create mode 100644 src/PlateauResoniteLink/Application/Importing/TextureImportSourceIdentity.cs diff --git a/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs b/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs index ff4914b0..dc76fc9d 100644 --- a/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs +++ b/src/PlateauResoniteLink/Application/Importing/CityGmlAppearanceStore.cs @@ -189,15 +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}"); + $"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 2cee4cf9..5331f718 100644 --- a/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs +++ b/src/PlateauResoniteLink/Application/Importing/SceneImportContractTypes.cs @@ -114,19 +114,18 @@ public TexturePayload( ArgumentNullException.ThrowIfNull(binaryPayload); RawTexturePayload.EnsureValidShape(width, height, binaryPayload.Length, RawTexturePayloadFormat.Rgba32); ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); - string effectiveIdentity = identity ?? Guid.NewGuid().ToString("N"); + TextureImportSourceIdentity effectiveIdentity = new(identity ?? Guid.NewGuid().ToString("N")); Width = width; Height = height; ColorProfile = colorProfile; BinaryPayload = immutablePayload; - Identity = effectiveIdentity; Format = TexturePayloadFormat.RawRgba32; Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, immutablePayload, - effectiveIdentity); + effectiveIdentity.Value); } internal TexturePayload( @@ -136,12 +135,12 @@ internal TexturePayload( IRawTexturePayloadSource source) { ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Identity.Value, nameof(source)); RawTexturePayload.EnsureValidDimensions(width, height); Width = width; Height = height; ColorProfile = colorProfile; BinaryPayload = []; - Identity = source.Identity; Format = TexturePayloadFormat.RawRgba32; Source = source; } @@ -150,23 +149,14 @@ public TexturePayload( int? width, int? height, string? colorProfile, - ITextureImportSource source, - string? identity = null) + ITextureImportSource source) { ArgumentNullException.ThrowIfNull(source); - string effectiveIdentity = identity ?? source.Identity; - if (!string.Equals(effectiveIdentity, source.Identity, StringComparison.Ordinal)) - { - throw new ArgumentException( - "Texture payload identity must match the texture import source identity.", - nameof(identity)); - } - + ArgumentException.ThrowIfNullOrWhiteSpace(source.Identity.Value, nameof(source)); Width = width; Height = height; ColorProfile = colorProfile; BinaryPayload = []; - Identity = effectiveIdentity; Format = TexturePayloadFormat.EncodedImage; Source = source; } @@ -179,8 +169,6 @@ public TexturePayload( public ImmutableArray BinaryPayload { get; } - public string Identity { get; } - public TexturePayloadFormat Format { get; } public ITextureImportSource Source { get; } diff --git a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs index a66b9531..b484817e 100644 --- a/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs +++ b/src/PlateauResoniteLink/Application/Importing/TextureImportSource.cs @@ -113,7 +113,7 @@ internal static void EnsureValidDimensions(int width, int height) public interface ITextureImportSource { - string Identity { get; } + TextureImportSourceIdentity Identity { get; } string Description { get; } @@ -156,13 +156,13 @@ public InMemoryRawTextureImportSource( 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 = ImmutableArray.CreateRange(bytes); - Identity = identity; + Identity = resolvedIdentity; } public InMemoryRawTextureImportSource( @@ -177,20 +177,20 @@ public InMemoryRawTextureImportSource( throw new ArgumentException("Raw texture bytes must be initialized.", nameof(bytes)); } - ArgumentException.ThrowIfNullOrWhiteSpace(identity); + TextureImportSourceIdentity resolvedIdentity = new(identity); RawTexturePayload.EnsureValidShape(width, height, bytes.Length, RawTexturePayloadFormat.Rgba32); Width = width; Height = height; ColorProfile = colorProfile; this.bytes = bytes; - Identity = identity; + Identity = resolvedIdentity; } public int Width { get; } public int Height { get; } - public string Identity { get; } + public TextureImportSourceIdentity Identity { get; } public string Description => $"memory:{Identity}"; @@ -219,13 +219,13 @@ public InMemoryEncodedTextureImportSource( string identity) { ArgumentNullException.ThrowIfNull(bytes); - ArgumentException.ThrowIfNullOrWhiteSpace(identity); + TextureImportSourceIdentity resolvedIdentity = new(identity); ColorProfile = colorProfile; this.bytes = ImmutableArray.CreateRange(bytes); - Identity = identity; + Identity = resolvedIdentity; } - public string Identity { get; } + public TextureImportSourceIdentity Identity { get; } public string Description => $"memory:{Identity}"; @@ -250,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}"; @@ -273,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)}"; @@ -312,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; 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/ResoniteTexturePayload.cs b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs index 116a4d71..a613cb2c 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/ResoniteTexturePayload.cs @@ -26,19 +26,18 @@ private ResoniteTexturePayload( } RawTexturePayload.EnsureValidShape(width, height, binaryPayload.Length, RawTexturePayloadFormat.Rgba32); - string effectiveIdentity = identity ?? Guid.NewGuid().ToString("N"); + TextureImportSourceIdentity effectiveIdentity = new(identity ?? Guid.NewGuid().ToString("N")); Width = width; Height = height; ColorProfile = colorProfile; BinaryPayload = binaryPayload; - Identity = effectiveIdentity; Format = ResoniteTexturePayloadFormat.RawRgba32; Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, binaryPayload, - effectiveIdentity); + effectiveIdentity.Value); } internal static ResoniteTexturePayload CreateRaw( @@ -61,42 +60,32 @@ public ResoniteTexturePayload( ArgumentNullException.ThrowIfNull(binaryPayload); RawTexturePayload.EnsureValidShape(width, height, binaryPayload.Length, RawTexturePayloadFormat.Rgba32); ImmutableArray immutablePayload = ImmutableArray.CreateRange(binaryPayload); - string effectiveIdentity = identity ?? Guid.NewGuid().ToString("N"); + TextureImportSourceIdentity effectiveIdentity = new(identity ?? Guid.NewGuid().ToString("N")); Width = width; Height = height; ColorProfile = colorProfile; BinaryPayload = immutablePayload; - Identity = effectiveIdentity; Format = ResoniteTexturePayloadFormat.RawRgba32; Source = TextureImportSourceFactory.CreateRawRgba32InMemory( width, height, colorProfile, immutablePayload, - effectiveIdentity); + effectiveIdentity.Value); } public ResoniteTexturePayload( int? width, int? height, string? colorProfile, - ITextureImportSource source, - string? identity = null) + ITextureImportSource source) { ArgumentNullException.ThrowIfNull(source); - string effectiveIdentity = identity ?? source.Identity; - if (!string.Equals(effectiveIdentity, source.Identity, StringComparison.Ordinal)) - { - throw new ArgumentException( - "Texture payload identity must match the texture import source identity.", - nameof(identity)); - } - + ArgumentException.ThrowIfNullOrWhiteSpace(source.Identity.Value, nameof(source)); Width = width; Height = height; ColorProfile = colorProfile; BinaryPayload = []; - Identity = effectiveIdentity; Format = ResoniteTexturePayloadFormat.EncodedImage; Source = source; } @@ -108,12 +97,12 @@ internal ResoniteTexturePayload( IRawTexturePayloadSource source) { ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Identity.Value, nameof(source)); RawTexturePayload.EnsureValidDimensions(width, height); Width = width; Height = height; ColorProfile = colorProfile; BinaryPayload = []; - Identity = source.Identity; Format = ResoniteTexturePayloadFormat.RawRgba32; Source = source; } @@ -126,8 +115,6 @@ internal ResoniteTexturePayload( public ImmutableArray BinaryPayload { get; } - public string Identity { get; } - public ResoniteTexturePayloadFormat Format { get; } public ITextureImportSource Source { get; } diff --git a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs index 998fa9dd..bcd1fad8 100644 --- a/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs +++ b/src/PlateauResoniteLink/Targets/Resonite/SceneImportContractMapper.cs @@ -139,8 +139,7 @@ private static ResoniteTexturePayload ToInternal(TexturePayload payload) payload.Width, payload.Height, payload.ColorProfile, - payload.Source, - payload.Identity), + payload.Source), TexturePayloadFormat.RawRgba32 => ToInternalRawTexturePayload(payload), _ => throw new ArgumentOutOfRangeException(nameof(payload), payload.Format, "Unsupported texture payload format."), }; diff --git a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs index 511bff4e..24368a4b 100644 --- a/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Application/TexturePayloadTests.cs @@ -56,12 +56,11 @@ public async Task RawSourceMaterializationReusesImmutablePayloadBytes() } [Fact] - public void RawConstructorKeepsGeneratedIdentityConsistentWithSource() + public void RawConstructorCreatesGeneratedSourceIdentity() { TexturePayload payload = new(1, 1, "sRGB", [1, 2, 3, 4]); - Assert.False(string.IsNullOrWhiteSpace(payload.Identity)); - Assert.Equal(payload.Identity, payload.Source.Identity); + Assert.False(string.IsNullOrWhiteSpace(payload.Source.Identity.Value)); } [Fact] @@ -96,22 +95,31 @@ public void RawConstructorRejectsInvalidRawShape(int width, int height, int byte } [Fact] - public void EncodedConstructorRejectsPayloadIdentityThatDiffersFromSourceIdentity() + public void EncodedConstructorUsesSourceAsIdentityCarrier() { ITextureImportSource source = TextureImportSourceFactory.CreateEncodedImageInMemory( "sRGB", [1, 2, 3, 4], "source:texture"); - ArgumentException exception = Assert.Throws(() => - new TexturePayload( - 1, - 1, - "sRGB", - source, - "payload:texture")); + TexturePayload payload = new( + 1, + 1, + "sRGB", + source); + + Assert.Same(source, payload.Source); + Assert.Equal(new TextureImportSourceIdentity("source:texture"), payload.Source.Identity); + } - Assert.Contains("identity must match", exception.Message, System.StringComparison.Ordinal); + [Fact] + public void SourceBackedConstructorRejectsDefaultSourceIdentity() + { + Assert.ThrowsAny(() => new TexturePayload( + 1, + 1, + "sRGB", + new DefaultIdentityTextureImportSource())); } private static byte[] CreateEncodedPixelBytes(Rgba32 pixel) @@ -122,4 +130,15 @@ private static byte[] CreateEncodedPixelBytes(Rgba32 pixel) 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/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 2ef51f36..a859db36 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteLiveSceneImportTargetTestSupport.cs @@ -547,8 +547,7 @@ private static TexturePayload ToContractTexturePayload(ResoniteTexturePayload pa payload.Width, payload.Height, payload.ColorProfile, - payload.Source, - payload.Identity); + payload.Source); private static TexturePayload ToContractRawTexturePayload(ResoniteTexturePayload payload) { @@ -561,7 +560,7 @@ private static TexturePayload ToContractRawTexturePayload(ResoniteTexturePayload height, payload.ColorProfile, payload.BinaryPayload.AsSpan().ToArray(), - payload.Identity); + payload.Source.Identity.Value); } if (payload.Source is not IRawTexturePayloadSource rawSource) diff --git a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs index 2d71d98b..d9455604 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/ResoniteTexturePayloadTests.cs @@ -35,12 +35,11 @@ public async Task ConstructorCreatesDimensionedRawTextureSource() } [Fact] - public void RawConstructorKeepsGeneratedIdentityConsistentWithSource() + public void RawConstructorCreatesGeneratedSourceIdentity() { ResoniteTexturePayload payload = new(1, 1, "sRGB", [4, 3, 2, 1]); - Assert.False(string.IsNullOrWhiteSpace(payload.Identity)); - Assert.Equal(payload.Identity, payload.Source.Identity); + Assert.False(string.IsNullOrWhiteSpace(payload.Source.Identity.Value)); } [Theory] @@ -56,22 +55,31 @@ public void RawConstructorRejectsInvalidRawShape(int width, int height, int byte } [Fact] - public void EncodedConstructorRejectsPayloadIdentityThatDiffersFromSourceIdentity() + public void EncodedConstructorUsesSourceAsIdentityCarrier() { ITextureImportSource source = TextureImportSourceFactory.CreateEncodedImageInMemory( "sRGB", [1, 2, 3, 4], "source:texture"); - ArgumentException exception = Assert.Throws(() => - new ResoniteTexturePayload( - 1, - 1, - "sRGB", - source, - "payload:texture")); + ResoniteTexturePayload payload = new( + 1, + 1, + "sRGB", + source); - Assert.Contains("identity must match", exception.Message, System.StringComparison.Ordinal); + 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] @@ -102,7 +110,18 @@ public void ContractRoundTripPreservesSourceBackedRawPayload() TexturePayload contractPayload = Assert.IsType(contractBinding.TexturePayload); Assert.Equal(TexturePayloadFormat.RawRgba32, contractPayload.Format); Assert.Same(source, contractPayload.Source); - Assert.Equal(source.Identity, contractPayload.Identity); + 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 b3fc8204..76f4cc3f 100644 --- a/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs +++ b/tests/PlateauResoniteLink.Tests/Targets/SceneImportContractMapperTests.cs @@ -20,8 +20,7 @@ public void ToInternalMaterialBindingsPreservesNeutralContractFields() 2, 2, "sRGB", - TextureImportSourceFactory.CreateEncodedImageInMemory("sRGB", [1, 2, 3, 4], "dataset:texture"), - "dataset:texture"), + TextureImportSourceFactory.CreateEncodedImageInMemory("sRGB", [1, 2, 3, 4], "dataset:texture")), TextureSourceKind: TextureSourceKind.Dataset, Projection: MaterialProjection.Uv, DepthOffset: new MaterialDepthOffset(-1.5, 2.5), @@ -37,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); @@ -93,7 +92,7 @@ public void ToInternalMaterialBindingsReusesRawTextureSource() Assert.Equal(ResoniteTexturePayloadFormat.RawRgba32, mapped.TexturePayload!.Format); Assert.Same(texturePayload.Source, mapped.TexturePayload.Source); - Assert.Equal(texturePayload.Source.Identity, mapped.TexturePayload.Identity); + Assert.Equal(texturePayload.Source.Identity, mapped.TexturePayload.Source.Identity); Assert.True(mapped.TexturePayload.BinaryPayload.IsDefaultOrEmpty); } @@ -176,8 +175,7 @@ private static MaterialBinding CreateValidBinding() 2, 2, "sRGB", - TextureImportSourceFactory.CreateEncodedImageInMemory("sRGB", [1, 2, 3, 4], "dataset:texture"), - "dataset:texture"), + TextureImportSourceFactory.CreateEncodedImageInMemory("sRGB", [1, 2, 3, 4], "dataset:texture")), TextureSourceKind: TextureSourceKind.Dataset, Projection: MaterialProjection.Uv, DepthOffset: null, diff --git a/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs b/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs index 7815afec..12d5ac42 100644 --- a/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs +++ b/tests/PlateauResoniteLink.Tests/Transport/ResoniteLinkClientTests.cs @@ -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";