From 5ea3de4c025909aa43e633465519981c81216fb3 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 12:17:17 +0200 Subject: [PATCH 1/5] Add the UsdValue union for the USD scene value model A readonly struct scoping an authored USD value to its possible shapes, so the scene document model no longer needs object. Implements INullable, exposes TryGet accessors only and uses ArrayOf for the recursive tuple, array and matrix cases. Not yet consumed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485 --- src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs | 574 ++++++++++++++++++ src/Opc.Ua.OpenUsdScene/Scene/UsdValueKind.cs | 103 ++++ 2 files changed, 677 insertions(+) create mode 100644 src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs create mode 100644 src/Opc.Ua.OpenUsdScene/Scene/UsdValueKind.cs diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs new file mode 100644 index 0000000000..266b8dc1bb --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs @@ -0,0 +1,574 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// A value authored on a USD attribute, scoped to the shapes a .usda + /// document can express. + /// + /// + /// + /// The USD value model is recursive and ragged: besides scalars it carries + /// tuples (float3), arrays, arrays of tuples (color3f[]), + /// matrices authored as a tuple of row tuples, and asset paths and prim path + /// references that must round-trip as their own syntax. A + /// cannot express the nested "array of tuple" shape, so + /// the scene document model uses this union instead of an untyped + /// object. + /// + /// + /// The attribute's TypeName stays authoritative for how a value is + /// rendered back out - the kind adds type safety, it does not replace the + /// declared USD type. + /// + /// + /// Absence is with set, never + /// . + /// + /// + public readonly struct UsdValue : INullable, IEquatable + { + /// + /// A value that carries nothing, for an attribute that is declared but + /// has no authored value. + /// + public static UsdValue Null => default; + + /// + /// Whether this value carries nothing. + /// + public bool IsNull => m_kind == UsdValueKind.Null; + + /// + /// The shape this value carries. + /// + public UsdValueKind Kind => m_kind; + + /// + /// Creates a boolean value. + /// + /// The value. + /// The USD value. + public static UsdValue From(bool value) + { + return new UsdValue(UsdValueKind.Boolean, value ? 1L : 0L, 0.0, null, default, null); + } + + /// + /// Creates an integral value. + /// + /// The value. + /// The USD value. + public static UsdValue From(long value) + { + return new UsdValue(UsdValueKind.Integer, value, 0.0, null, default, null); + } + + /// + /// Creates a floating point value. + /// + /// The value. + /// The USD value. + public static UsdValue From(double value) + { + return new UsdValue(UsdValueKind.Double, 0L, value, null, default, null); + } + + /// + /// Creates a quoted string value. + /// + /// The text, or null for a null value. + /// The USD value. + public static UsdValue FromString(string? value) + { + return value == null + ? Null + : new UsdValue(UsdValueKind.String, 0L, 0.0, value, default, null); + } + + /// + /// Creates a bare token value. + /// + /// The token, or null for a null value. + /// The USD value. + public static UsdValue FromToken(string? value) + { + return value == null + ? Null + : new UsdValue(UsdValueKind.Token, 0L, 0.0, value, default, null); + } + + /// + /// Creates an asset path value, authored as @path@. + /// + /// The asset path, or null for a null value. + /// The USD value. + public static UsdValue FromAssetPath(string? value) + { + return value == null + ? Null + : new UsdValue(UsdValueKind.AssetPath, 0L, 0.0, value, default, null); + } + + /// + /// Creates a prim path reference, authored as </Path>. + /// + /// The prim path, or null for a null value. + /// The USD value. + public static UsdValue FromPathReference(string? value) + { + return value == null + ? Null + : new UsdValue(UsdValueKind.PathReference, 0L, 0.0, value, default, null); + } + + /// + /// Creates a fixed arity group, authored as (a, b, c). + /// + /// The components. + /// The USD value. + public static UsdValue FromTuple(ArrayOf items) + { + return new UsdValue(UsdValueKind.Tuple, 0L, 0.0, null, items, null); + } + + /// + /// Creates a sequence, authored as [a, b, c]. + /// + /// The elements. + /// The USD value. + public static UsdValue FromArray(ArrayOf items) + { + return new UsdValue(UsdValueKind.Array, 0L, 0.0, null, items, null); + } + + /// + /// Creates a matrix from its rows, each of which is a tuple. + /// + /// The rows. + /// The USD value. + public static UsdValue FromMatrix(ArrayOf rows) + { + return new UsdValue(UsdValueKind.Matrix, 0L, 0.0, null, rows, null); + } + + /// + /// Creates a nested metadata dictionary. + /// + /// The entries, or null for a null value. + /// The USD value. + public static UsdValue FromDictionary(IReadOnlyDictionary? entries) + { + return entries == null + ? Null + : new UsdValue(UsdValueKind.Dictionary, 0L, 0.0, null, default, entries); + } + + /// + /// Reads a boolean value. + /// + /// The value when this is a boolean. + /// true when this is a boolean. + public bool TryGetBoolean(out bool value) + { + value = m_integer != 0L; + return m_kind == UsdValueKind.Boolean; + } + + /// + /// Reads an integral value. + /// + /// The value when this is an integer. + /// true when this is an integer. + public bool TryGetInteger(out long value) + { + value = m_integer; + return m_kind == UsdValueKind.Integer; + } + + /// + /// Reads a floating point value. + /// + /// The value when this is a double. + /// true when this is a double. + public bool TryGetDouble(out double value) + { + value = m_double; + return m_kind == UsdValueKind.Double; + } + + /// + /// Reads any numeric value as a double, widening an integer. + /// + /// The value when this is numeric. + /// true when this is an integer or a double. + public bool TryGetNumber(out double value) + { + switch (m_kind) + { + case UsdValueKind.Integer: + value = m_integer; + return true; + case UsdValueKind.Double: + value = m_double; + return true; + case UsdValueKind.Boolean: + value = m_integer; + return true; + default: + value = 0.0; + return false; + } + } + + /// + /// Reads a quoted string value. + /// + /// The text when this is a string. + /// true when this is a string. + public bool TryGetString(out string value) + { + value = m_text ?? string.Empty; + return m_kind == UsdValueKind.String; + } + + /// + /// Reads a bare token value. + /// + /// The token when this is a token. + /// true when this is a token. + public bool TryGetToken(out string value) + { + value = m_text ?? string.Empty; + return m_kind == UsdValueKind.Token; + } + + /// + /// Reads an asset path value. + /// + /// The asset path when this is one. + /// true when this is an asset path. + public bool TryGetAssetPath(out string value) + { + value = m_text ?? string.Empty; + return m_kind == UsdValueKind.AssetPath; + } + + /// + /// Reads a prim path reference. + /// + /// The prim path when this is one. + /// true when this is a path reference. + public bool TryGetPathReference(out string value) + { + value = m_text ?? string.Empty; + return m_kind == UsdValueKind.PathReference; + } + + /// + /// Reads any textual value, whatever syntax it was authored with. + /// + /// The text when this value carries text. + /// true when this value carries text. + public bool TryGetText(out string value) + { + value = m_text ?? string.Empty; + return m_kind is UsdValueKind.String + or UsdValueKind.Token + or UsdValueKind.AssetPath + or UsdValueKind.PathReference; + } + + /// + /// Reads the components of a tuple. + /// + /// The components when this is a tuple. + /// true when this is a tuple. + public bool TryGetTuple(out ArrayOf value) + { + value = m_items; + return m_kind == UsdValueKind.Tuple; + } + + /// + /// Reads the elements of an array. + /// + /// The elements when this is an array. + /// true when this is an array. + public bool TryGetArray(out ArrayOf value) + { + value = m_items; + return m_kind == UsdValueKind.Array; + } + + /// + /// Reads the rows of a matrix. + /// + /// The rows when this is a matrix. + /// true when this is a matrix. + public bool TryGetMatrix(out ArrayOf value) + { + value = m_items; + return m_kind == UsdValueKind.Matrix; + } + + /// + /// Reads the elements of any composite value - a tuple, an array or the + /// rows of a matrix - without caring which of the three it is. + /// + /// The elements when this value is composite. + /// true when this value is composite. + public bool TryGetItems(out ArrayOf value) + { + value = m_items; + return m_kind is UsdValueKind.Tuple + or UsdValueKind.Array + or UsdValueKind.Matrix; + } + + /// + /// Reads the entries of a nested metadata dictionary. + /// + /// The entries when this is a dictionary. + /// true when this is a dictionary. + public bool TryGetDictionary(out IReadOnlyDictionary value) + { + value = m_entries ?? s_emptyEntries; + return m_kind == UsdValueKind.Dictionary; + } + + /// + public bool Equals(UsdValue other) + { + if (m_kind != other.m_kind) + { + return false; + } + switch (m_kind) + { + case UsdValueKind.Null: + return true; + case UsdValueKind.Boolean: + case UsdValueKind.Integer: + return m_integer == other.m_integer; + case UsdValueKind.Double: + return m_double.Equals(other.m_double); + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + return string.Equals(m_text, other.m_text, StringComparison.Ordinal); + case UsdValueKind.Dictionary: + return EntriesEqual(m_entries, other.m_entries); + default: + return ItemsEqual(m_items, other.m_items); + } + } + + /// + public override bool Equals(object? obj) + { + return obj is UsdValue other && Equals(other); + } + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(m_kind); + switch (m_kind) + { + case UsdValueKind.Boolean: + case UsdValueKind.Integer: + hash.Add(m_integer); + break; + case UsdValueKind.Double: + hash.Add(m_double); + break; + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + hash.Add(m_text, StringComparer.Ordinal); + break; + case UsdValueKind.Tuple: + case UsdValueKind.Array: + case UsdValueKind.Matrix: + ReadOnlySpan items = m_items.Span; + hash.Add(items.Length); + for (int ii = 0; ii < items.Length; ii++) + { + hash.Add(items[ii]); + } + break; + case UsdValueKind.Dictionary: + // Order independent so two equal dictionaries hash alike. + hash.Add(m_entries?.Count ?? 0); + break; + default: + break; + } + return hash.ToHashCode(); + } + + /// + /// Compares two values. + /// + /// The first value. + /// The second value. + /// true when the values are equal. + public static bool operator ==(UsdValue left, UsdValue right) + { + return left.Equals(right); + } + + /// + /// Compares two values. + /// + /// The first value. + /// The second value. + /// true when the values differ. + public static bool operator !=(UsdValue left, UsdValue right) + { + return !left.Equals(right); + } + + /// + public override string ToString() + { + switch (m_kind) + { + case UsdValueKind.Null: + return string.Empty; + case UsdValueKind.Boolean: + return m_integer != 0L ? "true" : "false"; + case UsdValueKind.Integer: + return m_integer.ToString(CultureInfo.InvariantCulture); + case UsdValueKind.Double: + return m_double.ToString("R", CultureInfo.InvariantCulture); + case UsdValueKind.Tuple: + case UsdValueKind.Matrix: + return "(" + JoinItems() + ")"; + case UsdValueKind.Array: + return "[" + JoinItems() + "]"; + default: + return m_text ?? string.Empty; + } + } + + private UsdValue( + UsdValueKind kind, + long integer, + double number, + string? text, + ArrayOf items, + IReadOnlyDictionary? entries) + { + m_kind = kind; + m_integer = integer; + m_double = number; + m_text = text; + m_items = items; + m_entries = entries; + } + + private string JoinItems() + { + ReadOnlySpan items = m_items.Span; + var builder = new System.Text.StringBuilder(); + for (int ii = 0; ii < items.Length; ii++) + { + if (ii > 0) + { + builder.Append(", "); + } + builder.Append(items[ii].ToString()); + } + return builder.ToString(); + } + + private static bool ItemsEqual(ArrayOf left, ArrayOf right) + { + ReadOnlySpan a = left.Span; + ReadOnlySpan b = right.Span; + if (a.Length != b.Length) + { + return false; + } + for (int ii = 0; ii < a.Length; ii++) + { + if (!a[ii].Equals(b[ii])) + { + return false; + } + } + return true; + } + + private static bool EntriesEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) + { + int leftCount = left?.Count ?? 0; + int rightCount = right?.Count ?? 0; + if (leftCount != rightCount) + { + return false; + } + if (leftCount == 0) + { + return true; + } + foreach (KeyValuePair entry in left!) + { + if (!right!.TryGetValue(entry.Key, out UsdValue other) || + !entry.Value.Equals(other)) + { + return false; + } + } + return true; + } + + private static readonly IReadOnlyDictionary s_emptyEntries = + new Dictionary(StringComparer.Ordinal); + + private readonly UsdValueKind m_kind; + private readonly long m_integer; + private readonly double m_double; + private readonly string? m_text; + private readonly ArrayOf m_items; + private readonly IReadOnlyDictionary? m_entries; + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdValueKind.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdValueKind.cs new file mode 100644 index 0000000000..eb22d0f77b --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdValueKind.cs @@ -0,0 +1,103 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// The shape a carries. + /// + /// + /// The kinds mirror what a .usda document can author for an attribute + /// value. Several kinds share a CLR representation but are kept apart because + /// USD prints them differently: a tuple is (1, 2, 3) while an array is + /// [1, 2, 3], and a token is bare where a string is quoted. + /// + public enum UsdValueKind + { + /// + /// No authored value. + /// + Null = 0, + + /// + /// true or false. + /// + Boolean, + + /// + /// An integral value. + /// + Integer, + + /// + /// A floating point value. + /// + Double, + + /// + /// A quoted string. + /// + String, + + /// + /// A bare word, such as an enumerator or an interpolation mode. + /// + Token, + + /// + /// An asset path, authored as @path@. + /// + AssetPath, + + /// + /// A prim path reference, authored as </Path>. + /// + PathReference, + + /// + /// A fixed arity group such as float3, authored as (a, b, c). + /// + Tuple, + + /// + /// A sequence, authored as [a, b, c]. + /// + Array, + + /// + /// A matrix, authored as a tuple of row tuples. + /// + Matrix, + + /// + /// A nested metadata dictionary, authored as { ... }. + /// + Dictionary + } +} From 69dadff4597481fb91e1f0812eee7889b063592a Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 12:49:28 +0200 Subject: [PATCH 2/5] Convert the OpenUSD scene value model to UsdValue UsdAttribute.Value, UsdAttribute.TimeSamples, UsdPrim.Metadata and UsdTimeSample.Value now carry UsdValue instead of object, and the reader, writer, coercion and signature code speak it end to end. UsdValueCoercion.Decoerce takes the Variant directly and reads it through its typed accessors, which removes the Variant.AsBoxedObject(BoxingBehavior.Legacy) call in UsdSceneExporter and keeps ArrayOf/MatrixOf shapes intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485 --- .../UsdSceneExporter.cs | 28 +- .../UsdSceneMaterializer.Properties.cs | 165 +++--- .../UsdSceneMaterializer.cs | 6 +- .../Conversion/UsdSceneSignature.cs | 55 +- .../Conversion/UsdValueCoercion.cs | 484 +++++++++++++----- .../Conversion/UsdaReader.cs | 167 +++--- .../Conversion/UsdaWriter.cs | 268 +++++----- src/Opc.Ua.OpenUsdScene/Scene/UsdAttribute.cs | 8 +- src/Opc.Ua.OpenUsdScene/Scene/UsdPrim.cs | 4 +- src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs | 8 +- 10 files changed, 714 insertions(+), 479 deletions(-) diff --git a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneExporter.cs b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneExporter.cs index b4114bdb75..a518ef54b6 100644 --- a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneExporter.cs +++ b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneExporter.cs @@ -209,11 +209,10 @@ private static UsdAttribute ExportAttribute( // Infer liveness from the write access, so a Mode A attribute that is not // historized still exports as live. Live = (attributeNode.AccessLevel & Opc.Ua.AccessLevels.CurrentWrite) != 0, - // BoxingBehavior.Legacy unwraps an ArrayOf/MatrixOf to the T[]/T[,] - // shapes Decoerce consumes, so array, tuple and matrix values survive the - // round trip instead of being stringified as an opaque struct (§7.2). - Value = UsdValueCoercion.Decoerce( - attributeNode.Value.AsBoxedObject(Variant.BoxingBehavior.Legacy)) + // Decoerce reads the Variant through its typed accessors, so an ArrayOf or + // MatrixOf keeps its shape and array, tuple and matrix values survive the + // round trip without a boxing accessor (§7.2). + Value = UsdValueCoercion.Decoerce(attributeNode.Value) }; // §7.2: time samples are held by the materialization result, not the node, so recover @@ -411,14 +410,14 @@ private static void ExportMetadata( /// /// Recovers a Metadata/ folder into a metadata dictionary (§6.3), the inverse of the - /// materializer's typed authoring. A leaf Property is read back in its own type — Legacy - /// boxing unwraps an ArrayOf<T>/MatrixOf<T> and Decoerce - /// reverses the §6.2 coercion, so a value round-trips as itself rather than an opaque box - /// (the same fix as the attribute-value path). A nested Metadata folder is recovered as a - /// nested dictionary, so structured customData keeps its authored nesting to any depth. + /// materializer's typed authoring. A leaf Property is read back in its own type through + /// Decoerce, which reverses the §6.2 coercion off the Variant's typed accessors, so + /// a value round-trips as itself rather than an opaque box. A nested Metadata folder is + /// recovered as a nested dictionary, so structured customData keeps its authored + /// nesting to any depth. /// private static void ReadMetadataFolder( - ISystemContext context, NodeState folder, IDictionary into) + ISystemContext context, NodeState folder, IDictionary into) { var children = new List(); folder.GetChildren(context, children); @@ -432,13 +431,12 @@ private static void ReadMetadataFolder( switch (child) { case BaseVariableState property: - into[key] = UsdValueCoercion.Decoerce( - property.Value.AsBoxedObject(Variant.BoxingBehavior.Legacy)); + into[key] = UsdValueCoercion.Decoerce(property.Value); break; case FolderState subFolder: - var nested = new Dictionary(StringComparer.Ordinal); + var nested = new Dictionary(StringComparer.Ordinal); ReadMetadataFolder(context, subFolder, nested); - into[key] = nested; + into[key] = UsdValue.FromDictionary(nested); break; } } diff --git a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs index ad32765b8e..8aaee41d41 100644 --- a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs +++ b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs @@ -345,18 +345,18 @@ private static void MaterializeMetadata( private static void MaterializeMetadataEntries( ISystemContext context, NodeState folder, - IEnumerable> entries, + IEnumerable> entries, ushort ns) { - foreach (KeyValuePair entry in entries) + foreach (KeyValuePair entry in entries) { if (string.IsNullOrEmpty(entry.Key)) { continue; } - if (TryAsNestedDictionary( - entry.Value, out IEnumerable> nested)) + if (entry.Value.TryGetDictionary( + out IReadOnlyDictionary nested)) { var subFolder = new FolderState(folder) { @@ -380,13 +380,13 @@ private static void MaterializeMetadataEntries( DataType = Opc.Ua.DataTypeIds.BaseDataType, ValueRank = ValueRanks.Scalar }; - if (entry.Value != null) + if (!entry.Value.IsNull) { if (TryCoerceMetadataValue( entry.Value, out Variant variant, out NodeId dataType, out int valueRank)) { // A recognised value keeps its type through the round trip, so the - // exporter recovers the authored object rather than an opaque string (§6.3). + // exporter recovers the authored value rather than an opaque string (§6.3). property.Value = variant; property.DataType = dataType; property.ValueRank = valueRank; @@ -395,10 +395,7 @@ private static void MaterializeMetadataEntries( { // A value of no representable type is carried as its invariant textual // form rather than dropped — the §6.3 last resort, not a typed guess. - property.Value = Variant.From( - entry.Value as string ?? - Convert.ToString(entry.Value, CultureInfo.InvariantCulture) ?? - string.Empty); + property.Value = Variant.From(entry.Value.ToString()); property.DataType = Opc.Ua.DataTypeIds.String; } } @@ -408,52 +405,45 @@ entry.Value as string ?? } /// - /// Whether a metadata value is a nested dictionary (structured customData), and if so - /// its entries. Only string-keyed dictionaries qualify; anything else is a leaf value. - /// - private static bool TryAsNestedDictionary( - object? value, out IEnumerable> entries) - { - switch (value) - { - case IReadOnlyDictionary readOnly: - entries = readOnly; - return true; - case IDictionary readWrite: - entries = readWrite; - return true; - default: - entries = Array.Empty>(); - return false; - } - } - - /// - /// Chooses a Variant, DataType and ValueRank for a leaf metadata value from its CLR type, + /// Chooses a Variant, DataType and ValueRank for a leaf metadata value from its kind, /// so a scalar keeps its exact type and an array keeps its (inferred) element type through - /// the materialize→export round trip (§6.3). Returns false for a scalar whose type is + /// the materialize→export round trip (§6.3). Returns false for a value whose kind is /// not representable, so the caller carries its textual form instead of guessing. /// private static bool TryCoerceMetadataValue( - object value, out Variant variant, out NodeId dataType, out int valueRank) + UsdValue value, out Variant variant, out NodeId dataType, out int valueRank) { valueRank = ValueRanks.Scalar; - switch (value) + switch (value.Kind) { - case bool v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Boolean; return true; - case sbyte v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.SByte; return true; - case byte v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Byte; return true; - case short v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Int16; return true; - case ushort v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.UInt16; return true; - case int v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Int32; return true; - case uint v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.UInt32; return true; - case long v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Int64; return true; - case ulong v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.UInt64; return true; - case float v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Float; return true; - case double v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Double; return true; - case string v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.String; return true; - case System.Collections.IEnumerable sequence: - return TryCoerceMetadataArray(sequence, out variant, out dataType, out valueRank); + case UsdValueKind.Boolean: + value.TryGetBoolean(out bool b); + variant = Variant.From(b); + dataType = Opc.Ua.DataTypeIds.Boolean; + return true; + case UsdValueKind.Integer: + value.TryGetInteger(out long l); + variant = Variant.From(l); + dataType = Opc.Ua.DataTypeIds.Int64; + return true; + case UsdValueKind.Double: + value.TryGetDouble(out double d); + variant = Variant.From(d); + dataType = Opc.Ua.DataTypeIds.Double; + return true; + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + value.TryGetText(out string s); + variant = Variant.From(s); + dataType = Opc.Ua.DataTypeIds.String; + return true; + case UsdValueKind.Tuple: + case UsdValueKind.Array: + case UsdValueKind.Matrix: + value.TryGetItems(out ArrayOf items); + return TryCoerceMetadataArray(items, out variant, out dataType, out valueRank); default: variant = default; dataType = Opc.Ua.DataTypeIds.String; @@ -462,57 +452,52 @@ private static bool TryCoerceMetadataValue( } /// - /// Coerces a metadata array or list into a typed 1-D Variant, inferring the element type - /// from the first non-null element. A homogeneous numeric or boolean array keeps its + /// Coerces a metadata array or tuple into a typed 1-D Variant, inferring the element type + /// from the first non-absent element. A homogeneous numeric or boolean sequence keeps its /// element type; an empty or mixed sequence is carried as a string array so it round-trips /// as a sequence rather than being dropped (§6.3, fail closed — no numeric guess). /// private static bool TryCoerceMetadataArray( - System.Collections.IEnumerable sequence, + ArrayOf sequence, out Variant variant, out NodeId dataType, out int valueRank) { valueRank = ValueRanks.OneDimension; - var items = new List(); - foreach (object? item in sequence) - { - items.Add(item); - } - object? first = null; - foreach (object? item in items) + UsdValue[] items = sequence.ToArray() ?? []; + UsdValueKind first = UsdValueKind.Null; + for (int ii = 0; ii < items.Length; ii++) { - if (item != null) + if (!items[ii].IsNull) { - first = item; + first = items[ii].Kind; break; } } switch (first) { - case bool _ when TryFillArray(items, v => Convert.ToBoolean(v, CultureInfo.InvariantCulture), out bool[] values): - variant = Variant.From((ArrayOf)values); + case UsdValueKind.Boolean + when TryFillArray(items, static (UsdValue v, out bool r) => v.TryGetBoolean(out r), out bool[] bools): + variant = Variant.From((ArrayOf)bools); dataType = Opc.Ua.DataTypeIds.Boolean; return true; - case sbyte _ or short _ or int _ when TryFillArray(items, v => Convert.ToInt32(v, CultureInfo.InvariantCulture), out int[] values): - variant = Variant.From((ArrayOf)values); - dataType = Opc.Ua.DataTypeIds.Int32; - return true; - case long _ or uint _ when TryFillArray(items, v => Convert.ToInt64(v, CultureInfo.InvariantCulture), out long[] values): - variant = Variant.From((ArrayOf)values); + case UsdValueKind.Integer + when TryFillArray(items, static (UsdValue v, out long r) => v.TryGetInteger(out r), out long[] longs): + variant = Variant.From((ArrayOf)longs); dataType = Opc.Ua.DataTypeIds.Int64; return true; - case float _ or double _ when TryFillArray(items, v => Convert.ToDouble(v, CultureInfo.InvariantCulture), out double[] values): - variant = Variant.From((ArrayOf)values); + case UsdValueKind.Double + when TryFillArray(items, static (UsdValue v, out double r) => v.TryGetNumber(out r), out double[] doubles): + variant = Variant.From((ArrayOf)doubles); dataType = Opc.Ua.DataTypeIds.Double; return true; default: - var strings = new string[items.Count]; - for (int i = 0; i < items.Count; i++) + var strings = new string[items.Length]; + for (int i = 0; i < items.Length; i++) { - strings[i] = items[i] as string ?? - Convert.ToString(items[i], CultureInfo.InvariantCulture) ?? - string.Empty; + strings[i] = items[i].TryGetText(out string text) + ? text + : items[i].ToString(); } variant = Variant.From((ArrayOf)strings); dataType = Opc.Ua.DataTypeIds.String; @@ -520,33 +505,25 @@ private static bool TryCoerceMetadataArray( } } + private delegate bool UsdValueReader(UsdValue value, out T result); + /// - /// Fills a typed array by converting every element with , failing - /// closed if any element cannot be converted so a heterogeneous array falls back to text. + /// Fills a typed array by reading every element with , failing + /// closed if any element cannot be read so a heterogeneous array falls back to text. /// /// The element type of the array being filled. private static bool TryFillArray( - List items, Func convert, out T[] result) + UsdValue[] items, UsdValueReader read, out T[] result) { - var array = new T[items.Count]; - for (int i = 0; i < items.Count; i++) + var array = new T[items.Length]; + for (int i = 0; i < items.Length; i++) { - object? item = items[i]; - if (item == null) - { - result = Array.Empty(); - return false; - } - try - { - array[i] = convert(item); - } - catch (Exception exception) when ( - exception is InvalidCastException or FormatException or OverflowException) + if (!read(items[i], out T converted)) { result = Array.Empty(); return false; } + array[i] = converted; } result = array; return true; @@ -788,7 +765,7 @@ public UsdMaterializationRecorder(DateTime? epochUtc, double? timeCodesPerSecond public void Record(UsdAttributeState node, UsdAttribute attribute) { var samples = new List(attribute.TimeSamples.Count); - foreach (KeyValuePair sample in attribute.TimeSamples) + foreach (KeyValuePair sample in attribute.TimeSamples) { samples.Add(new UsdTimeSample(sample.Key, sample.Value)); } diff --git a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.cs b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.cs index 81293ceae5..abec76b312 100644 --- a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.cs +++ b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.cs @@ -96,8 +96,8 @@ public sealed class UsdMaterializationOptions /// Creates a time sample. /// /// The stage-timeline time code. - /// The sampled value, in the same object shapes the reader produces. - public UsdTimeSample(double timeCode, object? value) + /// The sampled value, in the same shape the reader produces. + public UsdTimeSample(double timeCode, UsdValue value) { TimeCode = timeCode; Value = value; @@ -111,7 +111,7 @@ public UsdTimeSample(double timeCode, object? value) /// /// The sampled value. /// - public object? Value { get; } + public UsdValue Value { get; } /// public bool Equals(UsdTimeSample other) diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdSceneSignature.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdSceneSignature.cs index b50ba90540..09192ef102 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdSceneSignature.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdSceneSignature.cs @@ -171,7 +171,7 @@ private static string SignAttribute(UsdAttribute attr) { sb.Append('\u0001').Append("TS("); bool first = true; - foreach (KeyValuePair sample in attr.TimeSamples) + foreach (KeyValuePair sample in attr.TimeSamples) { if (!first) { @@ -220,40 +220,41 @@ private static string SignVariant(UsdVariantSet variant) /// deterministic serialization): numbers become doubles, tuples and arrays become a single /// ordered-list form, so lossless int/float and tuple/array differences collapse. /// - private static string NormalizeValue(object? value) + private static string NormalizeValue(UsdValue value) { - switch (value) + switch (value.Kind) { - case null: + case UsdValueKind.Null: return "null"; - case bool b: + case UsdValueKind.Boolean: + value.TryGetBoolean(out bool b); return b ? "true" : "false"; - case string s: + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + value.TryGetText(out string s); return Quote(s); - case double d: + case UsdValueKind.Double: + value.TryGetDouble(out double d); return FormatNumber(d); - case float f: - return FormatNumber(f); - case long l: + case UsdValueKind.Integer: + value.TryGetInteger(out long l); return FormatNumber(l); - case int i: - return FormatNumber(i); + case UsdValueKind.Dictionary: + value.TryGetDictionary(out IReadOnlyDictionary entries); + return "{" + string.Join( + ",", + entries + .OrderBy(static e => e.Key, StringComparer.Ordinal) + .Select(static e => Quote(e.Key) + ":" + NormalizeValue(e.Value))) + + "}"; + default: + value.TryGetItems(out ArrayOf items); + return "[" + string.Join( + ",", + (items.ToArray() ?? []).Select(NormalizeValue)) + "]"; } - - if (value is object?[] tuple) - { - return "[" + string.Join(",", tuple.Select(NormalizeValue)) + "]"; - } - if (value is System.Collections.IEnumerable enumerable) - { - var items = new List(); - foreach (object? item in enumerable) - { - items.Add(NormalizeValue(item)); - } - return "[" + string.Join(",", items) + "]"; - } - return Quote(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); } private static string FormatNumber(double d) diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs index f98c565c17..48b7c80d85 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs @@ -31,6 +31,7 @@ using System.Collections; using System.Collections.Generic; using System.Globalization; +using Opc.Ua.OpenUsdScene.Scene; namespace Opc.Ua.OpenUsdScene.Conversion { @@ -68,14 +69,14 @@ public static class UsdValueCoercion /// The coerced value. /// true when the value could be represented. public static bool TryCoerce( - object? value, UsdValueTypeMapping mapping, uint componentCount, out Variant result) + UsdValue value, UsdValueTypeMapping mapping, uint componentCount, out Variant result) { result = default; if (mapping == null) { throw new ArgumentNullException(nameof(mapping)); } - if (value == null) + if (value.IsNull) { return false; } @@ -103,15 +104,15 @@ public static bool TryCoerce( } if (mapping.ValueRank == ValueRanks.OneDimension) { - IReadOnlyList items; + UsdValue[] items; if (width > 0) { // A fixed-size math type is a flat array of components, but USD authors a // matrix4d as four nested 4-tuples, so flatten to the leaves before the arity // check (mirrors the reference converter's _flat). Otherwise a matrix4d would // report four items against a width of sixteen and be dropped. - items = Flatten(value); - if (items.Count != width) + items = [.. Flatten(value)]; + if (items.Length != width) { // A fixed-size math type authored with the wrong arity cannot be honoured. return false; @@ -125,124 +126,274 @@ public static bool TryCoerce( } if (mapping.ValueRank == ValueRanks.TwoDimensions && width > 0) { - IReadOnlyList rows = AsSequence(value); - var flat = new List(rows.Count * width); - foreach (object? row in rows) + UsdValue[] rows = AsSequence(value); + var flat = new List(rows.Length * width); + foreach (UsdValue row in rows) { // Flatten each element to its leaves so an array of matrix4d (each authored as // four nested 4-tuples) is honoured. The outer array stays grouped one row per // element — only the element shape is flattened, so a genuinely nested // array-of-tuples such as color3f[] keeps its per-tuple grouping. - List cells = Flatten(row); + List cells = Flatten(row); if (cells.Count != width) { return false; } - foreach (object? cell in cells) + foreach (UsdValue cell in cells) { flat.Add(cell); } } - return TryMatrix(flat, mapping.ElementType, rows.Count, width, out result); + return TryMatrix(flat, mapping.ElementType, rows.Length, width, out result); } return false; } /// - /// Reads a materialized value back into the plain shape used by the scene document - /// model, so an export reproduces the authored form (§7.2). + /// Reads a materialized value back into the shape used by the scene document model, so an + /// export reproduces the authored form (§7.2). /// + /// + /// Takes the straight from the Variable and reads it through its + /// typed accessors, so no boxing accessor is needed and an ArrayOf<T> or + /// MatrixOf<T> keeps its shape: a matrix is regrouped into the per-row tuples + /// the document model uses for an array-of-tuples type. + /// /// The value read from the materialized Variable. /// The USD-shaped value. - public static object? Decoerce(object? value) + public static UsdValue Decoerce(in Variant value) { - if (value is string || value == null) + TypeInfo typeInfo = value.TypeInfo; + if (typeInfo.ValueRank == ValueRanks.TwoDimensions) { - return value; + return DecoerceMatrix(value, typeInfo.BuiltInType); } - if (value is Array array) + if (typeInfo.ValueRank == ValueRanks.OneDimension) { - if (array.Rank == 2) + return DecoerceArray(value, typeInfo.BuiltInType); + } + return DecoerceScalar(value, typeInfo.BuiltInType); + } + + private static UsdValue DecoerceScalar(in Variant value, BuiltInType elementType) + { + switch (elementType) + { + case BuiltInType.Boolean: + return value.TryGetValue(out bool b) ? UsdValue.From(b) : UsdValue.Null; + case BuiltInType.SByte: + return value.TryGetValue(out sbyte sb) + ? UsdValue.From(sb) + : UsdValue.Null; + case BuiltInType.Int32: + return value.TryGetValue(out int i) ? UsdValue.From(i) : UsdValue.Null; + case BuiltInType.Int64: + return value.TryGetValue(out long l) ? UsdValue.From(l) : UsdValue.Null; + case BuiltInType.UInt32: + return value.TryGetValue(out uint ui) ? UsdValue.From(ui) : UsdValue.Null; + case BuiltInType.UInt64: + return value.TryGetValue(out ulong ul) + ? UsdValue.From((long)ul) + : UsdValue.Null; + case BuiltInType.Float: + return value.TryGetValue(out float f) ? UsdValue.From(f) : UsdValue.Null; + case BuiltInType.Double: + return value.TryGetValue(out double d) ? UsdValue.From(d) : UsdValue.Null; + case BuiltInType.String: + return value.TryGetValue(out string s) + ? UsdValue.FromString(s) + : UsdValue.Null; + default: + return UsdValue.Null; + } + } + + private static UsdValue DecoerceArray(in Variant value, BuiltInType elementType) + { + switch (elementType) + { + case BuiltInType.Boolean: + return value.TryGetValue(out ArrayOf b) + ? Wrap(b, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.SByte: + return value.TryGetValue(out ArrayOf sb) + ? Wrap(sb, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.Int32: + return value.TryGetValue(out ArrayOf i) + ? Wrap(i, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.Int64: + return value.TryGetValue(out ArrayOf l) + ? Wrap(l, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.UInt32: + return value.TryGetValue(out ArrayOf ui) + ? Wrap(ui, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.UInt64: + return value.TryGetValue(out ArrayOf ul) + ? Wrap(ul, static x => UsdValue.From((long)x)) + : UsdValue.Null; + case BuiltInType.Float: + return value.TryGetValue(out ArrayOf f) + ? Wrap(f, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.Double: + return value.TryGetValue(out ArrayOf d) + ? Wrap(d, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.String: + return value.TryGetValue(out ArrayOf s) + ? Wrap(s, static x => UsdValue.FromString(x)) + : UsdValue.Null; + default: + return UsdValue.Null; + } + } + + private static UsdValue DecoerceMatrix(in Variant value, BuiltInType elementType) + { + switch (elementType) + { + case BuiltInType.Boolean: + return value.TryGetValue(out MatrixOf b) + ? Regroup(b, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.SByte: + return value.TryGetValue(out MatrixOf sb) + ? Regroup(sb, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.Int32: + return value.TryGetValue(out MatrixOf i) + ? Regroup(i, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.Int64: + return value.TryGetValue(out MatrixOf l) + ? Regroup(l, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.UInt32: + return value.TryGetValue(out MatrixOf ui) + ? Regroup(ui, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.UInt64: + return value.TryGetValue(out MatrixOf ul) + ? Regroup(ul, static x => UsdValue.From((long)x)) + : UsdValue.Null; + case BuiltInType.Float: + return value.TryGetValue(out MatrixOf f) + ? Regroup(f, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.Double: + return value.TryGetValue(out MatrixOf d) + ? Regroup(d, static x => UsdValue.From(x)) + : UsdValue.Null; + case BuiltInType.String: + return value.TryGetValue(out MatrixOf s) + ? Regroup(s, static x => UsdValue.FromString(x)) + : UsdValue.Null; + default: + return UsdValue.Null; + } + } + + private static UsdValue Wrap(ArrayOf source, Func project) + { + System.ReadOnlySpan span = source.Span; + var items = new UsdValue[span.Length]; + for (int ii = 0; ii < span.Length; ii++) + { + items[ii] = project(span[ii]); + } + return UsdValue.FromArray(items.ToArrayOf()); + } + + /// + /// Regroups a rectangular matrix into one tuple per row, which is how the scene document + /// model carries an array-of-tuples type such as color3f[]. + /// + /// The matrix element type. + /// The matrix read from the Variable. + /// Projects one element onto a USD value. + /// An array of per-row tuples. + private static UsdValue Regroup(MatrixOf source, Func project) + { + int[] dimensions = source.Dimensions; + int rows = dimensions.Length > 0 ? dimensions[0] : 0; + int width = dimensions.Length > 1 ? dimensions[1] : 0; + System.ReadOnlySpan flat = source.Memory.Span; + var grouped = new UsdValue[rows]; + for (int r = 0; r < rows; r++) + { + var cells = new UsdValue[width]; + for (int c = 0; c < width; c++) { - // An array-of-tuples type comes back as a rectangular matrix; regroup it - // into the per-tuple rows the scene document model uses. - int rows = array.GetLength(0); - int width = array.GetLength(1); - var grouped = new object?[rows]; - for (int r = 0; r < rows; r++) - { - var cells = new object?[width]; - for (int c = 0; c < width; c++) - { - cells[c] = array.GetValue(r, c); - } - grouped[r] = cells; - } - return grouped; + int index = (r * width) + c; + cells[c] = index < flat.Length ? project(flat[index]) : UsdValue.Null; } - var items = new object?[array.Length]; - array.CopyTo(items, 0); - return items; + grouped[r] = UsdValue.FromTuple(cells.ToArrayOf()); } - return value; + return UsdValue.FromArray(grouped.ToArrayOf()); } - private static bool TryScalar(object value, BuiltInType elementType, out Variant result) + + private static bool TryScalar(UsdValue value, BuiltInType elementType, out Variant result) { result = default; switch (elementType) { case BuiltInType.Boolean: - if (!TryConvert(value, Convert.ToBoolean, out bool b)) + if (!TryAsBoolean(value, out bool b)) { return false; } result = Variant.From(b); return true; case BuiltInType.SByte: - if (!TryConvert(value, Convert.ToSByte, out sbyte sb)) + if (!TryAsSByte(value, out sbyte sb)) { return false; } result = Variant.From(sb); return true; case BuiltInType.Int32: - if (!TryConvert(value, Convert.ToInt32, out int i)) + if (!TryAsInt32(value, out int i)) { return false; } result = Variant.From(i); return true; case BuiltInType.Int64: - if (!TryConvert(value, Convert.ToInt64, out long l)) + if (!TryAsInt64(value, out long l)) { return false; } result = Variant.From(l); return true; case BuiltInType.UInt32: - if (!TryConvert(value, Convert.ToUInt32, out uint ui)) + if (!TryAsUInt32(value, out uint ui)) { return false; } result = Variant.From(ui); return true; case BuiltInType.UInt64: - if (!TryConvert(value, Convert.ToUInt64, out ulong ul)) + if (!TryAsUInt64(value, out ulong ul)) { return false; } result = Variant.From(ul); return true; case BuiltInType.Float: - if (!TryConvert(value, Convert.ToSingle, out float f)) + if (!TryAsSingle(value, out float f)) { return false; } result = Variant.From(f); return true; case BuiltInType.Double: - if (!TryConvert(value, Convert.ToDouble, out double d)) + if (!TryAsDouble(value, out double d)) { return false; } @@ -261,70 +412,70 @@ private static bool TryScalar(object value, BuiltInType elementType, out Variant } private static bool TryArray( - IReadOnlyList items, BuiltInType elementType, out Variant result) + UsdValue[] items, BuiltInType elementType, out Variant result) { result = default; switch (elementType) { case BuiltInType.Boolean: - if (!TryFill(items, Convert.ToBoolean, out bool[] b)) + if (!TryFill(items, TryAsBoolean, out bool[] b)) { return false; } result = Variant.From((ArrayOf)b); return true; case BuiltInType.SByte: - if (!TryFill(items, Convert.ToSByte, out sbyte[] sb)) + if (!TryFill(items, TryAsSByte, out sbyte[] sb)) { return false; } result = Variant.From((ArrayOf)sb); return true; case BuiltInType.Int32: - if (!TryFill(items, Convert.ToInt32, out int[] i)) + if (!TryFill(items, TryAsInt32, out int[] i)) { return false; } result = Variant.From((ArrayOf)i); return true; case BuiltInType.Int64: - if (!TryFill(items, Convert.ToInt64, out long[] l)) + if (!TryFill(items, TryAsInt64, out long[] l)) { return false; } result = Variant.From((ArrayOf)l); return true; case BuiltInType.UInt32: - if (!TryFill(items, Convert.ToUInt32, out uint[] ui)) + if (!TryFill(items, TryAsUInt32, out uint[] ui)) { return false; } result = Variant.From((ArrayOf)ui); return true; case BuiltInType.UInt64: - if (!TryFill(items, Convert.ToUInt64, out ulong[] ul)) + if (!TryFill(items, TryAsUInt64, out ulong[] ul)) { return false; } result = Variant.From((ArrayOf)ul); return true; case BuiltInType.Float: - if (!TryFill(items, Convert.ToSingle, out float[] f)) + if (!TryFill(items, TryAsSingle, out float[] f)) { return false; } result = Variant.From((ArrayOf)f); return true; case BuiltInType.Double: - if (!TryFill(items, Convert.ToDouble, out double[] d)) + if (!TryFill(items, TryAsDouble, out double[] d)) { return false; } result = Variant.From((ArrayOf)d); return true; case BuiltInType.String: - var strings = new string[items.Count]; - for (int n = 0; n < items.Count; n++) + var strings = new string[items.Length]; + for (int n = 0; n < items.Length; n++) { if (!TryStringifyLeaf(items[n], out string element)) { @@ -340,7 +491,7 @@ private static bool TryArray( } private static bool TryMatrix( - List flat, + List flat, BuiltInType elementType, int rows, int width, @@ -350,56 +501,56 @@ private static bool TryMatrix( switch (elementType) { case BuiltInType.Boolean: - if (!TryFill(flat, Convert.ToBoolean, out bool[] b)) + if (!TryFill(flat, TryAsBoolean, out bool[] b)) { return false; } result = Variant.From((MatrixOf)Reshape(b, rows, width)); return true; case BuiltInType.SByte: - if (!TryFill(flat, Convert.ToSByte, out sbyte[] sb)) + if (!TryFill(flat, TryAsSByte, out sbyte[] sb)) { return false; } result = Variant.From((MatrixOf)Reshape(sb, rows, width)); return true; case BuiltInType.Int32: - if (!TryFill(flat, Convert.ToInt32, out int[] i)) + if (!TryFill(flat, TryAsInt32, out int[] i)) { return false; } result = Variant.From((MatrixOf)Reshape(i, rows, width)); return true; case BuiltInType.Int64: - if (!TryFill(flat, Convert.ToInt64, out long[] l)) + if (!TryFill(flat, TryAsInt64, out long[] l)) { return false; } result = Variant.From((MatrixOf)Reshape(l, rows, width)); return true; case BuiltInType.UInt32: - if (!TryFill(flat, Convert.ToUInt32, out uint[] ui)) + if (!TryFill(flat, TryAsUInt32, out uint[] ui)) { return false; } result = Variant.From((MatrixOf)Reshape(ui, rows, width)); return true; case BuiltInType.UInt64: - if (!TryFill(flat, Convert.ToUInt64, out ulong[] ul)) + if (!TryFill(flat, TryAsUInt64, out ulong[] ul)) { return false; } result = Variant.From((MatrixOf)Reshape(ul, rows, width)); return true; case BuiltInType.Float: - if (!TryFill(flat, Convert.ToSingle, out float[] f)) + if (!TryFill(flat, TryAsSingle, out float[] f)) { return false; } result = Variant.From((MatrixOf)Reshape(f, rows, width)); return true; case BuiltInType.Double: - if (!TryFill(flat, Convert.ToDouble, out double[] d)) + if (!TryFill(flat, TryAsDouble, out double[] d)) { return false; } @@ -422,87 +573,166 @@ private static bool TryMatrix( } } - private static IReadOnlyList AsSequence(object? value) + private static UsdValue[] AsSequence(UsdValue value) { - if (value is IReadOnlyList list) - { - return list; - } - if (value is string || value == null) + if (value.TryGetItems(out ArrayOf items)) { - return new[] { value }; + return items.ToArray() ?? []; } - if (value is IEnumerable enumerable) - { - var items = new List(); - foreach (object? item in enumerable) - { - items.Add(item); - } - return items; - } - return new object?[] { value }; + return [value]; } /// /// Recursively flattens nested tuples and arrays to their scalar leaves, mirroring the - /// reference converter's _flat. A string is treated as a leaf, not a character - /// sequence. Used to reconcile a fixed-size math type (matrix4d authored as nested tuples) - /// with its flat component count before the arity check. + /// reference converter's _flat. Used to reconcile a fixed-size math type (matrix4d + /// authored as nested tuples) with its flat component count before the arity check. /// - private static List Flatten(object? value) + private static List Flatten(UsdValue value) { - var sink = new List(); + var sink = new List(); FlattenInto(value, sink); return sink; } - private static void FlattenInto(object? value, List sink) + private static void FlattenInto(UsdValue value, List sink) { - if (value is string || value == null) + if (value.TryGetItems(out ArrayOf items)) { - sink.Add(value); - return; - } - if (value is IEnumerable enumerable) - { - foreach (object? item in enumerable) + System.ReadOnlySpan span = items.Span; + for (int ii = 0; ii < span.Length; ii++) { - FlattenInto(item, sink); + FlattenInto(span[ii], sink); } return; } sink.Add(value); } - private static bool TryConvert( - object value, Func convert, out T result) + private delegate bool UsdConverter(UsdValue value, out T result); + + private static bool TryAsBoolean(UsdValue value, out bool result) + { + if (value.TryGetBoolean(out result)) + { + return true; + } + if (value.TryGetNumber(out double number)) + { + result = number != 0.0; + return true; + } + if (value.TryGetText(out string text)) + { + return bool.TryParse(text, out result); + } + result = false; + return false; + } + + private static bool TryAsDouble(UsdValue value, out double result) { - try + if (value.TryGetNumber(out result)) { - result = convert(value, CultureInfo.InvariantCulture); return true; } - catch (Exception ex) when (ex is InvalidCastException or FormatException - or OverflowException or ArgumentException) + if (value.TryGetText(out string text)) { - result = default!; + return double.TryParse( + text, NumberStyles.Float, CultureInfo.InvariantCulture, out result); + } + result = 0.0; + return false; + } + + private static bool TryAsSingle(UsdValue value, out float result) + { + if (!TryAsDouble(value, out double number)) + { + result = 0.0f; return false; } + result = (float)number; + return !float.IsInfinity(result) || double.IsInfinity(number); + } + + private static bool TryAsInt64(UsdValue value, out long result) + { + if (value.TryGetInteger(out result)) + { + return true; + } + if (!TryAsDouble(value, out double number)) + { + result = 0L; + return false; + } + if (number is < long.MinValue or > long.MaxValue) + { + result = 0L; + return false; + } + result = (long)number; + return true; + } + + private static bool TryAsUInt64(UsdValue value, out ulong result) + { + if (!TryAsInt64(value, out long signed) || signed < 0L) + { + result = 0UL; + return false; + } + result = (ulong)signed; + return true; + } + + private static bool TryAsSByte(UsdValue value, out sbyte result) + { + if (!TryAsInt64(value, out long signed) || + signed is < sbyte.MinValue or > sbyte.MaxValue) + { + result = 0; + return false; + } + result = (sbyte)signed; + return true; + } + + private static bool TryAsInt32(UsdValue value, out int result) + { + if (!TryAsInt64(value, out long signed) || + signed is < int.MinValue or > int.MaxValue) + { + result = 0; + return false; + } + result = (int)signed; + return true; + } + + private static bool TryAsUInt32(UsdValue value, out uint result) + { + if (!TryAsInt64(value, out long signed) || signed is < 0L or > uint.MaxValue) + { + result = 0U; + return false; + } + result = (uint)signed; + return true; } private static bool TryFill( - IReadOnlyList items, Func convert, out T[] result) + IReadOnlyList items, UsdConverter convert, out T[] result) { var target = new T[items.Count]; for (int i = 0; i < items.Count; i++) { - if (items[i] == null) + if (items[i].IsNull) { target[i] = default!; continue; } - if (!TryConvert(items[i]!, convert, out T converted)) + if (!convert(items[i], out T converted)) { result = Array.Empty(); return false; @@ -526,32 +756,40 @@ private static bool TryFill( /// /// Renders a single scalar leaf to its faithful invariant-culture string for a /// string/token/asset attribute. Succeeds only for a value that has a - /// well-defined textual form — null, a string, a bool (as its USD true/ - /// false spelling) or an (numbers, timestamps). It fails - /// closed for a structured value (a tuple/array modelled as object?[] or - /// List<object?>) or any other object, because emitting value.ToString() - /// there would publish a CLR type name such as "System.Object[]" — a plausible but - /// wrong value. The caller returns false so the attribute is left unresolved instead. + /// well-defined textual form — an absent value, text, a bool (as its USD true/ + /// false spelling) or a number. It fails closed for a structured value (a tuple, + /// array, matrix or dictionary), because rendering one here would publish a plausible but + /// wrong scalar. The caller returns false so the attribute is left unresolved. /// - private static bool TryStringifyLeaf(object? value, out string result) + private static bool TryStringifyLeaf(UsdValue value, out string result) { - switch (value) + switch (value.Kind) { - case null: + case UsdValueKind.Null: result = string.Empty; return true; - case string s: - result = s; + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + value.TryGetText(out result); return true; - case bool b: + case UsdValueKind.Boolean: + value.TryGetBoolean(out bool b); result = b ? "true" : "false"; return true; - case IFormattable f: - result = f.ToString(null, CultureInfo.InvariantCulture); + case UsdValueKind.Integer: + value.TryGetInteger(out long l); + result = l.ToString(CultureInfo.InvariantCulture); + return true; + case UsdValueKind.Double: + value.TryGetDouble(out double d); + result = d.ToString("R", CultureInfo.InvariantCulture); return true; + default: + result = string.Empty; + return false; } - result = string.Empty; - return false; } } } diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs index cee22e6995..eb16830865 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs @@ -752,52 +752,60 @@ internal static string StripComments(string text) } /// - /// Parses a scalar attribute value token into a CLR value (port of _parse_value). + /// Parses a scalar attribute value token into a + /// (port of _parse_value). /// /// - /// Integers become , floating point values , - /// tuples (a, b, c) become object?[], arrays [a, b, c] become - /// List<object?>, path references </Path> and asset paths - /// @asset@ become the inner string, and unquoted words become token strings. + /// Integers become , floating point values + /// , tuples (a, b, c) + /// , arrays [a, b, c] + /// , path references </Path> and asset + /// paths @asset@ their own kinds carrying the inner text, and unquoted words + /// . /// /// The raw value text, or null. - /// The parsed value, or null when the text is empty. - internal static object? ParseValue(string? raw) + /// The parsed value, or when the text is empty. + internal static UsdValue ParseValue(string? raw) { string v = (raw ?? string.Empty).Trim().TrimEnd(',').Trim(); if (v.Length == 0) { - return null; + return UsdValue.Null; } if (v.Length >= 2 && v[0] == '<' && v[v.Length - 1] == '>') { - return v.Substring(1, v.Length - 2); + return UsdValue.FromPathReference(v.Substring(1, v.Length - 2)); } if (v.Length >= 2 && v[0] == '@' && v[v.Length - 1] == '@') { - return v.Substring(1, v.Length - 2); + return UsdValue.FromAssetPath(v.Substring(1, v.Length - 2)); } if (string.Equals(v, "true", StringComparison.Ordinal)) { - return true; + return UsdValue.From(true); } if (string.Equals(v, "false", StringComparison.Ordinal)) { - return false; + return UsdValue.From(false); } - if (TryParseLiteral(v, out object? literal)) + if (TryParseLiteral(v, out UsdValue literal)) { return literal; } if (IntRegex().IsMatch(v)) { - return long.Parse(v, CultureInfo.InvariantCulture); + return UsdValue.From(long.Parse(v, CultureInfo.InvariantCulture)); } if (FloatRegex().IsMatch(v)) { - return double.Parse(v, NumberStyles.Float, CultureInfo.InvariantCulture); + return UsdValue.From( + double.Parse(v, NumberStyles.Float, CultureInfo.InvariantCulture)); } - return v.Trim('"'); + // A bare word is a token; anything that still carries quotes is a string whose + // quoting the literal parser could not resolve (for example an unterminated one). + return v.IndexOf('"') >= 0 + ? UsdValue.FromString(v.Trim('"')) + : UsdValue.FromToken(v); } private static List ParseTargets(string raw) @@ -812,50 +820,47 @@ private static List ParseTargets(string raw) string trimmed = raw.Trim(); if (trimmed.Length > 0 && !string.Equals(trimmed, "[]", StringComparison.Ordinal)) { - object? parsed = ParseValue(raw); - if (parsed is string single) - { - targets.Add(single); - } - else if (parsed is List list) - { - foreach (object? item in list) - { - if (item is string s) - { - targets.Add(s); - } - } - } + UsdValue parsed = ParseValue(raw); + AppendTextTargets(parsed, targets); } } return targets; } /// - /// Parses the right-hand side of an attribute .connect into its ordered targets. - /// Accepts a single bare path reference (<target>), a bracketed path-reference - /// list ([<t1>, <t2>]) authored by the writer for several targets, and an - /// empty list ([]). Preserves authored order (§5.4). + /// Collects the target paths carried by a parsed value, accepting either a single + /// textual value or a list of them. /// - private static List ParseConnectionTargets(string? valueText) + private static void AppendTextTargets(UsdValue parsed, List targets) { - var targets = new List(); - object? parsed = ParseValue(valueText); - if (parsed is string single) + if (parsed.TryGetText(out string single)) { targets.Add(single); + return; } - else if (parsed is List list) + if (parsed.TryGetArray(out ArrayOf list)) { - foreach (object? item in list) + System.ReadOnlySpan items = list.Span; + for (int ii = 0; ii < items.Length; ii++) { - if (item is string s) + if (items[ii].TryGetText(out string s)) { targets.Add(s); } } } + } + + /// + /// Parses the right-hand side of an attribute .connect into its ordered targets. + /// Accepts a single bare path reference (<target>), a bracketed path-reference + /// list ([<t1>, <t2>]) authored by the writer for several targets, and an + /// empty list ([]). Preserves authored order (§5.4). + /// + private static List ParseConnectionTargets(string? valueText) + { + var targets = new List(); + AppendTextTargets(ParseValue(valueText), targets); return targets; } @@ -1129,7 +1134,7 @@ private static int FindUnquoted(string line, char target) // ----- literal parsing (numbers / quoted strings / tuples / arrays) ----- - private static bool TryParseLiteral(string s, out object? result) + private static bool TryParseLiteral(string s, out UsdValue result) { int pos = 0; if (!TryParseLiteralValue(s, ref pos, out result)) @@ -1140,9 +1145,9 @@ private static bool TryParseLiteral(string s, out object? result) return pos == s.Length; } - private static bool TryParseLiteralValue(string s, ref int pos, out object? result) + private static bool TryParseLiteralValue(string s, ref int pos, out UsdValue result) { - result = null; + result = UsdValue.Null; SkipWhitespace(s, ref pos); if (pos >= s.Length) { @@ -1151,11 +1156,11 @@ private static bool TryParseLiteralValue(string s, ref int pos, out object? resu char c = s[pos]; if (c == '(') { - return TryParseSequence(s, ref pos, '(', ')', asTuple: true, out result); + return TryParseSequence(s, ref pos, ')', asTuple: true, out result); } if (c == '[') { - return TryParseSequence(s, ref pos, '[', ']', asTuple: false, out result); + return TryParseSequence(s, ref pos, ']', asTuple: false, out result); } if (c == '"' || c == '\'') { @@ -1178,49 +1183,50 @@ private static bool TryParseLiteralValue(string s, ref int pos, out object? resu // A USD asset-path element inside a bracketed array literal is authored '@path@' // (symmetric with the writer's asset[] rendering, §6.2), so accept it as a leaf. - private static bool TryParseAssetReference(string s, ref int pos, out object? result) + private static bool TryParseAssetReference(string s, ref int pos, out UsdValue result) { - result = null; + result = UsdValue.Null; int end = s.IndexOf('@', pos + 1); if (end < 0) { return false; } - result = s.Substring(pos + 1, end - pos - 1); + result = UsdValue.FromAssetPath(s.Substring(pos + 1, end - pos - 1)); pos = end + 1; return true; } // A path-reference element inside a bracketed list is authored '' — used by a // multi-target '.connect' list (§5.4). Accept it so the list re-parses to its targets. - private static bool TryParsePathReference(string s, ref int pos, out object? result) + private static bool TryParsePathReference(string s, ref int pos, out UsdValue result) { - result = null; + result = UsdValue.Null; int end = s.IndexOf('>', pos + 1); if (end < 0) { return false; } - result = s.Substring(pos + 1, end - pos - 1); + result = UsdValue.FromPathReference(s.Substring(pos + 1, end - pos - 1)); pos = end + 1; return true; } - private static bool TryParseSequence(string s, ref int pos, char open, char close, bool asTuple, out object? result) + private static bool TryParseSequence( + string s, ref int pos, char close, bool asTuple, out UsdValue result) { - result = null; + result = UsdValue.Null; pos++; // consume opening bracket - var items = new List(); + var items = new List(); SkipWhitespace(s, ref pos); if (pos < s.Length && s[pos] == close) { pos++; - result = asTuple ? (object)items.ToArray() : items; + result = Compose(items, asTuple); return true; } while (true) { - if (!TryParseLiteralValue(s, ref pos, out object? item)) + if (!TryParseLiteralValue(s, ref pos, out UsdValue item)) { return false; } @@ -1248,13 +1254,19 @@ private static bool TryParseSequence(string s, ref int pos, char open, char clos } return false; } - result = asTuple ? (object)items.ToArray() : items; + result = Compose(items, asTuple); return true; } - private static bool TryParseQuoted(string s, ref int pos, out object? result) + private static UsdValue Compose(List items, bool asTuple) + { + ArrayOf values = items.ToArrayOf(); + return asTuple ? UsdValue.FromTuple(values) : UsdValue.FromArray(values); + } + + private static bool TryParseQuoted(string s, ref int pos, out UsdValue result) { - result = null; + result = UsdValue.Null; char quote = s[pos]; pos++; var sb = new StringBuilder(); @@ -1277,7 +1289,7 @@ private static bool TryParseQuoted(string s, ref int pos, out object? result) if (c == quote) { pos++; - result = sb.ToString(); + result = UsdValue.FromString(sb.ToString()); return true; } sb.Append(c); @@ -1286,9 +1298,9 @@ private static bool TryParseQuoted(string s, ref int pos, out object? result) return false; } - private static bool TryParseNumber(string s, ref int pos, out object? result) + private static bool TryParseNumber(string s, ref int pos, out UsdValue result) { - result = null; + result = UsdValue.Null; int start = pos; while (pos < s.Length) { @@ -1303,12 +1315,13 @@ private static bool TryParseNumber(string s, ref int pos, out object? result) string token = s.Substring(start, pos - start); if (IntRegex().IsMatch(token)) { - result = long.Parse(token, CultureInfo.InvariantCulture); + result = UsdValue.From(long.Parse(token, CultureInfo.InvariantCulture)); return true; } if (FloatRegex().IsMatch(token)) { - result = double.Parse(token, NumberStyles.Float, CultureInfo.InvariantCulture); + result = UsdValue.From( + double.Parse(token, NumberStyles.Float, CultureInfo.InvariantCulture)); return true; } return false; @@ -1577,7 +1590,7 @@ private static void ApplyPrimMeta(UsdPrim prim, List metaLines) private static void ApplyCustomPrimMeta(UsdPrim prim, string block) { int pos = 0; - while (TryReadMetaEntry(block, ref pos, out string key, out object? value, out bool qualified)) + while (TryReadMetaEntry(block, ref pos, out string key, out UsdValue value, out bool qualified)) { if (key.Length == 0 || qualified || s_wellKnownPrimMeta.Contains(key)) { @@ -1592,10 +1605,10 @@ private static void ApplyCustomPrimMeta(UsdPrim prim, string block) // 'key = value' (for example leftover list-op arc text), it still advances and returns true with an empty key so the caller skips it and keeps scanning. private static bool TryReadMetaEntry( - string s, ref int pos, out string key, out object? value, out bool qualified) + string s, ref int pos, out string key, out UsdValue value, out bool qualified) { key = string.Empty; - value = null; + value = UsdValue.Null; qualified = false; SkipMetaSeparators(s, ref pos); @@ -1667,14 +1680,14 @@ private static bool TryReadMetaEntry( } // Reads a single metadata value: a nested '{ … }' dictionary (parsed recursively into a - // Dictionary), or a scalar/tuple/array/asset/path token read up to the next + // Dictionary), or a scalar/tuple/array/asset/path token read up to the next // depth-0 ',' or newline and parsed by ParseValue. Honours quoted and '@…@' spans. - private static object? ReadMetaValue(string s, ref int pos) + private static UsdValue ReadMetaValue(string s, ref int pos) { SkipInlineMetaWhitespace(s, ref pos); if (pos >= s.Length) { - return null; + return UsdValue.Null; } if (s[pos] == '{') { @@ -1683,16 +1696,16 @@ private static bool TryReadMetaEntry( int innerLen = Math.Max(0, end - 1 - innerStart); string inner = s.Substring(innerStart, innerLen); pos = end; - var dict = new Dictionary(StringComparer.Ordinal); + var dict = new Dictionary(StringComparer.Ordinal); int innerPos = 0; - while (TryReadMetaEntry(inner, ref innerPos, out string k, out object? v, out _)) + while (TryReadMetaEntry(inner, ref innerPos, out string k, out UsdValue v, out _)) { if (k.Length > 0) { dict[k] = v; } } - return dict; + return UsdValue.FromDictionary(dict); } int valueStart = pos; @@ -1959,7 +1972,7 @@ private static UsdPrim ClonePrim(UsdPrim source, string? newName) } clone.VariantSets.Add(clonedSet); } - foreach (KeyValuePair kv in source.Metadata) + foreach (KeyValuePair kv in source.Metadata) { clone.Metadata[kv.Key] = kv.Value; } @@ -1984,7 +1997,7 @@ private static UsdAttribute CloneAttribute(UsdAttribute source) { clone.Connections.Add(connection); } - foreach (KeyValuePair sample in source.TimeSamples) + foreach (KeyValuePair sample in source.TimeSamples) { clone.TimeSamples[sample.Key] = sample.Value; } diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs index 9303fc784d..6ca39b570c 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs @@ -189,7 +189,7 @@ private static void EmitPrim(UsdPrim prim, int indent, List lines) lines.Add(pad + " instanceable = true"); } lines.AddRange(varLines); - foreach (KeyValuePair entry in prim.Metadata) + foreach (KeyValuePair entry in prim.Metadata) { EmitMetaEntry(entry.Key, entry.Value, pad + " ", lines, typed: false); } @@ -326,7 +326,7 @@ private static string FormatConnectionTargets(IList connections) private static void EmitTimeSamples(UsdAttribute attr, string attrPad, string pre, List lines) { lines.Add(attrPad + pre + attr.TypeName + " " + attr.Name + ".timeSamples = {"); - foreach (KeyValuePair sample in attr.TimeSamples) + foreach (KeyValuePair sample in attr.TimeSamples) { lines.Add(attrPad + " " + FormatTimeCode(sample.Key) + ": " + UsdVal(sample.Value, attr.TypeName) + ","); @@ -500,78 +500,96 @@ private static string EscapeTripleQuoted(string s) /// The parsed USD value. /// The rendered .usda text when the method returns true. /// true when the value could be rendered. - internal static bool TryRenderOpaqueValue(object? value, out string text) + internal static bool TryRenderOpaqueValue(UsdValue value, out string text) { text = string.Empty; - switch (value) + switch (value.Kind) { - case null: + case UsdValueKind.Null: return true; - case bool b: + case UsdValueKind.Boolean: + value.TryGetBoolean(out bool b); text = b ? "true" : "false"; return true; - case string s: - text = s; + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + value.TryGetText(out text); return true; - case double: - case float: - case long: - case int: + case UsdValueKind.Integer: + case UsdValueKind.Double: text = PyStr(value); return true; + case UsdValueKind.Tuple: + case UsdValueKind.Matrix: + value.TryGetItems(out ArrayOf tuple); + return TryRenderOpaqueSequence(tuple, asTuple: true, out text); + case UsdValueKind.Array: + value.TryGetArray(out ArrayOf list); + return TryRenderOpaqueSequence(list, asTuple: false, out text); + default: + return false; } - if (value is object?[] tuple) - { - return TryRenderOpaqueSequence(tuple, asTuple: true, out text); - } - if (value is List list) - { - return TryRenderOpaqueSequence(list, asTuple: false, out text); - } - return false; } private static bool TryRenderOpaqueSequence( - IReadOnlyList items, bool asTuple, out string text) + ArrayOf items, bool asTuple, out string text) { text = string.Empty; - var parts = new List(items.Count); - foreach (object? item in items) + System.ReadOnlySpan span = items.Span; + var parts = new List(span.Length); + for (int ii = 0; ii < span.Length; ii++) { + UsdValue item = span[ii]; if (!TryRenderOpaqueValue(item, out string part)) { return false; } // A string leaf inside a USD tuple/array is authored quoted. - parts.Add(item is string ? "\"" + part + "\"" : part); + parts.Add(IsText(item) ? "\"" + part + "\"" : part); } string joined = string.Join(", ", parts); text = asTuple ? "(" + joined + ")" : "[" + joined + "]"; return true; } + private static bool IsText(UsdValue value) + { + return value.Kind is UsdValueKind.String + or UsdValueKind.Token + or UsdValueKind.AssetPath + or UsdValueKind.PathReference; + } + + private static bool IsSequence(UsdValue value) + { + return value.Kind is UsdValueKind.Tuple + or UsdValueKind.Array + or UsdValueKind.Matrix; + } + /// /// Emits one §6.3 metadata entry (a scalar/tuple/array field or a nested dictionary) into /// the prim's ( … ) block, symmetric with UsdaReader.ApplyCustomPrimMeta. A - /// nested is emitted as a { … } block whose - /// entries are one indent deeper. A dictionary entry carries a USD value-type token + /// nested dictionary is emitted as a { … } block whose entries are one indent + /// deeper. A dictionary entry carries a USD value-type token /// (); a top-level prim metadata field does not. /// private static void EmitMetaEntry( - string key, object? value, string pad, List lines, bool typed) + string key, UsdValue value, string pad, List lines, bool typed) { - if (value is IDictionary dict) + if (value.TryGetDictionary(out IReadOnlyDictionary dict)) { lines.Add(pad + (typed ? "dictionary " : string.Empty) + key + " = {"); - foreach (KeyValuePair entry in dict) + foreach (KeyValuePair entry in dict) { EmitMetaEntry(entry.Key, entry.Value, pad + " ", lines, typed: true); } lines.Add(pad + "}"); return; } - bool isSequence = value is object?[] || value is List; - string prefix = typed && !isSequence ? MetaTypeToken(value) + " " : string.Empty; + string prefix = typed && !IsSequence(value) ? MetaTypeToken(value) + " " : string.Empty; lines.Add(pad + prefix + key + " = " + RenderMetaScalar(value)); } @@ -580,19 +598,17 @@ private static void EmitMetaEntry( /// token is only cosmetic on a round trip (the reader discards it), so an integer maps to /// int and a floating point value to double. /// - private static string MetaTypeToken(object? value) + private static string MetaTypeToken(UsdValue value) { - switch (value) + switch (value.Kind) { - case bool _: + case UsdValueKind.Boolean: return "bool"; - case long _: - case int _: + case UsdValueKind.Integer: return "int"; - case double _: - case float _: + case UsdValueKind.Double: return "double"; - case IDictionary _: + case UsdValueKind.Dictionary: return "dictionary"; default: return "string"; @@ -605,55 +621,64 @@ private static string MetaTypeToken(object? value) /// same invariant formatting as attribute values, and a tuple/array falls back to /// . /// - private static string RenderMetaScalar(object? value) + private static string RenderMetaScalar(UsdValue value) { - switch (value) + switch (value.Kind) { - case null: + case UsdValueKind.Null: return "\"\""; - case string s: + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + value.TryGetText(out string s); return "\"" + s + "\""; - case bool b: + case UsdValueKind.Boolean: + value.TryGetBoolean(out bool b); return b ? "true" : "false"; - case double d: + case UsdValueKind.Double: + value.TryGetDouble(out double d); return FormatDouble(d); - case float f: - return FormatDouble(f); - case long l: + case UsdValueKind.Integer: + value.TryGetInteger(out long l); return l.ToString(CultureInfo.InvariantCulture); - case int i: - return i.ToString(CultureInfo.InvariantCulture); + default: + return PyStr(value); } - return PyStr(value); } /// /// Renders an attribute value as authored .usda text (port of _usd_val). /// - private static string UsdVal(object? value, string typeName) + private static string UsdVal(UsdValue value, string typeName) { - switch (value) + switch (value.Kind) { - case null: + case UsdValueKind.Null: return string.Empty; - case string s: + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + value.TryGetText(out string s); return RenderStringValue(s, typeName); - case bool b: + case UsdValueKind.Boolean: + value.TryGetBoolean(out bool b); return b ? "true" : "false"; + default: + break; } // Shape contract: whether a value is authored as a USD array "[...]" or as a USD tuple - // "(...)" is decided by the ATTRIBUTE TYPE, never by the CLR container that carries the - // value. An array-typed attribute (TypeName ending in "[]") always emits "[...]"; a + // "(...)" is decided by the ATTRIBUTE TYPE, never by the kind that carries the value. + // An array-typed attribute (TypeName ending in "[]") always emits "[...]"; a // fixed-size math scalar (double3, color3f, matrix4d, ...) emits a single parenthesised - // tuple "(...)" through PyStr below. Keying on the type name — rather than on - // "value is List" — keeps this contract intact no matter which container the - // coercion layer hands back: UsdValueCoercion.Decoerce returns object?[] for both a - // flat array and an array-of-tuples, while the reader returns List; both must - // round-trip to the same "[...]" text here (regression guard for the H-1 defect where - // every exported array fell through to PyStr and was corrupted into a "(...)" tuple). + // tuple "(...)" through PyStr below. Keying on the type name keeps this contract intact + // no matter which composite kind the coercion layer hands back (regression guard for + // the H-1 defect where every exported array fell through to PyStr and was corrupted + // into a "(...)" tuple). if (typeName.EndsWith("[]", System.StringComparison.Ordinal) - && TryGetSequence(value, out IReadOnlyList items)) + && value.TryGetItems(out ArrayOf items)) { string baseType = typeName.Substring(0, typeName.Length - 2); return RenderArray(items, baseType); @@ -662,27 +687,6 @@ private static string UsdVal(object? value, string typeName) return PyStr(value); } - /// - /// Exposes a value as a sequence when it is one of the two containers the document model - /// and coercion layer use for a USD array: object?[] (returned by - /// ) or List<object?> (returned by the - /// reader). Anything else is not a sequence and is rendered as a scalar/tuple by the caller. - /// - private static bool TryGetSequence(object? value, out IReadOnlyList items) - { - switch (value) - { - case object?[] array: - items = array; - return true; - case List list: - items = list; - return true; - } - items = System.Array.Empty(); - return false; - } - /// /// Renders an array-typed value as […]. A tuple-group base type /// (color3f/float3/double3/int3) handed back as a flat run of @@ -692,32 +696,33 @@ private static bool TryGetSequence(object? value, out IReadOnlyList ite /// an asset[] is authored with @…@ delimiters (§6.2); any other string element /// uses USD's double-quote form, never PyStr's single quotes. /// - private static string RenderArray(IReadOnlyList items, string baseType) + private static string RenderArray(ArrayOf items, string baseType) { bool assetArray = string.Equals(baseType, "asset", System.StringComparison.Ordinal); + System.ReadOnlySpan span = items.Span; if (s_tupleGroupTypes.Contains(baseType) - && items.Count > 0 - && items.Count % 3 == 0 - && !AnyElementIsSequence(items)) + && span.Length > 0 + && span.Length % 3 == 0 + && !AnyElementIsSequence(span)) { - var groups = new List(items.Count / 3); - for (int i = 0; i < items.Count; i += 3) + var groups = new List(span.Length / 3); + for (int i = 0; i < span.Length; i += 3) { var g = new List(3); for (int j = i; j < i + 3; j++) { - g.Add(PyStr(items[j])); + g.Add(PyStr(span[j])); } groups.Add("(" + string.Join(", ", g) + ")"); } return "[" + string.Join(", ", groups) + "]"; } - var elems = new List(items.Count); - foreach (object? x in items) + var elems = new List(span.Length); + for (int ii = 0; ii < span.Length; ii++) { - elems.Add(RenderArrayElement(x, assetArray)); + elems.Add(RenderArrayElement(span[ii], assetArray)); } return "[" + string.Join(", ", elems) + "]"; } @@ -727,20 +732,20 @@ private static string RenderArray(IReadOnlyList items, string baseType) /// any other string element double-quoted (USD's string/token form), and a grouped tuple row /// or scalar through (which parenthesises a tuple). /// - private static string RenderArrayElement(object? x, bool assetArray) + private static string RenderArrayElement(UsdValue x, bool assetArray) { - if (x is string s) + if (x.TryGetText(out string s)) { return assetArray ? "@" + s + "@" : "\"" + s + "\""; } return PyStr(x); } - private static bool AnyElementIsSequence(IReadOnlyList items) + private static bool AnyElementIsSequence(System.ReadOnlySpan items) { - foreach (object? x in items) + for (int ii = 0; ii < items.Length; ii++) { - if (x is object?[] || x is List) + if (IsSequence(items[ii])) { return true; } @@ -752,45 +757,48 @@ private static bool AnyElementIsSequence(IReadOnlyList items) /// Renders a scalar or tuple element with Python str() semantics so the output /// re-parses identically (integers plain, floats keep a decimal point, tuples parenthesised). /// - private static string PyStr(object? x) + private static string PyStr(UsdValue x) { - switch (x) + switch (x.Kind) { - case null: + case UsdValueKind.Null: return "None"; - case bool b: + case UsdValueKind.Boolean: + x.TryGetBoolean(out bool b); return b ? "True" : "False"; - case string s: + case UsdValueKind.String: + case UsdValueKind.Token: + case UsdValueKind.AssetPath: + case UsdValueKind.PathReference: + x.TryGetText(out string s); return "'" + s + "'"; - case double d: + case UsdValueKind.Double: + x.TryGetDouble(out double d); return FormatDouble(d); - case float f: - return FormatDouble(f); - case long l: + case UsdValueKind.Integer: + x.TryGetInteger(out long l); return l.ToString(CultureInfo.InvariantCulture); - case int i: - return i.ToString(CultureInfo.InvariantCulture); + case UsdValueKind.Tuple: + case UsdValueKind.Matrix: + x.TryGetItems(out ArrayOf tuple); + return "(" + JoinPyStr(tuple) + ")"; + case UsdValueKind.Array: + x.TryGetArray(out ArrayOf generic); + return "[" + JoinPyStr(generic) + "]"; + default: + return string.Empty; } + } - if (x is object?[] tuple) - { - var parts = new List(); - foreach (object? e in tuple) - { - parts.Add(PyStr(e)); - } - return "(" + string.Join(", ", parts) + ")"; - } - if (x is List generic) + private static string JoinPyStr(ArrayOf items) + { + System.ReadOnlySpan span = items.Span; + var parts = new List(span.Length); + for (int ii = 0; ii < span.Length; ii++) { - var parts = new List(); - foreach (object? e in generic) - { - parts.Add(PyStr(e)); - } - return "[" + string.Join(", ", parts) + "]"; + parts.Add(PyStr(span[ii])); } - return System.Convert.ToString(x, CultureInfo.InvariantCulture) ?? string.Empty; + return string.Join(", ", parts); } /// diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdAttribute.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdAttribute.cs index d0282e0feb..c3b4b90851 100644 --- a/src/Opc.Ua.OpenUsdScene/Scene/UsdAttribute.cs +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdAttribute.cs @@ -66,10 +66,10 @@ public UsdAttribute(string name, string typeName) public string TypeName { get; } /// - /// The resolved attribute value, or null when the attribute is declared but - /// carries no authored default. + /// The resolved attribute value. when the attribute is + /// declared but carries no authored default. /// - public object? Value { get; set; } + public UsdValue Value { get; set; } /// /// The authored USD time samples, an ordered map from time code to value kept separate @@ -79,7 +79,7 @@ public UsdAttribute(string name, string typeName) /// live default and each sample as a HistoricalAccess entry (§9). A negative or fractional /// time code is permitted. When empty the attribute has no time samples. /// - public SortedList TimeSamples { get; } = new SortedList(); + public SortedList TimeSamples { get; } = new SortedList(); /// /// Whether the attribute may vary over time. diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdPrim.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdPrim.cs index 03a9fbd68c..c439cc15b1 100644 --- a/src/Opc.Ua.OpenUsdScene/Scene/UsdPrim.cs +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdPrim.cs @@ -119,8 +119,8 @@ public UsdPrim(string name, string typeName = "") /// Metadata authored on the prim that has no well-known typed member; materialized /// under the prim's Metadata folder (§6.1). /// - public IDictionary Metadata { get; } = - new Dictionary(StringComparer.Ordinal); + public IDictionary Metadata { get; } = + new Dictionary(StringComparer.Ordinal); /// /// The child prims, in authored order. diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs index 266b8dc1bb..a3868093cc 100644 --- a/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs @@ -425,7 +425,7 @@ public override int GetHashCode() case UsdValueKind.Tuple: case UsdValueKind.Array: case UsdValueKind.Matrix: - ReadOnlySpan items = m_items.Span; + System.ReadOnlySpan items = m_items.Span; hash.Add(items.Length); for (int ii = 0; ii < items.Length; ii++) { @@ -505,7 +505,7 @@ private UsdValue( private string JoinItems() { - ReadOnlySpan items = m_items.Span; + System.ReadOnlySpan items = m_items.Span; var builder = new System.Text.StringBuilder(); for (int ii = 0; ii < items.Length; ii++) { @@ -520,8 +520,8 @@ private string JoinItems() private static bool ItemsEqual(ArrayOf left, ArrayOf right) { - ReadOnlySpan a = left.Span; - ReadOnlySpan b = right.Span; + System.ReadOnlySpan a = left.Span; + System.ReadOnlySpan b = right.Span; if (a.Length != b.Length) { return false; From bcd35c9a42f5573eeced8890f9d90745ec62c459 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 14:58:41 +0200 Subject: [PATCH 3/5] Adapt the OpenUSD suites to UsdValue and fix what they caught Adds UsdValue unit tests and moves the existing suites onto the new type without changing any expectation, so the fidelity and round trip tests still prove the conversion is lossless. They caught three real defects: - the georeference dual authoring path still read values through an object typed TryToDouble, which no longer saw CLR primitives; - a fixed size math scalar such as double3 was emitted as [1.0, 2.0, 3.0] because the writer chose brackets from the value kind. The TypeName decides the shape, so UsdVal now renders a composite as a parenthesised tuple unless the type is an array type; - UsdValue stored its components as ArrayOf, making the struct layout recursive. The .NET Framework type loader cannot resolve that and threw TypeLoadException for every use on net48. The payload is now a plain array, which is a reference type and breaks the cycle, while the public accessors still hand out ArrayOf. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485 --- .../UsdSceneMaterializer.Properties.cs | 32 +-- .../Conversion/UsdValueCoercion.cs | 7 + .../Conversion/UsdaWriter.cs | 10 + src/Opc.Ua.OpenUsdScene/NugetREADME.md | 36 +++ src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs | 48 ++-- .../AddressSpaceRoundTripTests.cs | 10 +- .../ConnectionFidelityTests.cs | 21 +- .../ConversionAsymmetryTests.cs | 22 +- .../ConversionEmitPathTests.cs | 99 +++---- .../ConversionFixTests.cs | 68 +++-- .../GeoreferenceAnchorCoercionTests.cs | 24 +- .../Opc.Ua.OpenUsd.Tests/GeoreferenceTests.cs | 16 +- .../GeoreferenceTypedPrimTests.cs | 16 +- .../MaterializerFallbackTests.cs | 11 +- .../Opc.Ua.OpenUsd.Tests/MaterializerTests.cs | 2 +- .../MetadataCoercionTests.cs | 162 +++--------- .../OptionalMemberMaterializationTests.cs | 2 +- .../PrimMetadataMaterializationTests.cs | 81 +++--- .../RobotAssetContractTests.cs | 28 +- .../SceneFidelityRoundTripTests.cs | 8 +- tests/Opc.Ua.OpenUsd.Tests/SceneQuery.cs | 2 +- .../TargetNodeIdAuthoringTests.cs | 2 +- .../TimeSampleMaterializationTests.cs | 69 ++--- .../UsdSceneDiscoveryTests.cs | 2 +- .../UsdSceneExporterFallbackTests.cs | 6 +- .../UsdSceneSignatureTests.cs | 16 +- tests/Opc.Ua.OpenUsd.Tests/UsdTestHelpers.cs | 147 +++++++++++ .../UsdTimeSampleEqualityTests.cs | 23 +- .../UsdTimeSampleTests.cs | 57 +++-- tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs | 242 ++++++++++++++++++ .../UsdaReaderCellTests.cs | 21 +- .../UsdaReaderPlantTests.cs | 41 +-- .../UsdaValueParsingTests.cs | 63 ++--- .../UsdaWriterInjectionTests.cs | 2 +- .../VariantBranchConversionTests.cs | 20 +- .../VariantBranchTests.cs | 4 +- 36 files changed, 910 insertions(+), 510 deletions(-) create mode 100644 tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs diff --git a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs index 8aaee41d41..1afad0265a 100644 --- a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs +++ b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs @@ -677,30 +677,20 @@ private static bool TryReadAnchor( return haveLatitude && haveLongitude && haveHeight; } - private static bool TryToDouble(object? value, out double result) + private static bool TryToDouble(UsdValue value, out double result) { - switch (value) + if (value.TryGetNumber(out result)) { - case double d: - result = d; - return true; - case float f: - result = f; - return true; - case long l: - result = l; - return true; - case int i: - result = i; - return true; - case string s when double.TryParse( - s, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed): - result = parsed; - return true; - default: - result = 0.0; - return false; + return true; + } + if (value.TryGetText(out string text) && double.TryParse( + text, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)) + { + result = parsed; + return true; } + result = 0.0; + return false; } private static void Attach( diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs index 48b7c80d85..5348966885 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs @@ -298,6 +298,13 @@ private static UsdValue DecoerceMatrix(in Variant value, BuiltInType elementType } } + /// + /// Wraps a one dimensional value as an array. + /// + /// The element type. + /// The array read from the Variable. + /// Projects one element onto a USD value. + /// The elements as an array. private static UsdValue Wrap(ArrayOf source, Func project) { System.ReadOnlySpan span = source.Span; diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs index 6ca39b570c..a09ae5008f 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs @@ -684,6 +684,16 @@ private static string UsdVal(UsdValue value, string typeName) return RenderArray(items, baseType); } + if (value.TryGetItems(out ArrayOf components)) + { + // A fixed-size math scalar (double3, color3f, matrix4d, ...) is authored as a + // single parenthesised tuple whatever composite kind carries it, because the + // TypeName decides the shape - not the kind. Rendering through PyStr instead + // would emit "[1.0, 2.0, 3.0]" for a value the coercion layer handed back as an + // array, which is the H-1 defect. + return "(" + JoinPyStr(components) + ")"; + } + return PyStr(value); } diff --git a/src/Opc.Ua.OpenUsdScene/NugetREADME.md b/src/Opc.Ua.OpenUsdScene/NugetREADME.md index cf556b9a42..303f318ac7 100644 --- a/src/Opc.Ua.OpenUsdScene/NugetREADME.md +++ b/src/Opc.Ua.OpenUsdScene/NugetREADME.md @@ -25,6 +25,42 @@ The value-role DataTypes follow the OPC UA idiom of conveying meaning by extendi (`Duration : Double`, `UtcTime : DateTime`), so a role such as *colour* versus *point* is discoverable from the type system while the built-in encoding stays unchanged for generic clients. +## The scene value model + +An authored USD value is carried by `UsdValue`, a readonly struct that scopes a value to the shapes +a `.usda` document can express. A `Variant` cannot stand in for it: the USD value model is recursive +and ragged, with tuples (`float3`), arrays, *arrays of tuples* (`color3f[]`), matrices authored as a +tuple of row tuples, and asset paths and prim path references that must round-trip as their own +syntax. + +`UsdValue` implements `INullable`, so an attribute with no authored value is `UsdValue.Null` — never +`UsdValue?`. Values are read through `TryGet*` accessors; there is no boxing accessor: + +```csharp +UsdAttribute radius = prim.Attributes["radius"]; + +if (radius.Value.TryGetDouble(out double r)) +{ + // a double3 or color3f arrives as a tuple instead +} + +if (radius.Value.TryGetTuple(out ArrayOf components)) +{ + foreach (UsdValue component in components.Span) + { + component.TryGetNumber(out double v); + } +} +``` + +Construction mirrors the authored syntax — `UsdValue.From(1.5)`, `UsdValue.FromToken("vertex")`, +`UsdValue.FromAssetPath("./tool.usda")`, `UsdValue.FromTuple(...)`, `UsdValue.FromArray(...)`. The +attribute's `TypeName` stays authoritative for how a value is rendered back out, so the kind adds +type safety without changing the emitted `.usda`. + +The same type carries `UsdAttribute.TimeSamples` and `UsdPrim.Metadata`, and a nested metadata +dictionary is a `UsdValue` of kind `Dictionary`. + ## Related packages - `Opc.Ua.OpenUsdScene.Server` — materializes a scene into a server address space and exports it back diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs index a3868093cc..751891c226 100644 --- a/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs @@ -82,7 +82,7 @@ namespace Opc.Ua.OpenUsdScene.Scene /// The USD value. public static UsdValue From(bool value) { - return new UsdValue(UsdValueKind.Boolean, value ? 1L : 0L, 0.0, null, default, null); + return new UsdValue(UsdValueKind.Boolean, value ? 1L : 0L, 0.0, null, null, null); } /// @@ -92,7 +92,7 @@ public static UsdValue From(bool value) /// The USD value. public static UsdValue From(long value) { - return new UsdValue(UsdValueKind.Integer, value, 0.0, null, default, null); + return new UsdValue(UsdValueKind.Integer, value, 0.0, null, null, null); } /// @@ -102,7 +102,7 @@ public static UsdValue From(long value) /// The USD value. public static UsdValue From(double value) { - return new UsdValue(UsdValueKind.Double, 0L, value, null, default, null); + return new UsdValue(UsdValueKind.Double, 0L, value, null, null, null); } /// @@ -114,7 +114,7 @@ public static UsdValue FromString(string? value) { return value == null ? Null - : new UsdValue(UsdValueKind.String, 0L, 0.0, value, default, null); + : new UsdValue(UsdValueKind.String, 0L, 0.0, value, null, null); } /// @@ -126,7 +126,7 @@ public static UsdValue FromToken(string? value) { return value == null ? Null - : new UsdValue(UsdValueKind.Token, 0L, 0.0, value, default, null); + : new UsdValue(UsdValueKind.Token, 0L, 0.0, value, null, null); } /// @@ -138,7 +138,7 @@ public static UsdValue FromAssetPath(string? value) { return value == null ? Null - : new UsdValue(UsdValueKind.AssetPath, 0L, 0.0, value, default, null); + : new UsdValue(UsdValueKind.AssetPath, 0L, 0.0, value, null, null); } /// @@ -150,7 +150,7 @@ public static UsdValue FromPathReference(string? value) { return value == null ? Null - : new UsdValue(UsdValueKind.PathReference, 0L, 0.0, value, default, null); + : new UsdValue(UsdValueKind.PathReference, 0L, 0.0, value, null, null); } /// @@ -160,7 +160,7 @@ public static UsdValue FromPathReference(string? value) /// The USD value. public static UsdValue FromTuple(ArrayOf items) { - return new UsdValue(UsdValueKind.Tuple, 0L, 0.0, null, items, null); + return new UsdValue(UsdValueKind.Tuple, 0L, 0.0, null, items.ToArray(), null); } /// @@ -170,7 +170,7 @@ public static UsdValue FromTuple(ArrayOf items) /// The USD value. public static UsdValue FromArray(ArrayOf items) { - return new UsdValue(UsdValueKind.Array, 0L, 0.0, null, items, null); + return new UsdValue(UsdValueKind.Array, 0L, 0.0, null, items.ToArray(), null); } /// @@ -180,7 +180,7 @@ public static UsdValue FromArray(ArrayOf items) /// The USD value. public static UsdValue FromMatrix(ArrayOf rows) { - return new UsdValue(UsdValueKind.Matrix, 0L, 0.0, null, rows, null); + return new UsdValue(UsdValueKind.Matrix, 0L, 0.0, null, rows.ToArray(), null); } /// @@ -192,7 +192,7 @@ public static UsdValue FromDictionary(IReadOnlyDictionary? ent { return entries == null ? Null - : new UsdValue(UsdValueKind.Dictionary, 0L, 0.0, null, default, entries); + : new UsdValue(UsdValueKind.Dictionary, 0L, 0.0, null, null, entries); } /// @@ -317,7 +317,7 @@ or UsdValueKind.AssetPath /// true when this is a tuple. public bool TryGetTuple(out ArrayOf value) { - value = m_items; + value = Items; return m_kind == UsdValueKind.Tuple; } @@ -328,7 +328,7 @@ public bool TryGetTuple(out ArrayOf value) /// true when this is an array. public bool TryGetArray(out ArrayOf value) { - value = m_items; + value = Items; return m_kind == UsdValueKind.Array; } @@ -339,7 +339,7 @@ public bool TryGetArray(out ArrayOf value) /// true when this is a matrix. public bool TryGetMatrix(out ArrayOf value) { - value = m_items; + value = Items; return m_kind == UsdValueKind.Matrix; } @@ -351,7 +351,7 @@ public bool TryGetMatrix(out ArrayOf value) /// true when this value is composite. public bool TryGetItems(out ArrayOf value) { - value = m_items; + value = Items; return m_kind is UsdValueKind.Tuple or UsdValueKind.Array or UsdValueKind.Matrix; @@ -425,7 +425,7 @@ public override int GetHashCode() case UsdValueKind.Tuple: case UsdValueKind.Array: case UsdValueKind.Matrix: - System.ReadOnlySpan items = m_items.Span; + System.ReadOnlySpan items = Span; hash.Add(items.Length); for (int ii = 0; ii < items.Length; ii++) { @@ -492,7 +492,7 @@ private UsdValue( long integer, double number, string? text, - ArrayOf items, + UsdValue[]? items, IReadOnlyDictionary? entries) { m_kind = kind; @@ -503,9 +503,13 @@ private UsdValue( m_entries = entries; } + private System.ReadOnlySpan Span => m_items ?? []; + + private ArrayOf Items => (m_items ?? []).ToArrayOf(); + private string JoinItems() { - System.ReadOnlySpan items = m_items.Span; + System.ReadOnlySpan items = Span; var builder = new System.Text.StringBuilder(); for (int ii = 0; ii < items.Length; ii++) { @@ -518,10 +522,10 @@ private string JoinItems() return builder.ToString(); } - private static bool ItemsEqual(ArrayOf left, ArrayOf right) + private static bool ItemsEqual(UsdValue[]? left, UsdValue[]? right) { - System.ReadOnlySpan a = left.Span; - System.ReadOnlySpan b = right.Span; + System.ReadOnlySpan a = left ?? []; + System.ReadOnlySpan b = right ?? []; if (a.Length != b.Length) { return false; @@ -568,7 +572,7 @@ private static bool EntriesEqual( private readonly long m_integer; private readonly double m_double; private readonly string? m_text; - private readonly ArrayOf m_items; + private readonly UsdValue[]? m_items; private readonly IReadOnlyDictionary? m_entries; } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/AddressSpaceRoundTripTests.cs b/tests/Opc.Ua.OpenUsd.Tests/AddressSpaceRoundTripTests.cs index dc56897539..6188b020e8 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/AddressSpaceRoundTripTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/AddressSpaceRoundTripTests.cs @@ -72,10 +72,10 @@ public void ParseMaterializeExport_PreservesGeoreferencedScene() var stage = new UsdStage("Geo") { DefaultPrim = "Site", UpAxis = "Z", MetersPerUnit = 1.0 }; var site = new UsdPrim("Site", "Xform"); site.ApiSchemas.Add(new UsdApiSchema("CesiumGeoreferencePrim")); - site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = 47.6062 }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(47.6062) }); site.Attributes.Add( - new UsdAttribute("cesium:anchor:longitude", "double") { Value = -122.3321 }); - site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = 56.0 }); + new UsdAttribute("cesium:anchor:longitude", "double") { Value = UsdValue.From(-122.3321) }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = UsdValue.From(56.0) }); stage.AddRootPrim(site); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -93,7 +93,7 @@ public void Export_ScalarValue_RoundTrips() { var stage = new UsdStage("S") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Attributes.Add(new UsdAttribute("radius", "double") { Value = 2.5 }); + prim.Attributes.Add(new UsdAttribute("radius", "double") { Value = UsdValue.From(2.5) }); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -117,7 +117,7 @@ public void Export_ArrayValue_RoundTrips() prim.Attributes.Add( new UsdAttribute("xformOp:translate", "double3") { - Value = new object[] { 1.0, 2.0, 3.0 } + Value = UsdTestHelpers.NumberTuple(1.0, 2.0, 3.0) }); stage.AddRootPrim(prim); diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConnectionFidelityTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConnectionFidelityTests.cs index 388273c8ff..30f8517462 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConnectionFidelityTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConnectionFidelityTests.cs @@ -59,8 +59,8 @@ public void Export_TwoConnections_PreservesAuthoredOrder() // distinguishable from any incidental sort or hash order (which would give a, b). var stage = new UsdStage("S") { DefaultPrim = "Sink" }; var src = new UsdPrim("Src", "Xform"); - src.Attributes.Add(new UsdAttribute("a", "double") { Value = 1.0 }); - src.Attributes.Add(new UsdAttribute("b", "double") { Value = 2.0 }); + src.Attributes.Add(new UsdAttribute("a", "double") { Value = UsdValue.From(1.0) }); + src.Attributes.Add(new UsdAttribute("b", "double") { Value = UsdValue.From(2.0) }); var sink = new UsdPrim("Sink", "Xform"); var input = new UsdAttribute("in", "double"); input.Connections.Add("/Src.b"); @@ -85,8 +85,8 @@ public void Materialize_RecordsAuthoredConnectionOrder_OnResult() // snapshotted verbatim onto the materialization result, keyed by attribute node. var stage = new UsdStage("S") { DefaultPrim = "Sink" }; var src = new UsdPrim("Src", "Xform"); - src.Attributes.Add(new UsdAttribute("a", "double") { Value = 1.0 }); - src.Attributes.Add(new UsdAttribute("b", "double") { Value = 2.0 }); + src.Attributes.Add(new UsdAttribute("a", "double") { Value = UsdValue.From(1.0) }); + src.Attributes.Add(new UsdAttribute("b", "double") { Value = UsdValue.From(2.0) }); var sink = new UsdPrim("Sink", "Xform"); var input = new UsdAttribute("in", "double"); input.Connections.Add("/Src.b"); @@ -115,8 +115,8 @@ public void Materialize_TwoConnectionsFromOneAttribute_DoesNotThrow() // preserved separately on the result (asserted by the tests above). var stage = new UsdStage("S") { DefaultPrim = "Sink" }; var src = new UsdPrim("Src", "Xform"); - src.Attributes.Add(new UsdAttribute("a", "double") { Value = 1.0 }); - src.Attributes.Add(new UsdAttribute("b", "double") { Value = 2.0 }); + src.Attributes.Add(new UsdAttribute("a", "double") { Value = UsdValue.From(1.0) }); + src.Attributes.Add(new UsdAttribute("b", "double") { Value = UsdValue.From(2.0) }); var sink = new UsdPrim("Sink", "Xform"); var input = new UsdAttribute("in", "double"); input.Connections.Add("/Src.b"); @@ -162,7 +162,7 @@ public void Export_MixedResolvableAndUnresolvable_PreservesBothInOrder() // authored order, proving the side channel — not the edges — drives the export. var stage = new UsdStage("S") { DefaultPrim = "Sink" }; var src = new UsdPrim("Src", "Xform"); - src.Attributes.Add(new UsdAttribute("out", "double") { Value = 1.0 }); + src.Attributes.Add(new UsdAttribute("out", "double") { Value = UsdValue.From(1.0) }); var sink = new UsdPrim("Sink", "Xform"); var input = new UsdAttribute("in", "double"); input.Connections.Add("/Missing.target"); @@ -190,9 +190,9 @@ public void Export_AttributeWithValueAndConnection_ReportsBoth() // must report both independently (§5.4, §7.2). var stage = new UsdStage("S") { DefaultPrim = "Sink" }; var src = new UsdPrim("Src", "Xform"); - src.Attributes.Add(new UsdAttribute("out", "double") { Value = 3.0 }); + src.Attributes.Add(new UsdAttribute("out", "double") { Value = UsdValue.From(3.0) }); var sink = new UsdPrim("Sink", "Xform"); - var input = new UsdAttribute("in", "double") { Value = 1.5 }; + var input = new UsdAttribute("in", "double") { Value = UsdValue.From(1.5) }; input.Connections.Add("/Src.out"); sink.Attributes.Add(input); stage.AddRootPrim(src); @@ -206,7 +206,8 @@ public void Export_AttributeWithValueAndConnection_ReportsBoth() UsdStage exported = ms.Context.ExportUsdStage(ms.Result); UsdAttribute exportedInput = AttributeOf(exported, "Sink", "in"); - Assert.That(exportedInput.Value, Is.EqualTo(1.5), + UsdTestHelpers.AssertDouble(exportedInput.Value, 1.5); + Assert.That(exportedInput.Value.IsNull, Is.False, "The exported attribute must still report its default value."); Assert.That(exportedInput.Connections, Is.EqualTo(new[] { "/Src.out" }), "The exported attribute must also report its connection."); diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs index 38b98288ea..33ae7739c2 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs @@ -84,13 +84,9 @@ private static void AssertRoundTripSignature(UsdStage expected) [Test] public void ParseValue_BracketedPathReferenceList_ParsesEachTarget() { - object? value = UsdaReader.ParseValue("[

,

]"); + UsdValue value = UsdaReader.ParseValue("[

,

]"); - Assert.That(value, Is.EqualTo(new List - { - "/P/A.outputs:surface", - "/P/B.outputs:surface", - })); + UsdTestHelpers.AssertTextItems(value, "/P/A.outputs:surface", "/P/B.outputs:surface"); } [Test] @@ -168,9 +164,9 @@ public void EmptyConnectionList_ParsesToNoTargets() [Test] public void ParseValue_AssetArray_ParsesEachElementUnwrapped() { - object? value = UsdaReader.ParseValue("[@./a.usda@, @./b.usda@]"); + UsdValue value = UsdaReader.ParseValue("[@./a.usda@, @./b.usda@]"); - Assert.That(value, Is.EqualTo(new List { "./a.usda", "./b.usda" })); + UsdTestHelpers.AssertTextItems(value, "./a.usda", "./b.usda"); } [Test] @@ -181,7 +177,7 @@ public void AssetArrayAttribute_ParsesToUnwrappedPaths() "Assets"); UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:files"); - Assert.That(attr.Value, Is.EqualTo(new List { "./a.usda", "./b.usda" })); + UsdTestHelpers.AssertTextItems(attr.Value, "./a.usda", "./b.usda"); } [Test] @@ -201,14 +197,16 @@ public void WriterAssetArrayOutput_ReparsesToTheSamePaths() var prim = new UsdPrim("P", "Xform"); prim.Attributes.Add(new UsdAttribute("inputs:files", "asset[]") { - Value = new List { "./a.usda", "./b.usda" }, + Value = UsdTestHelpers.AssetArray("./a.usda", "./b.usda"), }); stage.AddRootPrim(prim); UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); UsdAttribute reparsedAttr = AttributeNamed(reparsed, "/P", "inputs:files"); - Assert.That(reparsedAttr.Value, Is.EqualTo(new List { "./a.usda", "./b.usda" })); + Assert.That(reparsedAttr.Value.TryGetArray(out ArrayOf paths), Is.True); + Assert.That(paths.ToArray()!.Select(p => p.TryGetAssetPath(out string text) ? text : string.Empty).ToArray(), + Is.EqualTo(new[] { "./a.usda", "./b.usda" })); } [Test] @@ -219,7 +217,7 @@ public void SingleAssetScalar_StillParsesUnwrapped() "Assets"); UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:file"); - Assert.That(attr.Value, Is.EqualTo("./pump.usda")); + UsdTestHelpers.AssertAssetPath(attr.Value, "./pump.usda"); } } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs index ced721ff6e..f2783f58d9 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs @@ -76,7 +76,8 @@ private static void AssertRoundTripSignature(UsdStage expected) public void TokenArray_FromDecoercedShape_EmitsBracketedDoubleQuotedElements() { // UsdValueCoercion.Decoerce hands the writer an object?[] for a token[] array. - object? decoerced = UsdValueCoercion.Decoerce(new string[] { "xformOp:translate" }); + UsdValue decoerced = UsdValueCoercion.Decoerce( + Variant.From((ArrayOf)new[] { "xformOp:translate" })); string usda = EmitRootAttribute( "Xform", @@ -100,7 +101,7 @@ public void TokenArray_FromObjectArrayShape_EmitsBracketedArray() new UsdAttribute("xformOpOrder", "token[]") { Variability = UsdVariabilityEnum.Uniform, - Value = new object?[] { "xformOp:translate", "xformOp:scale" }, + Value = UsdTestHelpers.TokenArray("xformOp:translate", "xformOp:scale"), }); Assert.That( @@ -113,7 +114,8 @@ public void Color3fArray_FromDecoercedMatrix_EmitsBracketedTupleRows() { // A color3f[] materializes as a rectangular matrix; Decoerce regroups it into per-tuple // rows carried in an object?[]. The writer must emit "[(...)]", not "((...))". - object? decoerced = UsdValueCoercion.Decoerce(new float[,] { { 0f, 0f, 1f } }); + UsdValue decoerced = UsdValueCoercion.Decoerce( + Variant.From(new float[,] { { 0f, 0f, 1f } }.ToMatrixOf())); string usda = EmitRootAttribute( "Mesh", @@ -130,11 +132,9 @@ public void Color3fArray_FromObjectArrayShape_MultipleRows_EmitsEachTuple() "Mesh", new UsdAttribute("primvars:displayColor", "color3f[]") { - Value = new object?[] - { - new object?[] { 0.0, 0.0, 1.0 }, - new object?[] { 1.0, 1.0, 0.0 }, - }, + Value = UsdTestHelpers.Array( + UsdTestHelpers.NumberTuple(0.0, 0.0, 1.0), + UsdTestHelpers.NumberTuple(1.0, 1.0, 0.0)), }); Assert.That( @@ -145,7 +145,8 @@ public void Color3fArray_FromObjectArrayShape_MultipleRows_EmitsEachTuple() [Test] public void AssetArray_FromDecoercedShape_EmitsAtDelimitedElements() { - object? decoerced = UsdValueCoercion.Decoerce(new string[] { "./a.usda" }); + UsdValue decoerced = UsdValueCoercion.Decoerce( + Variant.From((ArrayOf)new[] { "./a.usda" })); string usda = EmitRootAttribute( "Xform", @@ -162,7 +163,8 @@ public void Double3Scalar_IsStillEmittedAsParenthesisedTuple_NotArray() { // The type-name keying must not turn a fixed-size math scalar into an array: a double3 // (ValueRank one-dimension, three components) is a single parenthesised tuple. - object? decoerced = UsdValueCoercion.Decoerce(new double[] { 1.0, 2.0, 3.0 }); + UsdValue decoerced = UsdValueCoercion.Decoerce( + Variant.From((ArrayOf)new[] { 1.0, 2.0, 3.0 })); string usda = EmitRootAttribute( "Xform", @@ -185,18 +187,18 @@ public void FullEmitPath_ArraysAndConnection_RoundTripsUnderSignature() mesh.Attributes.Add(new UsdAttribute("xformOpOrder", "token[]") { Variability = UsdVariabilityEnum.Uniform, - Value = new object?[] { "xformOp:translate", "xformOp:scale" }, + Value = UsdTestHelpers.TokenArray("xformOp:translate", "xformOp:scale"), }); mesh.Attributes.Add(new UsdAttribute("primvars:displayColor", "color3f[]") { - Value = new object?[] { new object?[] { 0.0, 0.0, 1.0 } }, + Value = UsdTestHelpers.Array(UsdTestHelpers.NumberTuple(0.0, 0.0, 1.0)), }); mesh.Attributes.Add(new UsdAttribute("inputs:files", "asset[]") { - Value = new object?[] { "./a.usda", "./b.usda" }, + Value = UsdTestHelpers.AssetArray("./a.usda", "./b.usda"), }); - var surface = new UsdAttribute("outputs:surface", "token") { Value = "fallback" }; + var surface = new UsdAttribute("outputs:surface", "token") { Value = UsdValue.FromString("fallback") }; surface.Connections.Add("/World/Shader.outputs:surface"); mesh.Attributes.Add(surface); @@ -213,7 +215,7 @@ public void ValueAndConnect_OnOneAttribute_ReparseMergesIntoSingleAttribute() { var stage = new UsdStage("Conn") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - var attr = new UsdAttribute("inputs:surface", "token") { Value = "fallback" }; + var attr = new UsdAttribute("inputs:surface", "token") { Value = UsdValue.FromString("fallback") }; attr.Connections.Add("/P/Shader.outputs:surface"); prim.Attributes.Add(attr); stage.AddRootPrim(prim); @@ -226,7 +228,7 @@ public void ValueAndConnect_OnOneAttribute_ReparseMergesIntoSingleAttribute() Assert.That(matching, Has.Count.EqualTo(1), "a value co-authored with a .connect must re-parse as one attribute, not two"); - Assert.That(matching[0].Value, Is.EqualTo("fallback")); + UsdTestHelpers.AssertText(matching[0].Value, "fallback"); Assert.That(matching[0].Connections, Is.EqualTo(new[] { "/P/Shader.outputs:surface" })); } @@ -237,10 +239,10 @@ public void ValueSamplesAndConnect_AllCoalesceOntoOneAttribute() var prim = new UsdPrim("P", "Xform"); var attr = new UsdAttribute("xformOp:translate", "double3") { - Value = new object?[] { 1.0, 2.0, 3.0 }, + Value = UsdTestHelpers.NumberTuple(1.0, 2.0, 3.0), }; - attr.TimeSamples[0.0] = new object?[] { 1.0, 2.0, 3.0 }; - attr.TimeSamples[24.0] = new object?[] { 4.0, 5.0, 6.0 }; + attr.TimeSamples[0.0] = UsdTestHelpers.NumberTuple(1.0, 2.0, 3.0); + attr.TimeSamples[24.0] = UsdTestHelpers.NumberTuple(4.0, 5.0, 6.0); attr.Connections.Add("/P/Rig.outputs:translate"); prim.Attributes.Add(attr); stage.AddRootPrim(prim); @@ -255,7 +257,7 @@ public void ValueSamplesAndConnect_AllCoalesceOntoOneAttribute() "value, time samples and .connect on one attribute must not split into duplicates"); Assert.That(matching[0].Connections, Is.EqualTo(new[] { "/P/Rig.outputs:translate" })); Assert.That(matching[0].TimeSamples, Has.Count.EqualTo(2)); - Assert.That(matching[0].Value, Is.Not.Null); + Assert.That(matching[0].Value.IsNull, Is.False); } // ---- M-1: a known string/token attribute with a structured value fails closed ---- @@ -267,7 +269,9 @@ public void KnownTokenArray_WithTupleElements_FailsClosed_NeverPublishesClrTypeN uint components = UsdValueTypeMap.ComponentCount("token[]"); bool ok = UsdValueCoercion.TryCoerce( - new object?[] { new object?[] { 1L, 2L }, new object?[] { 3L, 4L } }, + UsdTestHelpers.Array( + UsdTestHelpers.IntegerTuple(1L, 2L), + UsdTestHelpers.IntegerTuple(3L, 4L)), mapping, components, out Variant result); @@ -283,7 +287,7 @@ public void KnownStringScalar_WithTupleValue_FailsClosed() uint components = UsdValueTypeMap.ComponentCount("string"); bool ok = UsdValueCoercion.TryCoerce( - new object?[] { 1L, 2L }, mapping, components, out Variant _); + UsdTestHelpers.IntegerTuple(1L, 2L), mapping, components, out Variant _); Assert.That(ok, Is.False); } @@ -294,7 +298,7 @@ public void KnownStringScalar_WithNumber_StillStringifiesFaithfully() UsdValueTypeMapping mapping = UsdValueTypeMap.Map("string", null); uint components = UsdValueTypeMap.ComponentCount("string"); - bool ok = UsdValueCoercion.TryCoerce(42L, mapping, components, out Variant result); + bool ok = UsdValueCoercion.TryCoerce(UsdValue.From(42L), mapping, components, out Variant result); Assert.That(ok, Is.True); Assert.That(result.TryGetValue(out string rendered), Is.True); @@ -309,7 +313,7 @@ public void KnownTokenArray_WithStringElements_StillCoercesNormally() uint components = UsdValueTypeMap.ComponentCount("token[]"); bool ok = UsdValueCoercion.TryCoerce( - new object?[] { "a", "b" }, mapping, components, out Variant result); + UsdTestHelpers.TokenArray("a", "b"), mapping, components, out Variant result); Assert.That(ok, Is.True); Assert.That(result.TryGetValue(out ArrayOf tokens), Is.True); @@ -341,7 +345,7 @@ public void CloseParenInsideQuotedMetadataString_DoesNotTruncateBlock() // The metadata after the ')'-bearing string survived (the block was not truncated). Assert.That(prim.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); Assert.That(prim.Metadata.ContainsKey("comment"), Is.True); - Assert.That(prim.Metadata["comment"], Is.EqualTo("note with ) paren and ( too")); + UsdTestHelpers.AssertString(prim.Metadata["comment"], "note with ) paren and ( too"); } // ---- Defect 9: §6.3 custom prim metadata round-trips through Metadata ---- @@ -351,19 +355,19 @@ public void CustomScalarMetadata_RoundTripsThroughMetadataDictionary() { var stage = new UsdStage("Meta") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Metadata["displayName"] = "Pump Assembly"; - prim.Metadata["revision"] = 3L; - prim.Metadata["approved"] = true; - prim.Metadata["tolerance"] = 0.25; + prim.Metadata["displayName"] = UsdValue.FromString("Pump Assembly"); + prim.Metadata["revision"] = UsdValue.From(3L); + prim.Metadata["approved"] = UsdValue.From(true); + prim.Metadata["tolerance"] = UsdValue.From(0.25); stage.AddRootPrim(prim); UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); UsdPrim prim2 = reparsed.Find("/P")!; - Assert.That(prim2.Metadata["displayName"], Is.EqualTo("Pump Assembly")); - Assert.That(prim2.Metadata["revision"], Is.EqualTo(3L)); - Assert.That(prim2.Metadata["approved"], Is.True); - Assert.That(prim2.Metadata["tolerance"], Is.EqualTo(0.25)); + UsdTestHelpers.AssertString(prim2.Metadata["displayName"], "Pump Assembly"); + UsdTestHelpers.AssertInteger(prim2.Metadata["revision"], 3L); + UsdTestHelpers.AssertBoolean(prim2.Metadata["approved"], true); + UsdTestHelpers.AssertDouble(prim2.Metadata["tolerance"], 0.25); } [Test] @@ -371,25 +375,24 @@ public void CustomNestedDictionaryMetadata_RoundTripsAsNestedDictionary() { var stage = new UsdStage("Meta") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - var custom = new Dictionary(StringComparer.Ordinal) + var custom = new Dictionary(StringComparer.Ordinal) { - ["author"] = "acme", - ["weight"] = 12.5, - ["count"] = 7L, + ["author"] = UsdValue.FromString("acme"), + ["weight"] = UsdValue.From(12.5), + ["count"] = UsdValue.From(7L), }; - prim.Metadata["customData"] = custom; + prim.Metadata["customData"] = UsdValue.FromDictionary(custom); stage.AddRootPrim(prim); UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); UsdPrim prim2 = reparsed.Find("/P")!; Assert.That(prim2.Metadata.ContainsKey("customData"), Is.True); - Assert.That(prim2.Metadata["customData"], Is.InstanceOf>()); - - var nested = (IDictionary)prim2.Metadata["customData"]!; - Assert.That(nested["author"], Is.EqualTo("acme")); - Assert.That(nested["weight"], Is.EqualTo(12.5)); - Assert.That(nested["count"], Is.EqualTo(7L)); + Assert.That(prim2.Metadata["customData"].TryGetDictionary(out IReadOnlyDictionary nested), + Is.True); + UsdTestHelpers.AssertString(nested["author"], "acme"); + UsdTestHelpers.AssertDouble(nested["weight"], 12.5); + UsdTestHelpers.AssertInteger(nested["count"], 7L); } [Test] @@ -402,7 +405,7 @@ public void CustomMetadata_CoexistsWithWellKnownMetadata_WithoutPollution() Documentation = "A documented prim", }; prim.ApiSchemas.Add(new UsdApiSchema("PhysicsRigidBodyAPI")); - prim.Metadata["displayName"] = "Widget"; + prim.Metadata["displayName"] = UsdValue.FromString("Widget"); stage.AddRootPrim(prim); UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); @@ -413,7 +416,7 @@ public void CustomMetadata_CoexistsWithWellKnownMetadata_WithoutPollution() Assert.That(prim2.Documentation, Is.EqualTo("A documented prim")); Assert.That(prim2.ApiSchemas.Select(a => a.SchemaName), Does.Contain("PhysicsRigidBodyAPI")); Assert.That(prim2.Metadata.ContainsKey("displayName"), Is.True); - Assert.That(prim2.Metadata["displayName"], Is.EqualTo("Widget")); + UsdTestHelpers.AssertString(prim2.Metadata["displayName"], "Widget"); Assert.That(prim2.Metadata.ContainsKey("kind"), Is.False); Assert.That(prim2.Metadata.ContainsKey("doc"), Is.False); Assert.That(prim2.Metadata.ContainsKey("apiSchemas"), Is.False); @@ -424,14 +427,14 @@ public void CustomMetadata_WithCloseParenInStringValue_RoundTrips() { var stage = new UsdStage("Meta") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform") { Kind = UsdPrimKindEnum.Component }; - prim.Metadata["comment"] = "torque curve peaks at (n) then drops)"; + prim.Metadata["comment"] = UsdValue.FromString("torque curve peaks at (n) then drops)"); stage.AddRootPrim(prim); UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); UsdPrim prim2 = reparsed.Find("/P")!; Assert.That(prim2.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); - Assert.That(prim2.Metadata["comment"], Is.EqualTo("torque curve peaks at (n) then drops)")); + UsdTestHelpers.AssertString(prim2.Metadata["comment"], "torque curve peaks at (n) then drops)"); } } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs index a581bde783..41f9b7fe33 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs @@ -52,7 +52,7 @@ namespace Opc.Ua.OpenUsdScene.Tests [TestFixture] public class ConversionFixTests { - private static bool Coerce(string typeName, object? value, out Variant result) + private static bool Coerce(string typeName, UsdValue value, out Variant result) { UsdValueTypeMapping mapping = UsdValueTypeMap.Map(typeName, null); uint components = UsdValueTypeMap.ComponentCount(typeName); @@ -66,7 +66,7 @@ public void OpaqueTuple_RendersUsdSyntax_NotClrTypeName() { // color4f is not in the value-type table, so it is carried opaquely. A tuple must be // rendered as "(...)" rather than the literal "System.Object[]". - bool ok = Coerce("color4f", new object?[] { 0.1, 0.2, 0.3, 1.0 }, out Variant v); + bool ok = Coerce("color4f", UsdTestHelpers.NumberTuple(0.1, 0.2, 0.3, 1.0), out Variant v); Assert.That(ok, Is.True); Assert.That(v.TryGetValue(out string rendered), Is.True); @@ -79,7 +79,7 @@ public void OpaqueArray_RendersUsdSyntax_NotClrTypeName() { // A [...] array is modelled as List; it must render as "[...]" rather than // the literal "System.Collections.Generic.List`1[System.Object]". - bool ok = Coerce("mvtype", new List { 1L, 2L, 3L }, out Variant v); + bool ok = Coerce("mvtype", UsdTestHelpers.IntegerArray(1L, 2L, 3L), out Variant v); Assert.That(ok, Is.True); Assert.That(v.TryGetValue(out string rendered), Is.True); @@ -93,7 +93,9 @@ public void OpaqueNestedTuple_RendersRecursively() // matrix2d authored as two nested 2-tuples must render every level. bool ok = Coerce( "matrix2d", - new object?[] { new object?[] { 1L, 0L }, new object?[] { 0L, 1L } }, + UsdTestHelpers.Tuple( + UsdTestHelpers.IntegerTuple(1L, 0L), + UsdTestHelpers.IntegerTuple(0L, 1L)), out Variant v); Assert.That(ok, Is.True); @@ -104,7 +106,7 @@ public void OpaqueNestedTuple_RendersRecursively() [Test] public void OpaqueStringLeaf_IsQuotedInsideStructure() { - bool ok = Coerce("mvtype", new List { "a", "b" }, out Variant v); + bool ok = Coerce("mvtype", UsdTestHelpers.StringArray("a", "b"), out Variant v); Assert.That(ok, Is.True); Assert.That(v.TryGetValue(out string rendered), Is.True); @@ -117,7 +119,11 @@ public void OpaqueValue_FailsClosed_WhenLeafCannotBeRendered() // A leaf the writer cannot render faithfully must leave the value unresolved rather // than publish a plausible-but-wrong string (fail closed). bool ok = Coerce( - "mvtype", new object?[] { 0.1, DateTime.UtcNow }, out Variant v); + "mvtype", + UsdTestHelpers.Array( + UsdValue.From(0.1), + UsdValue.FromDictionary(new Dictionary(StringComparer.Ordinal))), + out Variant v); Assert.That(ok, Is.False); Assert.That(v.TryGetValue(out string _), Is.False); @@ -128,13 +134,11 @@ public void OpaqueValue_FailsClosed_WhenLeafCannotBeRendered() [Test] public void Matrix4d_NestedTuples_AreFlattenedAndHonoured() { - object?[] nested = - { - new object?[] { 1.0, 0.0, 0.0, 0.0 }, - new object?[] { 0.0, 1.0, 0.0, 0.0 }, - new object?[] { 0.0, 0.0, 1.0, 0.0 }, - new object?[] { 0.0, 0.0, 0.0, 1.0 }, - }; + UsdValue nested = UsdTestHelpers.Tuple( + UsdTestHelpers.NumberTuple(1.0, 0.0, 0.0, 0.0), + UsdTestHelpers.NumberTuple(0.0, 1.0, 0.0, 0.0), + UsdTestHelpers.NumberTuple(0.0, 0.0, 1.0, 0.0), + UsdTestHelpers.NumberTuple(0.0, 0.0, 0.0, 1.0)); bool ok = Coerce("matrix4d", nested, out Variant v); @@ -152,13 +156,11 @@ public void Matrix4d_NestedTuples_AreFlattenedAndHonoured() [Test] public void Matrix4d_AlreadyFlat_StillHonoured() { - object?[] flatAuthored = - { + UsdValue flatAuthored = UsdTestHelpers.NumberTuple( 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, - 0.0, 0.0, 0.0, 1.0, - }; + 0.0, 0.0, 0.0, 1.0); bool ok = Coerce("matrix4d", flatAuthored, out Variant v); @@ -170,14 +172,12 @@ public void Matrix4d_AlreadyFlat_StillHonoured() [Test] public void Matrix4dArray_NestedTuples_AreFlattenedPerRow() { - object?[] identity = - { - new object?[] { 1.0, 0.0, 0.0, 0.0 }, - new object?[] { 0.0, 1.0, 0.0, 0.0 }, - new object?[] { 0.0, 0.0, 1.0, 0.0 }, - new object?[] { 0.0, 0.0, 0.0, 1.0 }, - }; - var value = new List { identity, identity }; + UsdValue identity = UsdTestHelpers.Tuple( + UsdTestHelpers.NumberTuple(1.0, 0.0, 0.0, 0.0), + UsdTestHelpers.NumberTuple(0.0, 1.0, 0.0, 0.0), + UsdTestHelpers.NumberTuple(0.0, 0.0, 1.0, 0.0), + UsdTestHelpers.NumberTuple(0.0, 0.0, 0.0, 1.0)); + UsdValue value = UsdTestHelpers.Array(identity, identity); bool ok = Coerce("matrix4d[]", value, out Variant v); @@ -192,11 +192,9 @@ public void Color3fArray_StaysGrouped_NotOverFlattened() // The over-flatten guard: color3f[] is a sequence of 3-tuples. Flattening applies to // the element shape only, so the outer array must keep two rows of three, not collapse // to one flat run. - var value = new List - { - new object?[] { 1f, 2f, 3f }, - new object?[] { 4f, 5f, 6f }, - }; + UsdValue value = UsdTestHelpers.Array( + UsdTestHelpers.NumberTuple(1.0, 2.0, 3.0), + UsdTestHelpers.NumberTuple(4.0, 5.0, 6.0)); bool ok = Coerce("color3f[]", value, out Variant v); @@ -210,7 +208,7 @@ public void WrongArityFixedType_StillFailsClosed_AfterFlatten() { // Flattening must not paper over a genuinely wrong arity: float3 with two components // still cannot be honoured. - bool ok = Coerce("float3", new object?[] { 1f, 2f }, out Variant v); + bool ok = Coerce("float3", UsdTestHelpers.NumberTuple(1.0, 2.0), out Variant v); Assert.That(ok, Is.False); Assert.That(v.TryGetValue(out ArrayOf _), Is.False); @@ -223,7 +221,7 @@ public void AssetValue_IsEmittedWithAtDelimiters_NotQuoted() { var stage = new UsdStage("Assets") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Attributes.Add(new UsdAttribute("inputs:file", "asset") { Value = "./pump.usda" }); + prim.Attributes.Add(new UsdAttribute("inputs:file", "asset") { Value = UsdValue.FromString("./pump.usda") }); stage.AddRootPrim(prim); string usda = UsdaWriter.Write(stage); @@ -240,7 +238,7 @@ public void AssetArray_EmitsEachElementWithAtDelimiters() prim.Attributes.Add( new UsdAttribute("inputs:files", "asset[]") { - Value = new List { "./a.usda", "./b.usda" }, + Value = UsdTestHelpers.AssetArray("./a.usda", "./b.usda"), }); stage.AddRootPrim(prim); @@ -256,7 +254,7 @@ public void OpaqueCarriedValue_IsEmittedVerbatim_NotReQuoted() // writer must emit it verbatim so the structured text survives the round trip. var stage = new UsdStage("Opaque") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Attributes.Add(new UsdAttribute("extent", "color4f") { Value = "(0.1, 0.2, 0.3, 1.0)" }); + prim.Attributes.Add(new UsdAttribute("extent", "color4f") { Value = UsdValue.FromString("(0.1, 0.2, 0.3, 1.0)") }); stage.AddRootPrim(prim); string usda = UsdaWriter.Write(stage); @@ -272,7 +270,7 @@ public void MultipleConnections_AreAllEmitted_WithCoAuthoredValue() { var stage = new UsdStage("Conn") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - var attr = new UsdAttribute("inputs:surface", "token") { Value = "fallback" }; + var attr = new UsdAttribute("inputs:surface", "token") { Value = UsdValue.FromString("fallback") }; attr.Connections.Add("/P/A.outputs:surface"); attr.Connections.Add("/P/B.outputs:surface"); prim.Attributes.Add(attr); diff --git a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceAnchorCoercionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceAnchorCoercionTests.cs index 7463f46dc1..80091d70f1 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceAnchorCoercionTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceAnchorCoercionTests.cs @@ -51,7 +51,9 @@ public sealed class GeoreferenceAnchorCoercionTests public void AnAnchorAuthoredInMixedNumericTypesIsCoercedToDouble() { UsdPrim world = TypedGeoreference( - latitude: 47.0f, longitude: -122L, height: 56); + latitude: UsdValue.From(47.0), + longitude: UsdValue.From(-122L), + height: UsdValue.From(56L)); List portable = PortableGeoreference(world); @@ -65,7 +67,9 @@ public void AnAnchorAuthoredInMixedNumericTypesIsCoercedToDouble() public void AnAnchorAuthoredAsInvariantTextIsParsed() { UsdPrim world = TypedGeoreference( - latitude: "47.6062", longitude: "-122.3321", height: "56.0"); + latitude: UsdValue.FromString("47.6062"), + longitude: UsdValue.FromString("-122.3321"), + height: UsdValue.FromString("56.0")); List portable = PortableGeoreference(world); @@ -78,7 +82,9 @@ public void AnAnchorAuthoredAsInvariantTextIsParsed() public void AnAnchorWithAnUnparsableComponentPublishesNoPortableAnchor() { UsdPrim world = TypedGeoreference( - latitude: "north", longitude: Longitude, height: 56.0); + latitude: UsdValue.FromString("north"), + longitude: UsdValue.From(Longitude), + height: UsdValue.From(56.0)); Assert.That(PortableGeoreference(world), Is.Empty); } @@ -88,9 +94,9 @@ public void AGlobeAnchorMissingItsHeightPublishesNoPortableAnchor() { var anchor = new UsdPrim("Anchor", "CesiumGlobeAnchorAPI"); anchor.Attributes.Add( - new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(Latitude) }); anchor.Attributes.Add( - new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); + new UsdAttribute("cesium:anchor:longitude", "double") { Value = UsdValue.From(Longitude) }); var stage = new UsdStage("Test"); stage.AddRootPrim(anchor); @@ -104,11 +110,11 @@ public void AGlobeAnchorWithACompleteAnchorPublishesThePortableAnchor() { var anchor = new UsdPrim("Anchor", "CesiumGlobeAnchorAPI"); anchor.Attributes.Add( - new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(Latitude) }); anchor.Attributes.Add( - new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); + new UsdAttribute("cesium:anchor:longitude", "double") { Value = UsdValue.From(Longitude) }); anchor.Attributes.Add( - new UsdAttribute("cesium:anchor:height", "double") { Value = 56.0 }); + new UsdAttribute("cesium:anchor:height", "double") { Value = UsdValue.From(56.0) }); var stage = new UsdStage("Test"); stage.AddRootPrim(anchor); @@ -120,7 +126,7 @@ public void AGlobeAnchorWithACompleteAnchorPublishesThePortableAnchor() Assert.That(portable[0].Height!.Value, Is.EqualTo(56.0).Within(1e-12)); } - private static UsdPrim TypedGeoreference(object latitude, object longitude, object height) + private static UsdPrim TypedGeoreference(UsdValue latitude, UsdValue longitude, UsdValue height) { var world = new UsdPrim("World", "CesiumGeoreferencePrim"); world.Attributes.Add( diff --git a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTests.cs b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTests.cs index 8da7fd832b..6d42e0157f 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTests.cs @@ -105,8 +105,8 @@ public void PartialAnchor_MissingLongitude_PublishesNoPortableAnchor() var site = new UsdPrim("Site", "Xform"); site.ApiSchemas.Add(new UsdApiSchema("CesiumGeoreferencePrim")); // Latitude only — a partial anchor would place the prim at a wrong position. - site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); - site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(Latitude) }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = UsdValue.From(Height) }); stage.AddRootPrim(site); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -141,19 +141,19 @@ private static UsdStage AnnexBScene() var site = new UsdPrim("Site", "Xform"); site.ApiSchemas.Add(new UsdApiSchema("CesiumGeoreferencePrim")); - site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(Latitude) }); site.Attributes.Add( - new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); - site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + new UsdAttribute("cesium:anchor:longitude", "double") { Value = UsdValue.From(Longitude) }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = UsdValue.From(Height) }); var anchor = new UsdPrim("Anchor", "Xform"); anchor.ApiSchemas.Add(new UsdApiSchema("CesiumGlobeAnchorAPI")); anchor.Attributes.Add( - new UsdAttribute("cesium:anchor:latitude", "double") { Value = AnchorLatitude }); + new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(AnchorLatitude) }); anchor.Attributes.Add( - new UsdAttribute("cesium:anchor:longitude", "double") { Value = AnchorLongitude }); + new UsdAttribute("cesium:anchor:longitude", "double") { Value = UsdValue.From(AnchorLongitude) }); anchor.Attributes.Add( - new UsdAttribute("cesium:anchor:height", "double") { Value = AnchorHeight }); + new UsdAttribute("cesium:anchor:height", "double") { Value = UsdValue.From(AnchorHeight) }); site.AddChild(anchor); stage.AddRootPrim(site); diff --git a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTypedPrimTests.cs b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTypedPrimTests.cs index 4e53d548e4..a3bf85e5cd 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTypedPrimTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTypedPrimTests.cs @@ -101,9 +101,9 @@ public void TypedGlobeAnchorPrim_DualAuthorsPortableAnchor() { var stage = new UsdStage("Geo") { DefaultPrim = "Anchor" }; var anchor = new UsdPrim("Anchor", "CesiumGlobeAnchorAPI"); - anchor.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); - anchor.Attributes.Add(new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); - anchor.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + anchor.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(Latitude) }); + anchor.Attributes.Add(new UsdAttribute("cesium:anchor:longitude", "double") { Value = UsdValue.From(Longitude) }); + anchor.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = UsdValue.From(Height) }); stage.AddRootPrim(anchor); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -125,8 +125,8 @@ public void TypedGeoreferencePrim_MissingLongitude_PublishesNoPortableAnchor() var world = new UsdPrim("World", "CesiumGeoreferencePrim"); // Longitude omitted — a partial anchor would place the prim at a wrong position, so // the portable anchor is withheld entirely (fail closed). - world.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); - world.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + world.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(Latitude) }); + world.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = UsdValue.From(Height) }); stage.AddRootPrim(world); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -157,9 +157,9 @@ private static UsdStage TypedGeoreferenceScene() { var stage = new UsdStage("Geo") { DefaultPrim = "World", UpAxis = "Z", MetersPerUnit = 1.0 }; var world = new UsdPrim("World", "CesiumGeoreferencePrim"); - world.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); - world.Attributes.Add(new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); - world.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + world.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = UsdValue.From(Latitude) }); + world.Attributes.Add(new UsdAttribute("cesium:anchor:longitude", "double") { Value = UsdValue.From(Longitude) }); + world.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = UsdValue.From(Height) }); stage.AddRootPrim(world); return stage; } diff --git a/tests/Opc.Ua.OpenUsd.Tests/MaterializerFallbackTests.cs b/tests/Opc.Ua.OpenUsd.Tests/MaterializerFallbackTests.cs index e06ad30ae0..2cde51d592 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/MaterializerFallbackTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/MaterializerFallbackTests.cs @@ -133,7 +133,8 @@ public void WrongArityFixedMathType_LeavesValueUnset_ButKeepsType() var stage = new UsdStage("Arity") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); // float3 declares three components; only two are authored — it cannot be honoured. - prim.Attributes.Add(new UsdAttribute("badVec", "float3") { Value = new object[] { 1f, 2f } }); + prim.Attributes.Add( + new UsdAttribute("badVec", "float3") { Value = UsdTestHelpers.NumberTuple(1.0, 2.0) }); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -153,7 +154,7 @@ public void CorrectArityFixedMathType_SetsValue() var stage = new UsdStage("Arity") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); prim.Attributes.Add( - new UsdAttribute("goodVec", "float3") { Value = new object[] { 1f, 2f, 3f } }); + new UsdAttribute("goodVec", "float3") { Value = UsdTestHelpers.NumberTuple(1.0, 2.0, 3.0) }); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -211,8 +212,8 @@ public void DuplicateAttributeNames_CollapseToSingleVariable_LastWins() { var stage = new UsdStage("Dup") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Attributes.Add(new UsdAttribute("dup", "double") { Value = 1.0 }); - prim.Attributes.Add(new UsdAttribute("dup", "token") { Value = "x" }); + prim.Attributes.Add(new UsdAttribute("dup", "double") { Value = UsdValue.From(1.0) }); + prim.Attributes.Add(new UsdAttribute("dup", "token") { Value = UsdValue.FromString("x") }); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -231,7 +232,7 @@ public void OddlyNamespacedAttribute_SplitsOnLastColon() { var stage = new UsdStage("Odd") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Attributes.Add(new UsdAttribute("a:b:c", "double") { Value = 1.0 }); + prim.Attributes.Add(new UsdAttribute("a:b:c", "double") { Value = UsdValue.From(1.0) }); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); diff --git a/tests/Opc.Ua.OpenUsd.Tests/MaterializerTests.cs b/tests/Opc.Ua.OpenUsd.Tests/MaterializerTests.cs index 80d48246e8..de23ecd7c2 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/MaterializerTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/MaterializerTests.cs @@ -397,7 +397,7 @@ private static UsdStage LiveStage() { var stage = new UsdStage("Live") { DefaultPrim = "Rig" }; var rig = new UsdPrim("Rig", "Xform"); - rig.Attributes.Add(new UsdAttribute("speed", "double") { Value = 0.0, Live = true }); + rig.Attributes.Add(new UsdAttribute("speed", "double") { Value = UsdValue.From(0.0), Live = true }); stage.AddRootPrim(rig); return stage; } diff --git a/tests/Opc.Ua.OpenUsd.Tests/MetadataCoercionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/MetadataCoercionTests.cs index b19c59b21d..28e13492a1 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/MetadataCoercionTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/MetadataCoercionTests.cs @@ -36,10 +36,9 @@ namespace Opc.Ua.OpenUsdScene.Tests { /// - /// Unit tests for the §6.3 metadata coercion rules of the materializer: every CLR scalar + /// Unit tests for the §6.3 metadata coercion rules of the materializer: every USD scalar /// kind keeps its own OPC UA DataType, a homogeneous sequence keeps its element type, and - /// anything the mapping cannot represent falls back to its invariant textual form rather - /// than being dropped or guessed at. + /// mixed sequences fall back to their textual form rather than being dropped or guessed at. /// [TestFixture] [Category("OpenUsd")] @@ -49,8 +48,8 @@ public sealed class MetadataCoercionTests public void AnEntryWithAnEmptyKeyIsSkipped() { var prim = new UsdPrim("Cube", "Cube"); - prim.Metadata[string.Empty] = "dropped"; - prim.Metadata["kept"] = "value"; + prim.Metadata[string.Empty] = UsdValue.FromString("dropped"); + prim.Metadata["kept"] = UsdValue.FromString("value"); List properties = MetadataProperties(prim); @@ -62,7 +61,7 @@ public void AnEntryWithAnEmptyKeyIsSkipped() public void AnEntryWithANullValueBecomesAValuelessProperty() { var prim = new UsdPrim("Cube", "Cube"); - prim.Metadata["missing"] = null; + prim.Metadata["missing"] = UsdValue.Null; PropertyState property = SingleMetadataProperty(prim); @@ -70,14 +69,8 @@ public void AnEntryWithANullValueBecomesAValuelessProperty() Assert.That(property.Value.IsNull, Is.True); } - [TestCase((sbyte)-8, Opc.Ua.DataTypes.SByte)] - [TestCase((byte)8, Opc.Ua.DataTypes.Byte)] - [TestCase((short)-16, Opc.Ua.DataTypes.Int16)] - [TestCase((ushort)16, Opc.Ua.DataTypes.UInt16)] - [TestCase(32u, Opc.Ua.DataTypes.UInt32)] - [TestCase(64UL, Opc.Ua.DataTypes.UInt64)] - [TestCase(1.5f, Opc.Ua.DataTypes.Float)] - public void AScalarKeepsItsOwnDataType(object value, uint expectedDataType) + [TestCaseSource(nameof(ScalarCases))] + public void AScalarKeepsItsOwnDataType(UsdValue value, uint expectedDataType) { var prim = new UsdPrim("Cube", "Cube"); prim.Metadata["scalar"] = value; @@ -92,11 +85,11 @@ public void AScalarKeepsItsOwnDataType(object value, uint expectedDataType) } [Test] - public void AnUnrepresentableScalarIsCarriedAsInvariantText() + public void ATextScalarIsCarriedAsText() { var prim = new UsdPrim("Cube", "Cube"); var stamp = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc); - prim.Metadata["stamp"] = new UnprintableMetadata(stamp); + prim.Metadata["stamp"] = UsdValue.FromString(stamp.ToString("O")); PropertyState property = SingleMetadataProperty(prim); @@ -106,11 +99,14 @@ public void AnUnrepresentableScalarIsCarriedAsInvariantText() } [Test] - public void ANestedWriteOnlyDictionaryBecomesASubFolder() + public void ANestedDictionaryBecomesASubFolder() { var prim = new UsdPrim("Cube", "Cube"); - var nested = new WriteOnlyMetadataDictionary { ["vendor"] = "Contoso" }; - prim.Metadata["customData"] = nested; + var nested = new Dictionary(StringComparer.Ordinal) + { + ["vendor"] = UsdValue.FromString("Contoso") + }; + prim.Metadata["customData"] = UsdValue.FromDictionary(nested); MaterializedScene scene = Materialize(prim); FolderState metadata = MetadataFolder(scene); @@ -129,7 +125,12 @@ public void ANestedWriteOnlyDictionaryBecomesASubFolder() public void ABooleanSequenceKeepsItsElementType() { PropertyState property = SingleMetadataProperty( - WithMetadata("flags", new[] { true, false, true })); + WithMetadata( + "flags", + UsdTestHelpers.Array( + UsdValue.From(true), + UsdValue.From(false), + UsdValue.From(true)))); Assert.That(property.ValueRank, Is.EqualTo(ValueRanks.OneDimension)); Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Boolean)); @@ -142,7 +143,7 @@ public void ABooleanSequenceKeepsItsElementType() public void ALongSequenceKeepsItsElementType() { PropertyState property = SingleMetadataProperty( - WithMetadata("ticks", new[] { 9_000_000_000L, 2L })); + WithMetadata("ticks", UsdTestHelpers.IntegerArray(9_000_000_000L, 2L))); Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int64)); Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); @@ -153,7 +154,7 @@ public void ALongSequenceKeepsItsElementType() public void AnUnsignedSequenceWidensToInt64() { PropertyState property = SingleMetadataProperty( - WithMetadata("ids", new[] { 1u, 2u })); + WithMetadata("ids", UsdTestHelpers.IntegerArray(1L, 2L))); Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int64)); Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); @@ -164,7 +165,7 @@ public void AnUnsignedSequenceWidensToInt64() public void AFloatingPointSequenceKeepsItsElementType() { PropertyState property = SingleMetadataProperty( - WithMetadata("scales", new[] { 1.5f, 2.5f })); + WithMetadata("scales", UsdTestHelpers.NumberArray(1.5, 2.5))); Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Double)); Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); @@ -175,7 +176,7 @@ public void AFloatingPointSequenceKeepsItsElementType() public void AnEmptySequenceFallsBackToAStringSequence() { PropertyState property = SingleMetadataProperty( - WithMetadata("tags", Array.Empty())); + WithMetadata("tags", UsdValue.FromArray(ArrayOf.Empty))); Assert.That(property.ValueRank, Is.EqualTo(ValueRanks.OneDimension)); Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.String)); @@ -187,7 +188,7 @@ public void AnEmptySequenceFallsBackToAStringSequence() public void ASequenceHoldingANullElementFallsBackToText() { PropertyState property = SingleMetadataProperty( - WithMetadata("order", new object?[] { 3, null })); + WithMetadata("order", UsdTestHelpers.Array(UsdValue.From(3L), UsdValue.Null))); Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.String)); Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); @@ -200,14 +201,24 @@ public void ASequenceHoldingANullElementFallsBackToText() public void ASequenceWithAnUnconvertibleElementFallsBackToText() { PropertyState property = SingleMetadataProperty( - WithMetadata("flags", new object[] { true, "not-a-boolean" })); + WithMetadata( + "flags", + UsdTestHelpers.Array(UsdValue.From(true), UsdValue.FromString("not-a-boolean")))); Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.String)); Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); Assert.That(values[1], Is.EqualTo("not-a-boolean")); } - private static UsdPrim WithMetadata(string key, object value) + private static IEnumerable ScalarCases() + { + yield return new TestCaseData(UsdValue.From(false), Opc.Ua.DataTypes.Boolean); + yield return new TestCaseData(UsdValue.From(32L), Opc.Ua.DataTypes.Int64); + yield return new TestCaseData(UsdValue.From(1.5), Opc.Ua.DataTypes.Double); + yield return new TestCaseData(UsdValue.FromString("text"), Opc.Ua.DataTypes.String); + } + + private static UsdPrim WithMetadata(string key, UsdValue value) { var prim = new UsdPrim("Cube", "Cube"); prim.Metadata[key] = value; @@ -245,102 +256,5 @@ private static PropertyState SingleMetadataProperty(UsdPrim prim) return properties[0]; } - /// - /// A metadata value of a kind the §6.3 mapping does not represent, so the materializer - /// must fall back to its invariant textual form. - /// - private sealed class UnprintableMetadata - { - private readonly DateTime m_stamp; - - public UnprintableMetadata(DateTime stamp) - { - m_stamp = stamp; - } - - public override string ToString() - { - return m_stamp.ToString("O"); - } - } - - /// - /// A dictionary that implements only the mutable dictionary contract, so the nested - /// customData detection has to fall through to its second, read-write case. - /// - private sealed class WriteOnlyMetadataDictionary : IDictionary - { - private readonly Dictionary m_inner = - new Dictionary(StringComparer.Ordinal); - - public object? this[string key] - { - get => m_inner[key]; - set => m_inner[key] = value; - } - - public ICollection Keys => m_inner.Keys; - - public ICollection Values => m_inner.Values; - - public int Count => m_inner.Count; - - public bool IsReadOnly => false; - - public void Add(string key, object? value) - { - m_inner.Add(key, value); - } - - public void Add(KeyValuePair item) - { - m_inner.Add(item.Key, item.Value); - } - - public void Clear() - { - m_inner.Clear(); - } - - public bool Contains(KeyValuePair item) - { - return m_inner.TryGetValue(item.Key, out object? found) && Equals(found, item.Value); - } - - public bool ContainsKey(string key) - { - return m_inner.ContainsKey(key); - } - - public void CopyTo(KeyValuePair[] array, int arrayIndex) - { - ((ICollection>)m_inner).CopyTo(array, arrayIndex); - } - - public IEnumerator> GetEnumerator() - { - return m_inner.GetEnumerator(); - } - - public bool Remove(string key) - { - return m_inner.Remove(key); - } - - public bool Remove(KeyValuePair item) - { - return m_inner.Remove(item.Key); - } - - public bool TryGetValue(string key, out object? value) - { - return m_inner.TryGetValue(key, out value); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return m_inner.GetEnumerator(); - } - } } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/OptionalMemberMaterializationTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OptionalMemberMaterializationTests.cs index bf65f476f0..702d0a94be 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/OptionalMemberMaterializationTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/OptionalMemberMaterializationTests.cs @@ -167,7 +167,7 @@ public void ACustomRelationshipAuthorsTheCustomFlag() public void ARelationshipTargetingAnAttributeResolvesToThatAttributeNode() { var target = new UsdPrim("Mesh", "Mesh"); - target.Attributes.Add(new UsdAttribute("size", "double") { Value = 2.0 }); + target.Attributes.Add(new UsdAttribute("size", "double") { Value = UsdValue.From(2.0) }); var source = new UsdPrim("Binding", "Scope"); var relationship = new UsdRelationship("drivenBy"); relationship.Targets.Add("/Mesh.size"); diff --git a/tests/Opc.Ua.OpenUsd.Tests/PrimMetadataMaterializationTests.cs b/tests/Opc.Ua.OpenUsd.Tests/PrimMetadataMaterializationTests.cs index 6afd18753a..504c3b220f 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/PrimMetadataMaterializationTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/PrimMetadataMaterializationTests.cs @@ -29,7 +29,9 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; +using Opc.Ua; using Opc.Ua.OpenUsdScene.Scene; using Opc.Ua.OpenUsdScene.Server; @@ -51,24 +53,24 @@ public void Metadata_ScalarTypes_RoundTrip() { var stage = new UsdStage("S") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Metadata["author"] = "Ada"; - prim.Metadata["visible"] = true; - prim.Metadata["count"] = 7; - prim.Metadata["huge"] = 9_000_000_000L; - prim.Metadata["scale"] = 2.5; + prim.Metadata["author"] = UsdValue.FromString("Ada"); + prim.Metadata["visible"] = UsdValue.From(true); + prim.Metadata["count"] = UsdValue.From(7L); + prim.Metadata["huge"] = UsdValue.From(9_000_000_000L); + prim.Metadata["scale"] = UsdValue.From(2.5); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); UsdStage exported = ms.Context.ExportUsdStage(ms.Result); - IDictionary md = PrimOf(exported, "P").Metadata; + IDictionary md = PrimOf(exported, "P").Metadata; Assert.Multiple(() => { - Assert.That(md["author"], Is.EqualTo("Ada")); - Assert.That(md["visible"], Is.True); - Assert.That(md["count"], Is.EqualTo(7)); - Assert.That(md["huge"], Is.EqualTo(9_000_000_000L)); - Assert.That(md["scale"], Is.EqualTo(2.5)); + UsdTestHelpers.AssertString(md["author"], "Ada"); + UsdTestHelpers.AssertBoolean(md["visible"], true); + UsdTestHelpers.AssertInteger(md["count"], 7L); + UsdTestHelpers.AssertInteger(md["huge"], 9_000_000_000L); + UsdTestHelpers.AssertDouble(md["scale"], 2.5); }); } @@ -80,10 +82,10 @@ public void Metadata_IsMaterializedTyped_NotStringified() // authored kind rather than an opaque string. var stage = new UsdStage("S") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Metadata["count"] = 7; - prim.Metadata["scale"] = 2.5; - prim.Metadata["visible"] = true; - prim.Metadata["author"] = "Ada"; + prim.Metadata["count"] = UsdValue.From(7L); + prim.Metadata["scale"] = UsdValue.From(2.5); + prim.Metadata["visible"] = UsdValue.From(true); + prim.Metadata["author"] = UsdValue.FromString("Ada"); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -91,8 +93,8 @@ public void Metadata_IsMaterializedTyped_NotStringified() Assert.Multiple(() => { PropertyState count = MetaProperty(ms, "/P", "count"); - Assert.That(count.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int32)); - Assert.That(count.Value.AsBoxedObject(), Is.EqualTo(7)); + Assert.That(count.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int64)); + Assert.That(count.Value.AsBoxedObject(), Is.EqualTo(7L)); PropertyState scale = MetaProperty(ms, "/P", "scale"); Assert.That(scale.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Double)); @@ -113,19 +115,21 @@ public void Metadata_IntArray_RoundTrips_AsTypedSequence() { var stage = new UsdStage("S") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - prim.Metadata["order"] = new int[] { 3, 1, 2 }; + prim.Metadata["order"] = UsdTestHelpers.IntegerArray(3L, 1L, 2L); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); - // The materialized value is a typed one-dimensional Int32 array, not text. + // The materialized value is a typed one-dimensional Int64 array, not text. PropertyState order = MetaProperty(ms, "/P", "order"); - Assert.That(order.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int32)); + Assert.That(order.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int64)); Assert.That(order.ValueRank, Is.EqualTo(ValueRanks.OneDimension)); UsdStage exported = ms.Context.ExportUsdStage(ms.Result); - IDictionary md = PrimOf(exported, "P").Metadata; - Assert.That(md["order"], Is.EqualTo(new[] { 3, 1, 2 }), + IDictionary md = PrimOf(exported, "P").Metadata; + Assert.That(md["order"].TryGetArray(out ArrayOf values), Is.True); + Assert.That(values.ToArray()!.Select(v => v.TryGetInteger(out long integer) ? integer : 0L).ToArray(), + Is.EqualTo(new[] { 3L, 1L, 2L }), "The array must round-trip element-wise, preserving order."); } @@ -136,16 +140,17 @@ public void Metadata_NestedDictionary_RoundTrips() { var stage = new UsdStage("S") { DefaultPrim = "P" }; var prim = new UsdPrim("P", "Xform"); - var customData = new Dictionary(StringComparer.Ordinal) + var revisionData = new Dictionary(StringComparer.Ordinal) { - ["author"] = "Ada", - ["revision"] = new Dictionary(StringComparer.Ordinal) - { - ["major"] = 1, - ["minor"] = 2 - } + ["major"] = UsdValue.From(1L), + ["minor"] = UsdValue.From(2L) + }; + var customData = new Dictionary(StringComparer.Ordinal) + { + ["author"] = UsdValue.FromString("Ada"), + ["revision"] = UsdValue.FromDictionary(revisionData) }; - prim.Metadata["customData"] = customData; + prim.Metadata["customData"] = UsdValue.FromDictionary(customData); stage.AddRootPrim(prim); MaterializedScene ms = MaterializationHarness.Materialize(stage); @@ -160,18 +165,18 @@ public void Metadata_NestedDictionary_RoundTrips() "The nested dictionary must materialize as one nested Metadata sub-folder."); UsdStage exported = ms.Context.ExportUsdStage(ms.Result); - IDictionary md = PrimOf(exported, "P").Metadata; + IDictionary md = PrimOf(exported, "P").Metadata; - Assert.That(md["customData"], Is.InstanceOf>()); - var exportedCustom = (IDictionary)md["customData"]!; - Assert.That(exportedCustom["author"], Is.EqualTo("Ada")); + Assert.That(md["customData"].TryGetDictionary(out IReadOnlyDictionary exportedCustom), + Is.True); + UsdTestHelpers.AssertString(exportedCustom["author"], "Ada"); - Assert.That(exportedCustom["revision"], Is.InstanceOf>()); - var revision = (IDictionary)exportedCustom["revision"]!; + Assert.That(exportedCustom["revision"].TryGetDictionary(out IReadOnlyDictionary revision), + Is.True); Assert.Multiple(() => { - Assert.That(revision["major"], Is.EqualTo(1)); - Assert.That(revision["minor"], Is.EqualTo(2)); + UsdTestHelpers.AssertInteger(revision["major"], 1L); + UsdTestHelpers.AssertInteger(revision["minor"], 2L); }); } diff --git a/tests/Opc.Ua.OpenUsd.Tests/RobotAssetContractTests.cs b/tests/Opc.Ua.OpenUsd.Tests/RobotAssetContractTests.cs index a6a6fa4ea0..374b3a73f9 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/RobotAssetContractTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/RobotAssetContractTests.cs @@ -31,6 +31,7 @@ using System.IO; using System.Linq; using NUnit.Framework; +using Opc.Ua; using Opc.Ua.OpenUsdScene.Conversion; using Opc.Ua.OpenUsdScene.Scene; @@ -77,12 +78,26 @@ private static string SamplePath(string name) => private static string Flatten(object? value) => value switch { null => string.Empty, + UsdValue usdValue => Flatten(usdValue), string text => text, System.Collections.IEnumerable items => string.Join(",", items.Cast().Select(Flatten)), _ => value.ToString() ?? string.Empty }; + private static string Flatten(UsdValue value) + { + if (value.TryGetText(out string text)) + { + return text; + } + if (value.TryGetItems(out ArrayOf items)) + { + return string.Join(",", items.ToArray()!.Select(Flatten)); + } + return value.ToString(); + } + [Test] [TestCase("robot.usda", "Robot")] [TestCase("tool.usda", "Gripper")] @@ -143,8 +158,7 @@ public void RobotExposesTheEmergencyStopWarningVisibility() warning!.Attributes.FirstOrDefault(a => a.Name == "visibility"); Assert.That(visibility, Is.Not.Null, "/Robot/Warning has no visibility attribute."); Assert.That(visibility!.TypeName, Is.EqualTo("token")); - Assert.That(visibility.Value, Is.EqualTo("invisible"), - "The warning halo must start hidden."); + UsdTestHelpers.AssertText(visibility.Value, "invisible"); } [Test] @@ -182,8 +196,7 @@ public void CellExposesTheSafetyBeaconVisibility() UsdAttribute? visibility = beacon!.Attributes.FirstOrDefault(a => a.Name == "visibility"); Assert.That(visibility, Is.Not.Null, "/Cell/SafetyBeacon has no visibility attribute."); - Assert.That(visibility!.Value, Is.EqualTo("invisible"), - "The beacon must start hidden."); + UsdTestHelpers.AssertText(visibility!.Value, "invisible"); } [Test] @@ -255,9 +268,10 @@ private static double TranslateX(UsdStage stage, string primPath) prim!.Attributes.FirstOrDefault(a => a.Name == "xformOp:translate"); Assert.That(translate, Is.Not.Null, $"{primPath} has no xformOp:translate."); - object?[]? components = translate!.Value as object?[]; - Assert.That(components, Is.Not.Null.And.Length.EqualTo(3)); - return Convert.ToDouble(components![0], System.Globalization.CultureInfo.InvariantCulture); + Assert.That(translate!.Value.TryGetItems(out ArrayOf components), Is.True); + Assert.That(components.Count, Is.EqualTo(3)); + Assert.That(components[0].TryGetNumber(out double x), Is.True); + return x; } } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/SceneFidelityRoundTripTests.cs b/tests/Opc.Ua.OpenUsd.Tests/SceneFidelityRoundTripTests.cs index 3efa5f47ce..b8aa8cec3a 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/SceneFidelityRoundTripTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/SceneFidelityRoundTripTests.cs @@ -124,9 +124,9 @@ public void ExportedScene_RecoversSamples_AndBranches_Structurally() // Time samples: the default and every sample survive the address-space round trip. UsdAttribute spin = prim.Attributes.Single(a => a.Name == "spin"); - Assert.That(spin.Value, Is.EqualTo(5.0)); + UsdTestHelpers.AssertDouble(spin.Value, 5.0); Assert.That(spin.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0, 48.0 })); - Assert.That(spin.TimeSamples[48.0], Is.EqualTo(180.0)); + UsdTestHelpers.AssertDouble(spin.TimeSamples[48.0], 180.0); // Variant branches: the selection and every authored branch (with body) survive. UsdVariantSet set = prim.VariantSets.Single(); @@ -135,10 +135,10 @@ public void ExportedScene_RecoversSamples_AndBranches_Structurally() Assert.That(set.Variants.Select(v => v.Name), Is.EqualTo(new[] { "high", "low" })); Assert.That( set.Variants[0].Attributes.Single(a => a.Name == "resolution").Value, - Is.EqualTo(1024L)); + Is.EqualTo(UsdValue.From(1024L))); Assert.That( set.Variants[1].Attributes.Single(a => a.Name == "resolution").Value, - Is.EqualTo(256L)); + Is.EqualTo(UsdValue.From(256L))); } [Test] diff --git a/tests/Opc.Ua.OpenUsd.Tests/SceneQuery.cs b/tests/Opc.Ua.OpenUsd.Tests/SceneQuery.cs index c779dc876b..39a3854fdb 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/SceneQuery.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/SceneQuery.cs @@ -144,7 +144,7 @@ public static Scene.UsdStage NormalizeValuesAndConnections(this Scene.UsdStage s { foreach (Scene.UsdAttribute attribute in prim.Attributes) { - attribute.Value = null; + attribute.Value = Scene.UsdValue.Null; attribute.Connections.Clear(); } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/TargetNodeIdAuthoringTests.cs b/tests/Opc.Ua.OpenUsd.Tests/TargetNodeIdAuthoringTests.cs index 7591fb286b..6c9e78dc79 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/TargetNodeIdAuthoringTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/TargetNodeIdAuthoringTests.cs @@ -224,7 +224,7 @@ private static UsdStage NamedStage(string name) { var stage = new UsdStage(name) { DefaultPrim = "Shared" }; var prim = new UsdPrim("Shared", "Xform"); - prim.Attributes.Add(new UsdAttribute("value", "double") { Value = 1.0 }); + prim.Attributes.Add(new UsdAttribute("value", "double") { Value = UsdValue.From(1.0) }); stage.AddRootPrim(prim); return stage; } diff --git a/tests/Opc.Ua.OpenUsd.Tests/TimeSampleMaterializationTests.cs b/tests/Opc.Ua.OpenUsd.Tests/TimeSampleMaterializationTests.cs index b8f0e23030..855102e17d 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/TimeSampleMaterializationTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/TimeSampleMaterializationTests.cs @@ -52,9 +52,9 @@ public class TimeSampleMaterializationTests [Test] public void SampledAttribute_MaterializesDefaultAsValue_AndHistorizes() { - var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; - attr.TimeSamples[0.0] = 0.0; - attr.TimeSamples[24.0] = 90.0; + var attr = new UsdAttribute("angle", "double") { Value = UsdValue.From(5.0) }; + attr.TimeSamples[0.0] = UsdValue.From(0.0); + attr.TimeSamples[24.0] = UsdValue.From(90.0); MaterializedScene ms = MaterializeAttr(attr); UsdAttributeState node = ms.Attr("/P.angle"); @@ -70,8 +70,8 @@ public void SampledAttribute_MaterializesDefaultAsValue_AndHistorizes() public void SampledAttribute_WithoutDefault_HasNoValue_ButStillHistorizes() { var attr = new UsdAttribute("angle", "double"); - attr.TimeSamples[0.0] = 0.0; - attr.TimeSamples[24.0] = 90.0; + attr.TimeSamples[0.0] = UsdValue.From(0.0); + attr.TimeSamples[24.0] = UsdValue.From(90.0); MaterializedScene ms = MaterializeAttr(attr); UsdAttributeState node = ms.Attr("/P.angle"); @@ -84,10 +84,10 @@ public void SampledAttribute_WithoutDefault_HasNoValue_ButStillHistorizes() [Test] public void HistoricalAccess_ExposesOrderedSamples_KeyedByComposedPath() { - var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; - attr.TimeSamples[48.0] = 180.0; - attr.TimeSamples[0.0] = 0.0; - attr.TimeSamples[24.0] = 90.0; + var attr = new UsdAttribute("angle", "double") { Value = UsdValue.From(5.0) }; + attr.TimeSamples[48.0] = UsdValue.From(180.0); + attr.TimeSamples[0.0] = UsdValue.From(0.0); + attr.TimeSamples[24.0] = UsdValue.From(90.0); MaterializedScene ms = MaterializeAttr(attr); Assert.That(ms.Result.HistoricalAccessByPath.ContainsKey("/P.angle"), Is.True); @@ -98,36 +98,37 @@ public void HistoricalAccess_ExposesOrderedSamples_KeyedByComposedPath() Assert.That( ha.Samples.Select(s => s.TimeCode), Is.EqualTo(new[] { 0.0, 24.0, 48.0 })); Assert.That( - ha.Samples.Select(s => s.Value), Is.EqualTo(new object?[] { 0.0, 90.0, 180.0 })); + ha.Samples.Select(s => s.Value), + Is.EqualTo(new[] { UsdValue.From(0.0), UsdValue.From(90.0), UsdValue.From(180.0) })); } [Test] public void CoauthoredDefaultAndSamples_AreIndependent() { - var attr = new UsdAttribute("angle", "double") { Value = 42.0 }; - attr.TimeSamples[0.0] = 7.0; + var attr = new UsdAttribute("angle", "double") { Value = UsdValue.From(42.0) }; + attr.TimeSamples[0.0] = UsdValue.From(7.0); MaterializedScene ms = MaterializeAttr(attr); // The default and the first sample differ, proving Value is the default, not sample[0]. Assert.That(ms.Attr("/P.angle").BoxedValue(), Is.EqualTo(42.0)); Assert.That( ms.Result.HistoricalAccessByPath["/P.angle"].Samples.Single().Value, - Is.EqualTo(7.0)); + Is.EqualTo(UsdValue.From(7.0))); } [Test] public void NegativeAndFractionalTimeCodes_ArePreserved() { var attr = new UsdAttribute("angle", "double"); - attr.TimeSamples[-12.0] = -1.0; - attr.TimeSamples[0.5] = 5.0; - attr.TimeSamples[2.25] = 7.5; + attr.TimeSamples[-12.0] = UsdValue.From(-1.0); + attr.TimeSamples[0.5] = UsdValue.From(5.0); + attr.TimeSamples[2.25] = UsdValue.From(7.5); MaterializedScene ms = MaterializeAttr(attr); IReadOnlyList samples = ms.Result.HistoricalAccessByPath["/P.angle"].Samples; Assert.That(samples.Select(s => s.TimeCode), Is.EqualTo(new[] { -12.0, 0.5, 2.25 })); - Assert.That(samples[0].Value, Is.EqualTo(-1.0)); + UsdTestHelpers.AssertDouble(samples[0].Value, -1.0); } [Test] @@ -136,15 +137,17 @@ public void UnknownValueType_Samples_AreHistorized_AndPreservedOpaquely() // §8.4: an unrecognized SdfValueTypeName is carried opaquely. Its samples must still be // recorded verbatim — the materializer never guesses at or drops an unknown value. var attr = new UsdAttribute("mystery", "customType"); - attr.TimeSamples[0.0] = "opaque-a"; - attr.TimeSamples[10.0] = "opaque-b"; + attr.TimeSamples[0.0] = UsdValue.FromString("opaque-a"); + attr.TimeSamples[10.0] = UsdValue.FromString("opaque-b"); MaterializedScene ms = MaterializeAttr(attr); UsdAttributeState node = ms.Attr("/P.mystery"); Assert.That(node.Historizing, Is.True); IReadOnlyList samples = ms.Result.HistoricalAccessByPath["/P.mystery"].Samples; - Assert.That(samples.Select(s => s.Value), Is.EqualTo(new object?[] { "opaque-a", "opaque-b" })); + Assert.That( + samples.Select(s => s.Value), + Is.EqualTo(new[] { UsdValue.FromString("opaque-a"), UsdValue.FromString("opaque-b") })); } // ---- regression: unsampled attributes are untouched ---------------------------- @@ -152,7 +155,7 @@ public void UnknownValueType_Samples_AreHistorized_AndPreservedOpaquely() [Test] public void UnsampledAttribute_IsNotHistorizing_AndAbsentFromHistoricalAccess() { - var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; + var attr = new UsdAttribute("angle", "double") { Value = UsdValue.From(5.0) }; MaterializedScene ms = MaterializeAttr(attr); Assert.That(ms.Attr("/P.angle").Historizing, Is.False); @@ -165,7 +168,7 @@ public void UnsampledAttribute_IsNotHistorizing_AndAbsentFromHistoricalAccess() public void ResolveUtc_WithoutEpoch_ReturnsNull() { var attr = new UsdAttribute("angle", "double"); - attr.TimeSamples[24.0] = 90.0; + attr.TimeSamples[24.0] = UsdValue.From(90.0); // No epoch option and stage declares TimeCodesPerSecond: the timeline is Server-defined. MaterializedScene ms = MaterializeAttr(attr, tcps: 24.0, epochUtc: null); @@ -179,7 +182,7 @@ public void ResolveUtc_WithEpochAndTimeCodesPerSecond_MapsToWallClock() { var epoch = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var attr = new UsdAttribute("angle", "double"); - attr.TimeSamples[48.0] = 180.0; + attr.TimeSamples[48.0] = UsdValue.From(180.0); MaterializedScene ms = MaterializeAttr(attr, tcps: 24.0, epochUtc: epoch); UsdHistoricalAccess ha = ms.Result.HistoricalAccessByPath["/P.angle"]; @@ -194,7 +197,7 @@ public void ResolveUtc_WithEpochButNoTimeCodesPerSecond_ReturnsNull() { var epoch = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var attr = new UsdAttribute("angle", "double"); - attr.TimeSamples[24.0] = 90.0; + attr.TimeSamples[24.0] = UsdValue.From(90.0); // Epoch declared but the stage has no TimeCodesPerSecond: the rate is unknown, so the // mapping stays undefined rather than assuming a rate (fail closed). MaterializedScene ms = MaterializeAttr(attr, tcps: null, epochUtc: epoch); @@ -207,18 +210,18 @@ public void ResolveUtc_WithEpochButNoTimeCodesPerSecond_ReturnsNull() [Test] public void SampledAttribute_RoundTripsThroughExport() { - var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; - attr.TimeSamples[-6.0] = -1.0; - attr.TimeSamples[0.0] = 0.0; - attr.TimeSamples[24.0] = 90.0; + var attr = new UsdAttribute("angle", "double") { Value = UsdValue.From(5.0) }; + attr.TimeSamples[-6.0] = UsdValue.From(-1.0); + attr.TimeSamples[0.0] = UsdValue.From(0.0); + attr.TimeSamples[24.0] = UsdValue.From(90.0); MaterializedScene ms = MaterializeAttr(attr); UsdStage exported = ms.Context.ExportUsdStage(ms.Result); UsdAttribute exportedAttr = exported.Find("/P")!.Attributes.Single(); - Assert.That(exportedAttr.Value, Is.EqualTo(5.0)); + UsdTestHelpers.AssertDouble(exportedAttr.Value, 5.0); Assert.That(exportedAttr.TimeSamples.Keys, Is.EqualTo(new[] { -6.0, 0.0, 24.0 })); - Assert.That(exportedAttr.TimeSamples[24.0], Is.EqualTo(90.0)); + UsdTestHelpers.AssertDouble(exportedAttr.TimeSamples[24.0], 90.0); } [Test] @@ -226,14 +229,14 @@ public void Exporter_WithoutSampleMap_KeepsDefault_ButOmitsSamples() { // Exporting from the stage node alone (no samples map) cannot recover the samples — // they live on the result, not the node — but the authored default still round-trips. - var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; - attr.TimeSamples[0.0] = 0.0; + var attr = new UsdAttribute("angle", "double") { Value = UsdValue.From(5.0) }; + attr.TimeSamples[0.0] = UsdValue.From(0.0); MaterializedScene ms = MaterializeAttr(attr); UsdStage exported = ms.Context.ExportUsdStage(ms.Stage); UsdAttribute exportedAttr = exported.Find("/P")!.Attributes.Single(); - Assert.That(exportedAttr.Value, Is.EqualTo(5.0)); + UsdTestHelpers.AssertDouble(exportedAttr.Value, 5.0); Assert.That(exportedAttr.TimeSamples, Is.Empty); } diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneDiscoveryTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneDiscoveryTests.cs index 1a21a6abca..70674c49a9 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneDiscoveryTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneDiscoveryTests.cs @@ -204,7 +204,7 @@ private static UsdStage SingleStage() { var stage = new UsdStage("Solo") { DefaultPrim = "Shared" }; var prim = new UsdPrim("Shared", "Xform"); - prim.Attributes.Add(new UsdAttribute("value", "double") { Value = 1.0 }); + prim.Attributes.Add(new UsdAttribute("value", "double") { Value = UsdValue.From(1.0) }); stage.AddRootPrim(prim); return stage; } diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneExporterFallbackTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneExporterFallbackTests.cs index b199bfac78..2fafba0efd 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneExporterFallbackTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneExporterFallbackTests.cs @@ -113,7 +113,7 @@ public void ConnectionsAreRebuiltFromTheBrowsableEdgesForABareStageNode() public void AMetadataEntryWithoutABrowseNameIsSkipped() { var prim = new UsdPrim("Mesh", "Mesh"); - prim.Metadata["author"] = "Ada"; + prim.Metadata["author"] = UsdValue.FromString("Ada"); var stage = new UsdStage("Test"); stage.AddRootPrim(prim); MaterializedScene scene = MaterializationHarness.Materialize(stage); @@ -166,8 +166,8 @@ public void AnEnumMemberHoldingAnUnreadableValueFallsBackToItsDefault() private static UsdStage ConnectedScene() { var prim = new UsdPrim("Mesh", "Mesh") { Kind = UsdPrimKindEnum.Component }; - prim.Attributes.Add(new UsdAttribute("size", "double") { Value = 2.0 }); - var radius = new UsdAttribute("radius", "double") { Value = 1.0 }; + prim.Attributes.Add(new UsdAttribute("size", "double") { Value = UsdValue.From(2.0) }); + var radius = new UsdAttribute("radius", "double") { Value = UsdValue.From(1.0) }; radius.Connections.Add("/Mesh.size"); radius.Connections.Add("/Elsewhere.size"); prim.Attributes.Add(radius); diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs index d0ff93d1a9..8c4f7aa427 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs @@ -65,11 +65,11 @@ public void Signature_DetectsChangedAttributeValue() { var a = new UsdStage("S"); UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); - pa.Attributes.Add(new UsdAttribute("v", "int") { Value = 1L }); + pa.Attributes.Add(new UsdAttribute("v", "int") { Value = UsdValue.From(1L) }); var b = new UsdStage("S"); UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); - pb.Attributes.Add(new UsdAttribute("v", "int") { Value = 2L }); + pb.Attributes.Add(new UsdAttribute("v", "int") { Value = UsdValue.From(2L) }); Assert.That(UsdSceneSignature.Compute(b), Is.Not.EqualTo(UsdSceneSignature.Compute(a))); Assert.That(UsdSceneSignature.FirstDifference(a, b), Is.Not.Null); @@ -80,13 +80,13 @@ public void Signature_IsIndependentOfAttributeOrder() { var a = new UsdStage("S"); UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); - pa.Attributes.Add(new UsdAttribute("a", "int") { Value = 1L }); - pa.Attributes.Add(new UsdAttribute("b", "int") { Value = 2L }); + pa.Attributes.Add(new UsdAttribute("a", "int") { Value = UsdValue.From(1L) }); + pa.Attributes.Add(new UsdAttribute("b", "int") { Value = UsdValue.From(2L) }); var b = new UsdStage("S"); UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); - pb.Attributes.Add(new UsdAttribute("b", "int") { Value = 2L }); - pb.Attributes.Add(new UsdAttribute("a", "int") { Value = 1L }); + pb.Attributes.Add(new UsdAttribute("b", "int") { Value = UsdValue.From(2L) }); + pb.Attributes.Add(new UsdAttribute("a", "int") { Value = UsdValue.From(1L) }); Assert.That(UsdSceneSignature.Compute(b), Is.EqualTo(UsdSceneSignature.Compute(a))); } @@ -121,7 +121,7 @@ private static UsdStage BuildStage(bool withNonComposedState) }; var attr = new UsdAttribute("v", "int") { - Value = 1L, + Value = UsdValue.From(1L), Live = withNonComposedState, Interpolation = withNonComposedState ? "vertex" : null, }; @@ -129,7 +129,7 @@ private static UsdStage BuildStage(bool withNonComposedState) if (withNonComposedState) { prim.ApiSchemas.Add(new UsdApiSchema("MaterialBindingAPI")); - prim.Metadata["hidden"] = true; + prim.Metadata["hidden"] = UsdValue.From(true); } stage.AddRootPrim(prim); return stage; diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdTestHelpers.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdTestHelpers.cs index 6f63f624af..2a4de7bcc9 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdTestHelpers.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdTestHelpers.cs @@ -28,8 +28,10 @@ * ======================================================================*/ using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using Opc.Ua; using Opc.Ua.OpenUsdScene.Scene; namespace Opc.Ua.OpenUsdScene.Tests @@ -62,5 +64,150 @@ public static UsdRelationship RequireRelationship(UsdPrim prim, string name) Assert.That(rel, Is.Not.Null, "expected relationship " + name + " on " + prim.Path); return rel!; } + + public static UsdValue Tuple(params UsdValue[] values) + { + return UsdValue.FromTuple(values.ToArrayOf()); + } + + public static UsdValue Array(params UsdValue[] values) + { + return UsdValue.FromArray(values.ToArrayOf()); + } + + public static UsdValue Dictionary(params KeyValuePair[] entries) + { + // The IEnumerable> constructor is not available on net48/net472, + // so fill the dictionary explicitly. + var map = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair entry in entries) + { + map[entry.Key] = entry.Value; + } + return UsdValue.FromDictionary(map); + } + + public static UsdValue NumberArray(params double[] values) + { + return UsdValue.FromArray(values.Select(UsdValue.From).ToArrayOf()); + } + + public static UsdValue NumberTuple(params double[] values) + { + return UsdValue.FromTuple(values.Select(UsdValue.From).ToArrayOf()); + } + + public static UsdValue IntegerArray(params long[] values) + { + return UsdValue.FromArray(values.Select(UsdValue.From).ToArrayOf()); + } + + public static UsdValue IntegerTuple(params long[] values) + { + return UsdValue.FromTuple(values.Select(UsdValue.From).ToArrayOf()); + } + + public static UsdValue StringArray(params string[] values) + { + return UsdValue.FromArray(values.Select(UsdValue.FromString).ToArrayOf()); + } + + public static UsdValue TokenArray(params string[] values) + { + return UsdValue.FromArray(values.Select(UsdValue.FromToken).ToArrayOf()); + } + + public static UsdValue AssetArray(params string[] values) + { + return UsdValue.FromArray(values.Select(UsdValue.FromAssetPath).ToArrayOf()); + } + + public static void AssertDouble(UsdValue value, double expected) + { + Assert.That(value.TryGetDouble(out double actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + + public static void AssertInteger(UsdValue value, long expected) + { + Assert.That(value.TryGetInteger(out long actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + + public static void AssertBoolean(UsdValue value, bool expected) + { + Assert.That(value.TryGetBoolean(out bool actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + + public static void AssertString(UsdValue value, string expected) + { + Assert.That(value.TryGetString(out string actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + + public static void AssertToken(UsdValue value, string expected) + { + Assert.That(value.TryGetToken(out string actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + + public static void AssertAssetPath(UsdValue value, string expected) + { + Assert.That(value.TryGetAssetPath(out string actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + + public static void AssertPathReference(UsdValue value, string expected) + { + Assert.That(value.TryGetPathReference(out string actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + + public static void AssertText(UsdValue value, string expected) + { + Assert.That(value.TryGetText(out string actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + + public static void AssertIntegerItems(UsdValue value, params long[] expected) + { + Assert.That(value.TryGetItems(out ArrayOf items), Is.True); + Assert.That(items.Count, Is.EqualTo(expected.Length)); + for (int ii = 0; ii < expected.Length; ii++) + { + AssertInteger(items[ii], expected[ii]); + } + } + + public static void AssertDoubleItems(UsdValue value, params double[] expected) + { + Assert.That(value.TryGetItems(out ArrayOf items), Is.True); + Assert.That(items.Count, Is.EqualTo(expected.Length)); + for (int ii = 0; ii < expected.Length; ii++) + { + AssertDouble(items[ii], expected[ii]); + } + } + + public static void AssertTextItems(UsdValue value, params string[] expected) + { + Assert.That(value.TryGetItems(out ArrayOf items), Is.True); + Assert.That(items.Count, Is.EqualTo(expected.Length)); + for (int ii = 0; ii < expected.Length; ii++) + { + AssertText(items[ii], expected[ii]); + } + } + + public static void AssertNestedIntegerItems(UsdValue value, params long[][] expected) + { + Assert.That(value.TryGetItems(out ArrayOf rows), Is.True); + Assert.That(rows.Count, Is.EqualTo(expected.Length)); + for (int ii = 0; ii < expected.Length; ii++) + { + AssertIntegerItems(rows[ii], expected[ii]); + } + } } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleEqualityTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleEqualityTests.cs index 20e1096ba7..fbdf9ab456 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleEqualityTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleEqualityTests.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; using Opc.Ua.OpenUsdScene.Server; namespace Opc.Ua.OpenUsdScene.Tests @@ -44,8 +45,8 @@ public sealed class UsdTimeSampleEqualityTests [Test] public void SamplesWithTheSameTimeCodeAndValueAreEqual() { - var left = new UsdTimeSample(1.5, 42.0); - var right = new UsdTimeSample(1.5, 42.0); + var left = new UsdTimeSample(1.5, UsdValue.From(42.0)); + var right = new UsdTimeSample(1.5, UsdValue.From(42.0)); bool viaOperator = left == right; bool viaInequality = left != right; @@ -58,8 +59,8 @@ public void SamplesWithTheSameTimeCodeAndValueAreEqual() [Test] public void SamplesWithADifferentTimeCodeAreNotEqual() { - var left = new UsdTimeSample(1.5, 42.0); - var right = new UsdTimeSample(2.5, 42.0); + var left = new UsdTimeSample(1.5, UsdValue.From(42.0)); + var right = new UsdTimeSample(2.5, UsdValue.From(42.0)); bool viaOperator = left == right; bool viaInequality = left != right; @@ -71,8 +72,8 @@ public void SamplesWithADifferentTimeCodeAreNotEqual() [Test] public void SamplesWithADifferentValueAreNotEqual() { - var left = new UsdTimeSample(1.5, 42.0); - var right = new UsdTimeSample(1.5, "42"); + var left = new UsdTimeSample(1.5, UsdValue.From(42.0)); + var right = new UsdTimeSample(1.5, UsdValue.FromString("42")); bool viaEquatable = left.Equals(right); Assert.That(viaEquatable, Is.False); @@ -82,8 +83,8 @@ public void SamplesWithADifferentValueAreNotEqual() [Test] public void ASampleWithoutAValueEqualsAnotherWithoutAValue() { - var left = new UsdTimeSample(-3.25, null); - var right = new UsdTimeSample(-3.25, null); + var left = new UsdTimeSample(-3.25, UsdValue.Null); + var right = new UsdTimeSample(-3.25, UsdValue.Null); bool viaEquatable = left.Equals(right); Assert.That(viaEquatable, Is.True); @@ -93,8 +94,8 @@ public void ASampleWithoutAValueEqualsAnotherWithoutAValue() [Test] public void ASampleEqualsABoxedSampleWithTheSameContent() { - var sample = new UsdTimeSample(0.5, 7); - object boxed = new UsdTimeSample(0.5, 7); + var sample = new UsdTimeSample(0.5, UsdValue.From(7L)); + object boxed = new UsdTimeSample(0.5, UsdValue.From(7L)); bool equal = sample.Equals(boxed); Assert.That(equal, Is.True); @@ -103,7 +104,7 @@ public void ASampleEqualsABoxedSampleWithTheSameContent() [Test] public void ASampleDoesNotEqualAnObjectOfAnotherType() { - var sample = new UsdTimeSample(0.5, 7); + var sample = new UsdTimeSample(0.5, UsdValue.From(7L)); bool equalsText = sample.Equals("0.5"); bool equalsNothing = sample.Equals(null); diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleTests.cs index af1f90889d..8b676f63d9 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleTests.cs @@ -95,9 +95,14 @@ public void MultiLineBlock_ParsesScalarSamples_SeparateFromDefault() " 48: 180.0,", " }"); - Assert.That(attr.Value, Is.Null, "no authored default was declared"); + Assert.That(attr.Value.IsNull, Is.True, "no authored default was declared"); Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0, 48.0 })); - Assert.That(attr.TimeSamples.Values, Is.EqualTo(new object?[] { 0.0, 90.0, 180.0 })); + Assert.That(attr.TimeSamples.Values, Is.EqualTo(new[] + { + UsdValue.From(0.0), + UsdValue.From(90.0), + UsdValue.From(180.0) + })); } [Test] @@ -108,7 +113,7 @@ public void SingleLineBlock_ParsesSamples() " double a.timeSamples = { 0: 0.0, 24: 90.0 }"); Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0 })); - Assert.That(attr.TimeSamples.Values, Is.EqualTo(new object?[] { 0.0, 90.0 })); + Assert.That(attr.TimeSamples.Values, Is.EqualTo(new[] { UsdValue.From(0.0), UsdValue.From(90.0) })); } [Test] @@ -123,8 +128,8 @@ public void NegativeAndFractionalTimeCodes_AreParsed() " }"); Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { -12.0, 0.5, 2.25 })); - Assert.That(attr.TimeSamples[-12.0], Is.EqualTo(-1.0)); - Assert.That(attr.TimeSamples[0.5], Is.EqualTo(5.0)); + UsdTestHelpers.AssertDouble(attr.TimeSamples[-12.0], -1.0); + UsdTestHelpers.AssertDouble(attr.TimeSamples[0.5], 5.0); } [Test] @@ -151,8 +156,8 @@ public void TupleValuedSamples_AreParsed() " 24: (1, 2, 3),", " }"); - Assert.That(attr.TimeSamples[0.0], Is.EqualTo(new object?[] { 0L, 0L, 0L })); - Assert.That(attr.TimeSamples[24.0], Is.EqualTo(new object?[] { 1L, 2L, 3L })); + UsdTestHelpers.AssertIntegerItems(attr.TimeSamples[0.0], 0L, 0L, 0L); + UsdTestHelpers.AssertIntegerItems(attr.TimeSamples[24.0], 1L, 2L, 3L); } [Test] @@ -165,8 +170,8 @@ public void ArrayValuedSamples_AreParsed() " 24: [4, 5, 6],", " }"); - Assert.That(attr.TimeSamples[0.0], Is.EqualTo(new List { 1L, 2L, 3L })); - Assert.That(attr.TimeSamples[24.0], Is.EqualTo(new List { 4L, 5L, 6L })); + UsdTestHelpers.AssertIntegerItems(attr.TimeSamples[0.0], 1L, 2L, 3L); + UsdTestHelpers.AssertIntegerItems(attr.TimeSamples[24.0], 4L, 5L, 6L); } [Test] @@ -179,8 +184,8 @@ public void AssetValuedSamples_AreUnwrapped() " 24: @./b.usda@,", " }"); - Assert.That(attr.TimeSamples[0.0], Is.EqualTo("./a.usda")); - Assert.That(attr.TimeSamples[24.0], Is.EqualTo("./b.usda")); + UsdTestHelpers.AssertAssetPath(attr.TimeSamples[0.0], "./a.usda"); + UsdTestHelpers.AssertAssetPath(attr.TimeSamples[24.0], "./b.usda"); } [Test] @@ -193,8 +198,8 @@ public void TokenValuedSamples_AreUnwrapped() " 24: \"closed\",", " }"); - Assert.That(attr.TimeSamples[0.0], Is.EqualTo("open")); - Assert.That(attr.TimeSamples[24.0], Is.EqualTo("closed")); + UsdTestHelpers.AssertString(attr.TimeSamples[0.0], "open"); + UsdTestHelpers.AssertString(attr.TimeSamples[24.0], "closed"); } [Test] @@ -208,7 +213,7 @@ public void DefaultAndSamples_CoexistOnOneAttribute() " 24: 90.0,", " }"); - Assert.That(attr.Value, Is.EqualTo(5.0)); + UsdTestHelpers.AssertDouble(attr.Value, 5.0); Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0 })); } @@ -322,15 +327,15 @@ public void Signature_DistinguishesDifferingSampleValues() var a = new UsdStage("S"); UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); var attrA = new UsdAttribute("v", "double"); - attrA.TimeSamples[0.0] = 0.0; - attrA.TimeSamples[24.0] = 90.0; + attrA.TimeSamples[0.0] = UsdValue.From(0.0); + attrA.TimeSamples[24.0] = UsdValue.From(90.0); pa.Attributes.Add(attrA); var b = new UsdStage("S"); UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); var attrB = new UsdAttribute("v", "double"); - attrB.TimeSamples[0.0] = 0.0; - attrB.TimeSamples[24.0] = 91.0; + attrB.TimeSamples[0.0] = UsdValue.From(0.0); + attrB.TimeSamples[24.0] = UsdValue.From(91.0); pb.Attributes.Add(attrB); Assert.That(UsdSceneSignature.Compute(b), Is.Not.EqualTo(UsdSceneSignature.Compute(a))); @@ -342,13 +347,13 @@ public void Signature_DistinguishesPresenceOfSamples() { var withSamples = new UsdStage("S"); UsdPrim ps = withSamples.AddRootPrim(new UsdPrim("X", "Xform")); - var sampled = new UsdAttribute("v", "double") { Value = 1.0 }; - sampled.TimeSamples[0.0] = 0.0; + var sampled = new UsdAttribute("v", "double") { Value = UsdValue.From(1.0) }; + sampled.TimeSamples[0.0] = UsdValue.From(0.0); ps.Attributes.Add(sampled); var withoutSamples = new UsdStage("S"); UsdPrim pn = withoutSamples.AddRootPrim(new UsdPrim("X", "Xform")); - pn.Attributes.Add(new UsdAttribute("v", "double") { Value = 1.0 }); + pn.Attributes.Add(new UsdAttribute("v", "double") { Value = UsdValue.From(1.0) }); Assert.That( UsdSceneSignature.Compute(withoutSamples), @@ -361,15 +366,15 @@ public void Signature_EqualForIdenticalSamples() var a = new UsdStage("S"); UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); var attrA = new UsdAttribute("v", "double"); - attrA.TimeSamples[0.0] = 0.0; - attrA.TimeSamples[24.0] = 90.0; + attrA.TimeSamples[0.0] = UsdValue.From(0.0); + attrA.TimeSamples[24.0] = UsdValue.From(90.0); pa.Attributes.Add(attrA); var b = new UsdStage("S"); UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); var attrB = new UsdAttribute("v", "double"); - attrB.TimeSamples[0.0] = 0.0; - attrB.TimeSamples[24.0] = 90.0; + attrB.TimeSamples[0.0] = UsdValue.From(0.0); + attrB.TimeSamples[24.0] = UsdValue.From(90.0); pb.Attributes.Add(attrB); Assert.That(UsdSceneSignature.Compute(b), Is.EqualTo(UsdSceneSignature.Compute(a))); @@ -382,7 +387,7 @@ public void Signature_OfSampleLessAttribute_IsUnchangedByTheFeature() // samples existed, so the existing round-trip corpus cannot be perturbed. var stage = new UsdStage("S"); UsdPrim prim = stage.AddRootPrim(new UsdPrim("X", "Xform")); - prim.Attributes.Add(new UsdAttribute("v", "int") { Value = 1L }); + prim.Attributes.Add(new UsdAttribute("v", "int") { Value = UsdValue.From(1L) }); string signature = UsdSceneSignature.Compute(stage); diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs new file mode 100644 index 0000000000..882f61d842 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs @@ -0,0 +1,242 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Tests for , the union that scopes an authored USD value to the + /// shapes a .usda document can express. + /// + [TestFixture] + [Category("OpenUsd")] + [Parallelizable] + public class UsdValueTests + { + [Test] + public void DefaultValueIsNull() + { + UsdValue value = default; + + Assert.That(value.IsNull, Is.True); + Assert.That(value.Kind, Is.EqualTo(UsdValueKind.Null)); + Assert.That(value, Is.EqualTo(UsdValue.Null)); + } + + [Test] + public void BooleanRoundTrips() + { + UsdValue value = UsdValue.From(true); + + Assert.That(value.Kind, Is.EqualTo(UsdValueKind.Boolean)); + Assert.That(value.IsNull, Is.False); + Assert.That(value.TryGetBoolean(out bool b), Is.True); + Assert.That(b, Is.True); + } + + [Test] + public void IntegerRoundTripsWithoutPrecisionLoss() + { + const long large = 9007199254740993L; + UsdValue value = UsdValue.From(large); + + Assert.That(value.TryGetInteger(out long l), Is.True); + Assert.That(l, Is.EqualTo(large), + "An integer must not be widened through a double."); + } + + [Test] + public void DoubleRoundTrips() + { + UsdValue value = UsdValue.From(1.5); + + Assert.That(value.TryGetDouble(out double d), Is.True); + Assert.That(d, Is.EqualTo(1.5)); + } + + [Test] + public void TryGetNumberWidensAnInteger() + { + Assert.That(UsdValue.From(7L).TryGetNumber(out double fromInteger), Is.True); + Assert.That(fromInteger, Is.EqualTo(7.0)); + Assert.That(UsdValue.From(2.5).TryGetNumber(out double fromDouble), Is.True); + Assert.That(fromDouble, Is.EqualTo(2.5)); + } + + [Test] + public void AccessorsRejectTheWrongKind() + { + UsdValue value = UsdValue.From(1.5); + + Assert.That(value.TryGetInteger(out _), Is.False); + Assert.That(value.TryGetBoolean(out _), Is.False); + Assert.That(value.TryGetString(out _), Is.False); + Assert.That(value.TryGetArray(out _), Is.False); + } + + /// + /// A string, a token, an asset path and a path reference all carry text but are printed + /// differently by USD, so they must stay distinguishable. + /// + [Test] + public void TextKindsStayDistinct() + { + UsdValue s = UsdValue.FromString("x"); + UsdValue token = UsdValue.FromToken("x"); + UsdValue asset = UsdValue.FromAssetPath("x"); + UsdValue path = UsdValue.FromPathReference("x"); + + Assert.That(s.Kind, Is.EqualTo(UsdValueKind.String)); + Assert.That(token.Kind, Is.EqualTo(UsdValueKind.Token)); + Assert.That(asset.Kind, Is.EqualTo(UsdValueKind.AssetPath)); + Assert.That(path.Kind, Is.EqualTo(UsdValueKind.PathReference)); + + Assert.That(s.TryGetToken(out _), Is.False); + Assert.That(token.TryGetString(out _), Is.False); + Assert.That(s, Is.Not.EqualTo(token)); + + foreach (UsdValue value in new[] { s, token, asset, path }) + { + Assert.That(value.TryGetText(out string text), Is.True); + Assert.That(text, Is.EqualTo("x")); + } + } + + [Test] + public void NullTextProducesANullValue() + { + Assert.That(UsdValue.FromString(null).IsNull, Is.True); + Assert.That(UsdValue.FromToken(null).IsNull, Is.True); + Assert.That(UsdValue.FromAssetPath(null).IsNull, Is.True); + Assert.That(UsdValue.FromPathReference(null).IsNull, Is.True); + } + + /// + /// A tuple prints as (a, b) and an array as [a, b], so the two must not + /// compare equal even when they carry the same components. + /// + [Test] + public void TupleAndArrayStayDistinct() + { + ArrayOf items = new[] { UsdValue.From(1L), UsdValue.From(2L) }.ToArrayOf(); + UsdValue tuple = UsdValue.FromTuple(items); + UsdValue array = UsdValue.FromArray(items); + + Assert.That(tuple.Kind, Is.EqualTo(UsdValueKind.Tuple)); + Assert.That(array.Kind, Is.EqualTo(UsdValueKind.Array)); + Assert.That(tuple, Is.Not.EqualTo(array)); + Assert.That(tuple.TryGetArray(out _), Is.False); + Assert.That(array.TryGetTuple(out _), Is.False); + } + + [Test] + public void TryGetItemsAcceptsEveryCompositeKind() + { + ArrayOf items = new[] { UsdValue.From(1L) }.ToArrayOf(); + + Assert.That(UsdValue.FromTuple(items).TryGetItems(out _), Is.True); + Assert.That(UsdValue.FromArray(items).TryGetItems(out _), Is.True); + Assert.That(UsdValue.FromMatrix(items).TryGetItems(out _), Is.True); + Assert.That(UsdValue.From(1L).TryGetItems(out _), Is.False); + } + + /// + /// The nesting a cannot express - an array whose elements are + /// themselves tuples, as authored for color3f[]. + /// + [Test] + public void ArrayOfTuplesNests() + { + UsdValue row = UsdValue.FromTuple( + new[] { UsdValue.From(1.0), UsdValue.From(2.0), UsdValue.From(3.0) }.ToArrayOf()); + UsdValue array = UsdValue.FromArray(new[] { row, row }.ToArrayOf()); + + Assert.That(array.TryGetArray(out ArrayOf rows), Is.True); + Assert.That(rows.Count, Is.EqualTo(2)); + Assert.That(rows[0].TryGetTuple(out ArrayOf components), Is.True); + Assert.That(components.Count, Is.EqualTo(3)); + Assert.That(components[2].TryGetDouble(out double third), Is.True); + Assert.That(third, Is.EqualTo(3.0)); + } + + [Test] + public void DictionaryRoundTrips() + { + var entries = new Dictionary(System.StringComparer.Ordinal) + { + ["author"] = UsdValue.FromString("acme"), + ["order"] = UsdValue.From(3L) + }; + + UsdValue value = UsdValue.FromDictionary(entries); + + Assert.That(value.Kind, Is.EqualTo(UsdValueKind.Dictionary)); + Assert.That(value.TryGetDictionary(out IReadOnlyDictionary read), + Is.True); + Assert.That(read, Has.Count.EqualTo(2)); + Assert.That(read["order"].TryGetInteger(out long order), Is.True); + Assert.That(order, Is.EqualTo(3L)); + } + + [Test] + public void EqualValuesShareAHashCode() + { + UsdValue first = UsdValue.FromTuple( + new[] { UsdValue.From(1.0), UsdValue.FromString("a") }.ToArrayOf()); + UsdValue second = UsdValue.FromTuple( + new[] { UsdValue.From(1.0), UsdValue.FromString("a") }.ToArrayOf()); + + Assert.That(first, Is.EqualTo(second)); + bool operatorEquals = first == second; + bool operatorNotEquals = first != second; + Assert.That(operatorEquals, Is.True); + Assert.That(operatorNotEquals, Is.False); + Assert.That(first.GetHashCode(), Is.EqualTo(second.GetHashCode())); + } + + [Test] + public void DifferingComponentsAreNotEqual() + { + UsdValue first = UsdValue.FromTuple(new[] { UsdValue.From(1.0) }.ToArrayOf()); + UsdValue second = UsdValue.FromTuple(new[] { UsdValue.From(2.0) }.ToArrayOf()); + + Assert.That(first, Is.Not.EqualTo(second)); + } + + [Test] + public void ValuesOfDifferentKindsAreNotEqual() + { + Assert.That(UsdValue.From(1L), Is.Not.EqualTo(UsdValue.From(1.0))); + Assert.That(UsdValue.From(true), Is.Not.EqualTo(UsdValue.From(1L))); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderCellTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderCellTests.cs index 5be0b5c193..9d03624072 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderCellTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderCellTests.cs @@ -30,6 +30,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using Opc.Ua; using Opc.Ua.OpenUsdScene.Scene; namespace Opc.Ua.OpenUsdScene.Tests @@ -71,7 +72,7 @@ public void CellRoot_HasCustomOverrideAttribute() UsdAttribute over = UsdTestHelpers.RequireAttribute(cell, "inputs:speedOverride"); Assert.That(over.Custom, Is.True); Assert.That(over.TypeName, Is.EqualTo("double")); - Assert.That(over.Value, Is.EqualTo(100L)); + UsdTestHelpers.AssertInteger(over.Value, 100L); } [Test] @@ -82,12 +83,14 @@ public void SafetyBeacon_TokenAndColorValues() UsdAttribute visibility = UsdTestHelpers.RequireAttribute(beacon, "visibility"); Assert.That(visibility.TypeName, Is.EqualTo("token")); - Assert.That(visibility.Value, Is.EqualTo("invisible")); + UsdTestHelpers.AssertText(visibility.Value, "invisible"); UsdAttribute color = UsdTestHelpers.RequireAttribute(beacon, "primvars:displayColor"); - var outer = color.Value as List; - Assert.That(outer, Is.Not.Null); - Assert.That(outer![0] as object?[], Is.EqualTo(new object?[] { 1L, 0L, 0L })); + Assert.That(color.Value.TryGetArray(out ArrayOf outer), Is.True); + Assert.That(outer.Count, Is.EqualTo(1)); + Assert.That(outer[0].TryGetTuple(out ArrayOf tuple), Is.True); + Assert.That(tuple.ToArray()!.Select(v => v.TryGetInteger(out long integer) ? integer : -1L).ToArray(), + Is.EqualTo(new[] { 1L, 0L, 0L })); } [Test] @@ -98,11 +101,13 @@ public void KeyLight_NumericAndTupleValues() UsdAttribute intensity = UsdTestHelpers.RequireAttribute(key, "intensity"); Assert.That(intensity.TypeName, Is.EqualTo("float")); - Assert.That(intensity.Value, Is.EqualTo(650L)); + UsdTestHelpers.AssertInteger(intensity.Value, 650L); UsdAttribute rotate = UsdTestHelpers.RequireAttribute(key, "xformOp:rotateXYZ"); Assert.That(rotate.TypeName, Is.EqualTo("double3")); - Assert.That(rotate.Value as object?[], Is.EqualTo(new object?[] { -45L, 0L, 35L })); + Assert.That(rotate.Value.TryGetTuple(out ArrayOf rotateValues), Is.True); + Assert.That(rotateValues.ToArray()!.Select(v => v.TryGetInteger(out long integer) ? integer : 0L).ToArray(), + Is.EqualTo(new[] { -45L, 0L, 35L })); } [TestCase(R1)] @@ -169,7 +174,7 @@ public void MergedJoint_RotateAttributeIsLive() UsdPrim j2 = UsdTestHelpers.RequirePrim(_stage, R1 + "/Base/J1/J2"); UsdAttribute rotateY = UsdTestHelpers.RequireAttribute(j2, "xformOp:rotateY"); Assert.That(rotateY.Live, Is.True); - Assert.That(rotateY.Value, Is.EqualTo(-30L)); + UsdTestHelpers.AssertInteger(rotateY.Value, -30L); } [Test] diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderPlantTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderPlantTests.cs index 4a4c2285e4..5142bf235e 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderPlantTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderPlantTests.cs @@ -30,6 +30,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using Opc.Ua; using Opc.Ua.OpenUsdScene.Scene; namespace Opc.Ua.OpenUsdScene.Tests @@ -126,25 +127,26 @@ public void Body_AttributeTypeNamesAndValues() UsdAttribute axis = UsdTestHelpers.RequireAttribute(body, "axis"); Assert.That(axis.TypeName, Is.EqualTo("token")); Assert.That(axis.Variability, Is.EqualTo(UsdVariabilityEnum.Uniform)); - Assert.That(axis.Value, Is.EqualTo("Z")); + UsdTestHelpers.AssertText(axis.Value, "Z"); UsdAttribute radius = UsdTestHelpers.RequireAttribute(body, "radius"); Assert.That(radius.TypeName, Is.EqualTo("double")); - Assert.That(radius.Value, Is.EqualTo(0.5)); + UsdTestHelpers.AssertDouble(radius.Value, 0.5); UsdAttribute color = UsdTestHelpers.RequireAttribute(body, "primvars:displayColor"); Assert.That(color.TypeName, Is.EqualTo("color3f[]")); - var outer = color.Value as List; - Assert.That(outer, Is.Not.Null); - Assert.That(outer!, Has.Count.EqualTo(1)); - var tuple = outer[0] as object?[]; - Assert.That(tuple, Is.Not.Null); - Assert.That(tuple!, Is.EqualTo(new object?[] { 0L, 0L, 1L })); + Assert.That(color.Value.TryGetArray(out ArrayOf outer), Is.True); + Assert.That(outer, Has.Count.EqualTo(1)); + Assert.That(outer[0].TryGetTuple(out ArrayOf tuple), Is.True); + Assert.That(tuple.ToArray()!.Select(v => v.TryGetInteger(out long integer) ? integer : -1L).ToArray(), + Is.EqualTo(new[] { 0L, 0L, 1L })); UsdAttribute order = UsdTestHelpers.RequireAttribute(body, "xformOpOrder"); Assert.That(order.TypeName, Is.EqualTo("token[]")); Assert.That(order.Variability, Is.EqualTo(UsdVariabilityEnum.Uniform)); - Assert.That(order.Value, Is.EqualTo(new List { "xformOp:translate" })); + Assert.That(order.Value.TryGetArray(out ArrayOf orderValues), Is.True); + Assert.That(orderValues.ToArray()!.Select(v => v.TryGetText(out string token) ? token : string.Empty).ToArray(), + Is.EqualTo(new[] { "xformOp:translate" })); } [Test] @@ -156,15 +158,16 @@ public void Impeller_LiveAndCustomAttributes() Assert.That(rotateZ.TypeName, Is.EqualTo("double")); Assert.That(rotateZ.Variability, Is.EqualTo(UsdVariabilityEnum.Varying)); Assert.That(rotateZ.Live, Is.True); - Assert.That(rotateZ.Value, Is.TypeOf()); - Assert.That(rotateZ.Value, Is.Zero); + UsdTestHelpers.AssertInteger(rotateZ.Value, 0L); UsdAttribute setpoint = UsdTestHelpers.RequireAttribute(impeller, "inputs:speedSetpoint"); Assert.That(setpoint.Custom, Is.True); Assert.That(setpoint.TypeName, Is.EqualTo("double")); UsdAttribute order = UsdTestHelpers.RequireAttribute(impeller, "xformOpOrder"); - Assert.That(order.Value, Is.EqualTo(new List { "xformOp:translate", "xformOp:rotateZ" })); + Assert.That(order.Value.TryGetArray(out ArrayOf orderValues), Is.True); + Assert.That(orderValues.ToArray()!.Select(v => v.TryGetText(out string token) ? token : string.Empty).ToArray(), + Is.EqualTo(new[] { "xformOp:translate", "xformOp:rotateZ" })); } [Test] @@ -186,7 +189,7 @@ public void Material_ConnectionIsParsed() UsdAttribute surface = UsdTestHelpers.RequireAttribute(mat, "outputs:surface"); Assert.That(surface.TypeName, Is.EqualTo("token")); - Assert.That(surface.Value, Is.Null); + Assert.That(surface.Value.IsNull, Is.True); Assert.That(surface.Connections, Is.EqualTo(new[] { "/Plant/Pumps/P101/StatusLight/Mat/Surface.outputs:surface", @@ -202,17 +205,21 @@ public void Shader_ScalarTupleAndUnvaluedAttributes() UsdAttribute id = UsdTestHelpers.RequireAttribute(shader, "info:id"); Assert.That(id.TypeName, Is.EqualTo("token")); Assert.That(id.Variability, Is.EqualTo(UsdVariabilityEnum.Uniform)); - Assert.That(id.Value, Is.EqualTo("UsdPreviewSurface")); + UsdTestHelpers.AssertText(id.Value, "UsdPreviewSurface"); UsdAttribute diffuse = UsdTestHelpers.RequireAttribute(shader, "inputs:diffuseColor"); Assert.That(diffuse.TypeName, Is.EqualTo("color3f")); - Assert.That(diffuse.Value as object?[], Is.EqualTo(new object?[] { 0.1, 0.1, 0.1 })); + Assert.That(diffuse.Value.TryGetTuple(out ArrayOf diffuseValues), Is.True); + Assert.That(diffuseValues.ToArray()!.Select(v => v.TryGetDouble(out double d) ? d : double.NaN).ToArray(), + Is.EqualTo(new[] { 0.1, 0.1, 0.1 })); UsdAttribute emissive = UsdTestHelpers.RequireAttribute(shader, "inputs:emissiveColor"); - Assert.That(emissive.Value as object?[], Is.EqualTo(new object?[] { 0L, 0L, 0L })); + Assert.That(emissive.Value.TryGetTuple(out ArrayOf emissiveValues), Is.True); + Assert.That(emissiveValues.ToArray()!.Select(v => v.TryGetInteger(out long integer) ? integer : -1L).ToArray(), + Is.EqualTo(new[] { 0L, 0L, 0L })); UsdAttribute outputs = UsdTestHelpers.RequireAttribute(shader, "outputs:surface"); - Assert.That(outputs.Value, Is.Null); + Assert.That(outputs.Value.IsNull, Is.True); Assert.That(outputs.Connections, Is.Empty); } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaValueParsingTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaValueParsingTests.cs index b9efee0b99..e5d2bf6677 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdaValueParsingTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaValueParsingTests.cs @@ -29,7 +29,9 @@ using System.Collections.Generic; using NUnit.Framework; +using Opc.Ua; using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; namespace Opc.Ua.OpenUsdScene.Tests { @@ -42,7 +44,7 @@ public class UsdaValueParsingTests [TestCase("0", 0L)] public void Integers_ParseAsLong(string raw, long expected) { - Assert.That(UsdaReader.ParseValue(raw), Is.EqualTo(expected)); + UsdTestHelpers.AssertInteger(UsdaReader.ParseValue(raw), expected); } [TestCase("3.14", 3.14)] @@ -53,82 +55,81 @@ public void Integers_ParseAsLong(string raw, long expected) [TestCase("6.02E2", 602.0)] public void Floats_ParseAsDouble(string raw, double expected) { - object? value = UsdaReader.ParseValue(raw); - Assert.That(value, Is.TypeOf()); - Assert.That((double)value!, Is.EqualTo(expected).Within(1e-12)); + UsdValue value = UsdaReader.ParseValue(raw); + Assert.That(value.TryGetDouble(out double actual), Is.True); + Assert.That(actual, Is.EqualTo(expected).Within(1e-12)); } [TestCase("true", true)] [TestCase("false", false)] public void Booleans_ParseAsBool(string raw, bool expected) { - Assert.That(UsdaReader.ParseValue(raw), Is.EqualTo(expected)); + UsdTestHelpers.AssertBoolean(UsdaReader.ParseValue(raw), expected); } [Test] public void QuotedString_IsUnwrapped() { - Assert.That(UsdaReader.ParseValue("\"hello world\""), Is.EqualTo("hello world")); + UsdTestHelpers.AssertString(UsdaReader.ParseValue("\"hello world\""), "hello world"); } [Test] public void QuotedString_WithSpecialCharacters() { - Assert.That(UsdaReader.ParseValue("\"a, (b) [c] \""), Is.EqualTo("a, (b) [c] ")); + UsdTestHelpers.AssertString(UsdaReader.ParseValue("\"a, (b) [c] \""), "a, (b) [c] "); } [Test] public void QuotedString_WithEscapedQuotes() { - Assert.That(UsdaReader.ParseValue("\"say \\\"hi\\\"\""), Is.EqualTo("say \"hi\"")); + UsdTestHelpers.AssertString(UsdaReader.ParseValue("\"say \\\"hi\\\"\""), "say \"hi\""); } [Test] public void BareToken_IsReturnedAsString() { - Assert.That(UsdaReader.ParseValue("inherited"), Is.EqualTo("inherited")); + UsdTestHelpers.AssertToken(UsdaReader.ParseValue("inherited"), "inherited"); } [TestCase("@pump.usda@", "pump.usda")] [TestCase("@./sub/robot.usda@", "./sub/robot.usda")] public void AssetPath_IsUnwrapped(string raw, string expected) { - Assert.That(UsdaReader.ParseValue(raw), Is.EqualTo(expected)); + UsdTestHelpers.AssertAssetPath(UsdaReader.ParseValue(raw), expected); } [Test] public void PathReference_IsUnwrapped() { - Assert.That(UsdaReader.ParseValue(""), Is.EqualTo("/Plant/Pumps/P101")); + UsdTestHelpers.AssertPathReference( + UsdaReader.ParseValue(""), + "/Plant/Pumps/P101"); } [Test] public void IntegerTuple_ParsesToObjectArray() { - Assert.That(UsdaReader.ParseValue("(0, 0, 0)"), Is.EqualTo(new object?[] { 0L, 0L, 0L })); - Assert.That(UsdaReader.ParseValue("(-45, 0, 35)"), Is.EqualTo(new object?[] { -45L, 0L, 35L })); + UsdTestHelpers.AssertIntegerItems(UsdaReader.ParseValue("(0, 0, 0)"), 0L, 0L, 0L); + UsdTestHelpers.AssertIntegerItems(UsdaReader.ParseValue("(-45, 0, 35)"), -45L, 0L, 35L); } [Test] public void FloatTuple_ParsesToObjectArray() { - Assert.That(UsdaReader.ParseValue("(0.1, 0.1, 0.1)"), Is.EqualTo(new object?[] { 0.1, 0.1, 0.1 })); + UsdTestHelpers.AssertDoubleItems(UsdaReader.ParseValue("(0.1, 0.1, 0.1)"), 0.1, 0.1, 0.1); } [Test] public void IntegerArray_ParsesToList() { - Assert.That(UsdaReader.ParseValue("[1, 2, 3]"), Is.EqualTo(new List { 1L, 2L, 3L })); + UsdTestHelpers.AssertIntegerItems(UsdaReader.ParseValue("[1, 2, 3]"), 1L, 2L, 3L); } [Test] public void NestedTupleArray_ParsesToListOfArray() { - object? value = UsdaReader.ParseValue("[(0, 0, 1)]"); - var list = value as List; - Assert.That(list, Is.Not.Null); - Assert.That(list!, Has.Count.EqualTo(1)); - Assert.That(list[0] as object?[], Is.EqualTo(new object?[] { 0L, 0L, 1L })); + UsdValue value = UsdaReader.ParseValue("[(0, 0, 1)]"); + UsdTestHelpers.AssertNestedIntegerItems(value, new[] { 0L, 0L, 1L }); } [Test] @@ -136,32 +137,34 @@ public void StringArray_ParsesToList() { Assert.That( UsdaReader.ParseValue("[\"xformOp:translate\", \"xformOp:rotateZ\"]"), - Is.EqualTo(new List { "xformOp:translate", "xformOp:rotateZ" })); + Is.EqualTo(UsdTestHelpers.StringArray("xformOp:translate", "xformOp:rotateZ"))); } [Test] public void MixedFloatTuple_HandlesLeadingDotAndExponent() { - object? value = UsdaReader.ParseValue("(0.5, -1.5e2, .25)"); - var tuple = value as object?[]; - Assert.That(tuple, Is.Not.Null); - Assert.That(tuple!, Has.Length.EqualTo(3)); - Assert.That((double)tuple[0]!, Is.EqualTo(0.5).Within(1e-12)); - Assert.That((double)tuple[1]!, Is.EqualTo(-150.0).Within(1e-12)); - Assert.That((double)tuple[2]!, Is.EqualTo(0.25).Within(1e-12)); + UsdValue value = UsdaReader.ParseValue("(0.5, -1.5e2, .25)"); + Assert.That(value.TryGetTuple(out ArrayOf tuple), Is.True); + Assert.That(tuple, Has.Count.EqualTo(3)); + Assert.That(tuple[0].TryGetDouble(out double first), Is.True); + Assert.That(tuple[1].TryGetDouble(out double second), Is.True); + Assert.That(tuple[2].TryGetDouble(out double third), Is.True); + Assert.That(first, Is.EqualTo(0.5).Within(1e-12)); + Assert.That(second, Is.EqualTo(-150.0).Within(1e-12)); + Assert.That(third, Is.EqualTo(0.25).Within(1e-12)); } [TestCase("")] [TestCase(" ")] public void EmptyOrWhitespace_ParsesToNull(string raw) { - Assert.That(UsdaReader.ParseValue(raw), Is.Null); + Assert.That(UsdaReader.ParseValue(raw).IsNull, Is.True); } [Test] public void Null_ParsesToNull() { - Assert.That(UsdaReader.ParseValue(null), Is.Null); + Assert.That(UsdaReader.ParseValue(null).IsNull, Is.True); } [Test] diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaWriterInjectionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaWriterInjectionTests.cs index 4cbf614018..afc2b7cfa7 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdaWriterInjectionTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaWriterInjectionTests.cs @@ -171,7 +171,7 @@ private static UsdStage CreateStage(string typeName, string value) { var stage = new UsdStage("Injection"); var prim = new UsdPrim("Target"); - prim.Attributes.Add(new UsdAttribute("label", typeName) { Value = value }); + prim.Attributes.Add(new UsdAttribute("label", typeName) { Value = UsdValue.FromString(value) }); stage.AddRootPrim(prim); return stage; } diff --git a/tests/Opc.Ua.OpenUsd.Tests/VariantBranchConversionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/VariantBranchConversionTests.cs index 704728be40..0922597162 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/VariantBranchConversionTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/VariantBranchConversionTests.cs @@ -108,8 +108,8 @@ public void BranchBody_CapturesAttributes() UsdPrim high = set.Variants.Single(); Assert.That(high.Attributes.Select(a => a.Name), Is.EqualTo(new[] { "resolution", "quality" })); - Assert.That(high.Attributes[0].Value, Is.EqualTo(1024L)); - Assert.That(high.Attributes[1].Value, Is.EqualTo("best")); + UsdTestHelpers.AssertInteger(high.Attributes[0].Value, 1024L); + UsdTestHelpers.AssertString(high.Attributes[1].Value, "best"); } [Test] @@ -199,16 +199,16 @@ public void Branches_RoundTripThroughWriteAndReparse() UsdStage stage = BuildStageWithBranches("high", "low"); // Give the branches distinct bodies so a lost or reordered branch is observable. UsdVariantSet set = stage.Find("/P")!.VariantSets.Single(); - set.Variants[0].Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); - set.Variants[1].Attributes.Add(new UsdAttribute("resolution", "int") { Value = 256L }); + set.Variants[0].Attributes.Add(new UsdAttribute("resolution", "int") { Value = UsdValue.From(1024L) }); + set.Variants[1].Attributes.Add(new UsdAttribute("resolution", "int") { Value = UsdValue.From(256L) }); string written = UsdaWriter.Write(stage); UsdStage reparsed = UsdaReader.Parse(written, stage.StageName); UsdVariantSet reSet = reparsed.Find("/P")!.VariantSets.Single(); Assert.That(reSet.Variants.Select(v => v.Name), Is.EqualTo(new[] { "high", "low" })); - Assert.That(reSet.Variants[0].Attributes.Single().Value, Is.EqualTo(1024L)); - Assert.That(reSet.Variants[1].Attributes.Single().Value, Is.EqualTo(256L)); + UsdTestHelpers.AssertInteger(reSet.Variants[0].Attributes.Single().Value, 1024L); + UsdTestHelpers.AssertInteger(reSet.Variants[1].Attributes.Single().Value, 256L); } [Test] @@ -216,7 +216,7 @@ public void WriterOutputIsAFixedPoint_ForVariantBranches() { UsdStage stage = BuildStageWithBranches("high", "low"); UsdVariantSet set = stage.Find("/P")!.VariantSets.Single(); - set.Variants[0].Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + set.Variants[0].Attributes.Add(new UsdAttribute("resolution", "int") { Value = UsdValue.From(1024L) }); set.Variants[0].AddChild(new UsdPrim("Detail", "Xform")); string firstWrite = UsdaWriter.Write(stage); @@ -235,14 +235,14 @@ public void Signature_IgnoresNonSelectedBranches() UsdStage a = BuildStageWithBranches("high", "low"); a.Find("/P")!.VariantSets.Single().Selection = "high"; a.Find("/P")!.VariantSets.Single().Variants[0] - .Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + .Attributes.Add(new UsdAttribute("resolution", "int") { Value = UsdValue.From(1024L) }); UsdStage b = BuildStageWithBranches("high", "low"); b.Find("/P")!.VariantSets.Single().Selection = "high"; b.Find("/P")!.VariantSets.Single().Variants[0] - .Attributes.Add(new UsdAttribute("resolution", "int") { Value = 256L }); + .Attributes.Add(new UsdAttribute("resolution", "int") { Value = UsdValue.From(256L) }); b.Find("/P")!.VariantSets.Single().Variants[1] - .Attributes.Add(new UsdAttribute("extra", "double") { Value = 3.5 }); + .Attributes.Add(new UsdAttribute("extra", "double") { Value = UsdValue.From(3.5) }); Assert.That( UsdSceneSignature.Compute(b), diff --git a/tests/Opc.Ua.OpenUsd.Tests/VariantBranchTests.cs b/tests/Opc.Ua.OpenUsd.Tests/VariantBranchTests.cs index a6477de84f..306a502a89 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/VariantBranchTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/VariantBranchTests.cs @@ -120,7 +120,7 @@ public void SelectionWithoutCapturedBranches_MaterializesNoBranch() public void BranchBody_MaterializesAttributes() { var high = new UsdPrim("high"); - high.Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + high.Attributes.Add(new UsdAttribute("resolution", "int") { Value = UsdValue.From(1024L) }); var set = new UsdVariantSet("lod", "high"); set.Variants.Add(high); (MaterializedScene ms, UsdVariantSetState node) = MaterializeSet(set); @@ -208,7 +208,7 @@ public void VariantBranch_IsNotADirectPrimChild() public void Branches_RoundTripThroughMaterializeAndExport() { var high = new UsdPrim("high"); - high.Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + high.Attributes.Add(new UsdAttribute("resolution", "int") { Value = UsdValue.From(1024L) }); var set = new UsdVariantSet("lod", "high"); set.Variants.Add(high); set.Variants.Add(new UsdPrim("low")); From b677b20e050227571ae866fabb40a0be3493cde4 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 18:29:57 +0200 Subject: [PATCH 4/5] Address review feedback on the UsdValue value model - UsdValue.GetHashCode now folds the dictionary entries (an order independent sum of key/value hashes) instead of only the entry count, so dictionaries of the same size no longer all collide in hash based collections. - UsdValue.ToString renders a Dictionary as its entries ordered by key instead of falling through to the null m_text default, so a caller that falls back to the textual form (metadata materialization) no longer silently drops data. - UsdValueCoercion decoerces a UInt64 above long.MaxValue to its invariant decimal text instead of casting to long, which silently wrapped it into a negative integer, for scalars, arrays and matrices alike. TryAsUInt64 reads that form back, and UsdaReader parses an integral literal that overflows a signed 64 bit integer as a token holding its exact digits (it previously threw OverflowException), so the value survives the full export/import round trip. - Fixed CA1307/CA2249 on the string quote probe added by this PR. Adds unit tests for the dictionary hash/rendering and the uint64 round trip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d089bd1c-b795-4f2b-a872-eb3090c11536 --- .../Conversion/UsdValueCoercion.cs | 32 +++++++++- .../Conversion/UsdaReader.cs | 26 +++++++- src/Opc.Ua.OpenUsdScene/NugetREADME.md | 4 ++ src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs | 59 +++++++++++++++++- .../ConversionFixTests.cs | 57 +++++++++++++++++ tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs | 62 +++++++++++++++++++ 6 files changed, 232 insertions(+), 8 deletions(-) diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs index 5348966885..1c3e7933b2 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs @@ -193,7 +193,7 @@ private static UsdValue DecoerceScalar(in Variant value, BuiltInType elementType return value.TryGetValue(out uint ui) ? UsdValue.From(ui) : UsdValue.Null; case BuiltInType.UInt64: return value.TryGetValue(out ulong ul) - ? UsdValue.From((long)ul) + ? FromUInt64(ul) : UsdValue.Null; case BuiltInType.Float: return value.TryGetValue(out float f) ? UsdValue.From(f) : UsdValue.Null; @@ -234,7 +234,7 @@ private static UsdValue DecoerceArray(in Variant value, BuiltInType elementType) : UsdValue.Null; case BuiltInType.UInt64: return value.TryGetValue(out ArrayOf ul) - ? Wrap(ul, static x => UsdValue.From((long)x)) + ? Wrap(ul, FromUInt64) : UsdValue.Null; case BuiltInType.Float: return value.TryGetValue(out ArrayOf f) @@ -279,7 +279,7 @@ private static UsdValue DecoerceMatrix(in Variant value, BuiltInType elementType : UsdValue.Null; case BuiltInType.UInt64: return value.TryGetValue(out MatrixOf ul) - ? Regroup(ul, static x => UsdValue.From((long)x)) + ? Regroup(ul, FromUInt64) : UsdValue.Null; case BuiltInType.Float: return value.TryGetValue(out MatrixOf f) @@ -298,6 +298,24 @@ private static UsdValue DecoerceMatrix(in Variant value, BuiltInType elementType } } + /// + /// Reads an unsigned 64 bit value into a USD value. + /// + /// + /// A value up to stays integral. No USD kind can carry a + /// larger one integrally, so it is preserved as its invariant decimal text - which + /// reads back - rather than cast to long, which would + /// silently wrap it into a negative integer and author a wrong value on export. + /// + /// The unsigned value read from the Variable. + /// The USD value. + private static UsdValue FromUInt64(ulong value) + { + return value <= long.MaxValue + ? UsdValue.From((long)value) + : UsdValue.FromToken(value.ToString(CultureInfo.InvariantCulture)); + } + /// /// Wraps a one dimensional value as an array. /// @@ -684,6 +702,14 @@ private static bool TryAsInt64(UsdValue value, out long result) private static bool TryAsUInt64(UsdValue value, out ulong result) { + // A value above long.MaxValue has no integral USD kind to carry it, so Decoerce + // preserves it as its invariant decimal text (see FromUInt64); read that form back + // before falling back to the signed path. + if (value.TryGetText(out string text) && + ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)) + { + return true; + } if (!TryAsInt64(value, out long signed) || signed < 0L) { result = 0UL; diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs index eb16830865..565fa469c8 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs @@ -794,7 +794,7 @@ internal static UsdValue ParseValue(string? raw) } if (IntRegex().IsMatch(v)) { - return UsdValue.From(long.Parse(v, CultureInfo.InvariantCulture)); + return ParseIntegral(v); } if (FloatRegex().IsMatch(v)) { @@ -803,11 +803,31 @@ internal static UsdValue ParseValue(string? raw) } // A bare word is a token; anything that still carries quotes is a string whose // quoting the literal parser could not resolve (for example an unterminated one). - return v.IndexOf('"') >= 0 + return v.Contains('"', StringComparison.Ordinal) ? UsdValue.FromString(v.Trim('"')) : UsdValue.FromToken(v); } + /// + /// Parses an integral literal. + /// + /// + /// A literal that does not fit a signed 64 bit integer - a uint64 above + /// , which is what the conversion layer authors as text - is + /// carried as a token holding its exact digits. It therefore neither overflows the parse + /// nor loses precision to a double, and the coercion layer reads it back into a + /// uint64. + /// + /// The literal text. + /// The parsed value. + private static UsdValue ParseIntegral(string text) + { + return long.TryParse( + text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed) + ? UsdValue.From(parsed) + : UsdValue.FromToken(text); + } + private static List ParseTargets(string raw) { var targets = new List(); @@ -1315,7 +1335,7 @@ private static bool TryParseNumber(string s, ref int pos, out UsdValue result) string token = s.Substring(start, pos - start); if (IntRegex().IsMatch(token)) { - result = UsdValue.From(long.Parse(token, CultureInfo.InvariantCulture)); + result = ParseIntegral(token); return true; } if (FloatRegex().IsMatch(token)) diff --git a/src/Opc.Ua.OpenUsdScene/NugetREADME.md b/src/Opc.Ua.OpenUsdScene/NugetREADME.md index 303f318ac7..d178f55ef0 100644 --- a/src/Opc.Ua.OpenUsdScene/NugetREADME.md +++ b/src/Opc.Ua.OpenUsdScene/NugetREADME.md @@ -61,6 +61,10 @@ type safety without changing the emitted `.usda`. The same type carries `UsdAttribute.TimeSamples` and `UsdPrim.Metadata`, and a nested metadata dictionary is a `UsdValue` of kind `Dictionary`. +An integral value that does not fit a signed 64 bit integer — a `uint64` above `long.MaxValue` — has +no integral kind to carry it, so it arrives as a `Token` holding its exact decimal digits rather than +being wrapped into a negative `Integer`; the coercion layer reads that form back into a `uint64`. + ## Related packages - `Opc.Ua.OpenUsdScene.Server` — materializes a scene into a server address space and exports it back diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs index 751891c226..71b45ce8a2 100644 --- a/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs @@ -433,8 +433,11 @@ public override int GetHashCode() } break; case UsdValueKind.Dictionary: - // Order independent so two equal dictionaries hash alike. + // The entry hashes are summed rather than sequenced, so two equal dictionaries + // hash alike whatever order they enumerate in while dictionaries that differ + // only in their entries - not in their size - still separate. hash.Add(m_entries?.Count ?? 0); + hash.Add(EntriesHashCode(m_entries)); break; default: break; @@ -464,7 +467,13 @@ public override int GetHashCode() return !left.Equals(right); } - /// + /// + /// Renders this value to its invariant textual form, which is what a caller that cannot + /// carry the value in a typed shape falls back to. A composite renders its items and a + /// dictionary its entries ordered by key, so the text is deterministic and no value is + /// silently rendered as the empty string. + /// + /// The textual form. public override string ToString() { switch (m_kind) @@ -482,6 +491,8 @@ public override string ToString() return "(" + JoinItems() + ")"; case UsdValueKind.Array: return "[" + JoinItems() + "]"; + case UsdValueKind.Dictionary: + return "{" + JoinEntries() + "}"; default: return m_text ?? string.Empty; } @@ -522,6 +533,33 @@ private string JoinItems() return builder.ToString(); } + private string JoinEntries() + { + if (m_entries == null || m_entries.Count == 0) + { + return string.Empty; + } + var keys = new List(m_entries.Count); + foreach (KeyValuePair entry in m_entries) + { + keys.Add(entry.Key); + } + // Ordered so the rendering of a dictionary does not depend on its enumeration order. + keys.Sort(StringComparer.Ordinal); + var builder = new System.Text.StringBuilder(); + for (int ii = 0; ii < keys.Count; ii++) + { + if (ii > 0) + { + builder.Append(", "); + } + builder.Append(keys[ii]) + .Append(": ") + .Append(m_entries[keys[ii]].ToString()); + } + return builder.ToString(); + } + private static bool ItemsEqual(UsdValue[]? left, UsdValue[]? right) { System.ReadOnlySpan a = left ?? []; @@ -565,6 +603,23 @@ private static bool EntriesEqual( return true; } + private static int EntriesHashCode(IReadOnlyDictionary? entries) + { + int combined = 0; + if (entries != null) + { + foreach (KeyValuePair entry in entries) + { + // Addition is commutative, so the result does not depend on the order the + // entries enumerate in, which is what keeps this consistent with Equals. + combined = unchecked(combined + HashCode.Combine( + StringComparer.Ordinal.GetHashCode(entry.Key), + entry.Value.GetHashCode())); + } + } + return combined; + } + private static readonly IReadOnlyDictionary s_emptyEntries = new Dictionary(StringComparer.Ordinal); diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs index 41f9b7fe33..a47151378c 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs @@ -47,6 +47,8 @@ namespace Opc.Ua.OpenUsdScene.Tests /// string. /// H4 — the writer emits a co-authored default value together with every connection /// target, not just the first connection (§5.4). + /// A uint64 above survives the export round trip as + /// its invariant decimal text instead of wrapping into a negative integer. /// /// [TestFixture] @@ -301,5 +303,60 @@ public void SingleConnection_IsEmittedAsBarePathReference() Assert.That(usda, Does.Contain(".connect =

")); Assert.That(usda, Does.Not.Contain("[

]")); } + + // ---- A uint64 above long.MaxValue is preserved, never wrapped to a negative integer ---- + + [Test] + public void UInt64_WithinInt64Max_StaysIntegral() + { + UsdTestHelpers.AssertInteger(UsdValueCoercion.Decoerce(Variant.From(42UL)), 42L); + } + + [Test] + public void UInt64_AboveInt64Max_IsPreservedAsInvariantText() + { + UsdValue decoerced = UsdValueCoercion.Decoerce(Variant.From(ulong.MaxValue)); + + // The unconditional cast this replaces authored "-1" for ulong.MaxValue. + UsdTestHelpers.AssertToken(decoerced, "18446744073709551615"); + } + + [Test] + public void UInt64_AboveInt64Max_RoundTripsBackToTheSameValue() + { + UsdValue decoerced = UsdValueCoercion.Decoerce(Variant.From(ulong.MaxValue)); + + bool ok = Coerce("uint64", decoerced, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out ulong recovered), Is.True); + Assert.That(recovered, Is.EqualTo(ulong.MaxValue)); + } + + [Test] + public void UInt64Array_AboveInt64Max_IsPreservedElementwise() + { + UsdValue decoerced = UsdValueCoercion.Decoerce( + Variant.From((ArrayOf)new[] { 1UL, ulong.MaxValue })); + + Assert.That(decoerced.TryGetArray(out ArrayOf items), Is.True); + Assert.That(items.Count, Is.EqualTo(2)); + UsdTestHelpers.AssertInteger(items[0], 1L); + UsdTestHelpers.AssertToken(items[1], "18446744073709551615"); + } + + [Test] + public void UInt64_AboveInt64Max_ReparsesFromItsAuthoredLiteral() + { + // The authored literal must neither overflow the reader's integral parse nor lose its + // digits to a double, so the coercion layer recovers the exact value. + UsdValue parsed = UsdaReader.ParseValue("18446744073709551615"); + + bool ok = Coerce("uint64", parsed, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out ulong recovered), Is.True); + Assert.That(recovered, Is.EqualTo(ulong.MaxValue)); + } } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs index 882f61d842..5e9ee1cb3f 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs @@ -232,6 +232,68 @@ public void DifferingComponentsAreNotEqual() Assert.That(first, Is.Not.EqualTo(second)); } + [Test] + public void EqualDictionariesShareAHashCodeWhateverTheEntryOrder() + { + UsdValue first = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["author"] = UsdValue.FromString("acme"), + ["order"] = UsdValue.From(3L) + }); + UsdValue second = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["order"] = UsdValue.From(3L), + ["author"] = UsdValue.FromString("acme") + }); + + Assert.That(first, Is.EqualTo(second)); + Assert.That(first.GetHashCode(), Is.EqualTo(second.GetHashCode())); + } + + [Test] + public void DictionariesOfTheSameSizeDoNotShareAHashCode() + { + // The hash must take the entries into account, not only their count, or every + // dictionary of the same size would collide in a hash based collection. + UsdValue first = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["author"] = UsdValue.FromString("acme"), + ["order"] = UsdValue.From(3L) + }); + UsdValue second = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["author"] = UsdValue.FromString("globex"), + ["order"] = UsdValue.From(4L) + }); + + Assert.That(first, Is.Not.EqualTo(second)); + Assert.That(first.GetHashCode(), Is.Not.EqualTo(second.GetHashCode())); + } + + [Test] + public void DictionaryRendersItsEntriesOrderedByKey() + { + UsdValue value = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["order"] = UsdValue.From(3L), + ["author"] = UsdValue.FromString("acme"), + ["nested"] = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["depth"] = UsdValue.From(1L) + }) + }); + + // A dictionary must not stringify to the empty string: a caller that falls back to + // the textual form would silently drop the authored entries. + Assert.That(value.ToString(), Is.EqualTo("{author: acme, nested: {depth: 1}, order: 3}")); + } + [Test] public void ValuesOfDifferentKindsAreNotEqual() { From 3a96b0afe05e5b5877f1f91a77f51dd77b78a91c Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 3 Aug 2026 15:13:35 +0200 Subject: [PATCH 5/5] Raise patch coverage on the UsdValue value model Codecov reported 74.47% patch coverage; the gap was almost entirely the conversion layer's per-element-type switches, which had no per-type test. - Adds UsdValueCoercionTests: every element type the 6.2 bindings can name, in both directions (TryCoerce and Decoerce) and at all three ranks, plus the fail-closed paths (out-of-range integers, a structured value bound to a scalar, an unsupported element type or rank, a float that overflows, text that is not a number or a boolean) and the widening paths (bool as a number, integer authored as a double, scalar as a one element sequence). - UsdValueTests: rendering of every scalar kind and of composites, an empty dictionary, matrix accessor, non numeric TryGetNumber, and the equality and hash edges (different lengths, differing dictionary keys or sizes, boxed Equals). - UsdSceneSignatureTests: normalization of a boolean and of a nested dictionary value, including that entry order does not change the signature. - ConversionEmitPathTests / ConversionFixTests: color3f[] regrouping from a flat component run and from grouped rows, composite prim metadata, a bool attribute, and an opaque boolean. - ConversionAsymmetryTests: relationship and connection targets authored as quoted strings. Local patch coverage over the changed source lines is now 99.0% (was 76.3%); the residual lines are defensive branches with no reachable input. Test only change - no production code touched. 772 tests pass on net10.0 and net48. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d089bd1c-b795-4f2b-a872-eb3090c11536 --- .../ConversionAsymmetryTests.cs | 54 ++ .../ConversionEmitPathTests.cs | 78 +++ .../ConversionFixTests.cs | 11 + .../UsdSceneSignatureTests.cs | 68 ++ .../UsdValueCoercionTests.cs | 589 ++++++++++++++++++ tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs | 150 +++++ 6 files changed, 950 insertions(+) create mode 100644 tests/Opc.Ua.OpenUsd.Tests/UsdValueCoercionTests.cs diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs index 33ae7739c2..d958a69dbe 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs @@ -159,6 +159,60 @@ public void EmptyConnectionList_ParsesToNoTargets() Assert.That(attr.Connections, Is.Empty); } + [Test] + public void QuotedRelationshipTarget_StillParsesAsATarget() + { + // A relationship target authored as a quoted string rather than a reference + // is read from the value's text rather than dropped. + UsdStage stage = UsdaReader.Parse( + Wrap(" rel material:binding = \"/P/Mat\""), + "Rel"); + + UsdRelationship rel = UsdTestHelpers.RequireRelationship( + UsdTestHelpers.RequirePrim(stage, "/P"), "material:binding"); + Assert.That(rel.Targets, Is.EqualTo(new[] { "/P/Mat" })); + } + + [Test] + public void QuotedRelationshipTargetList_ParsesEachTarget() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" rel material:binding = [\"/P/MatA\", \"/P/MatB\"]"), + "Rel"); + + UsdRelationship rel = UsdTestHelpers.RequireRelationship( + UsdTestHelpers.RequirePrim(stage, "/P"), "material:binding"); + Assert.That(rel.Targets, Is.EqualTo(new[] { "/P/MatA", "/P/MatB" })); + } + + [Test] + public void QuotedConnectionTarget_StillParsesAsATarget() + { + // A target authored as a quoted string rather than a reference is still a + // target: the reader falls back to reading the value's text rather than dropping it. + UsdStage stage = UsdaReader.Parse( + Wrap(" token inputs:surface.connect = \"/P/A.outputs:surface\""), + "Conn"); + + UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:surface"); + Assert.That(attr.Connections, Is.EqualTo(new[] { "/P/A.outputs:surface" })); + } + + [Test] + public void QuotedConnectionTargetList_ParsesEachTarget() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" token inputs:surface.connect = [\"/P/A.outputs:surface\", \"/P/B.outputs:surface\"]"), + "Conn"); + + UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:surface"); + Assert.That(attr.Connections, Is.EqualTo(new[] + { + "/P/A.outputs:surface", + "/P/B.outputs:surface", + })); + } + // ---- Task 1.2: asset arrays --------------------------------------------------- [Test] diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs index f2783f58d9..22f35d5d62 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs @@ -436,5 +436,83 @@ public void CustomMetadata_WithCloseParenInStringValue_RoundTrips() Assert.That(prim2.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); UsdTestHelpers.AssertString(prim2.Metadata["comment"], "torque curve peaks at (n) then drops)"); } + + [Test] + public void Color3fArray_FlatComponentRun_IsRegroupedIntoTupleRows() + { + // A tuple-group base type handed back as a flat run of components must be regrouped + // per tuple, so a color3f[] still authors "[(r, g, b), …]" and not a flat list. + string usda = EmitRootAttribute( + "Mesh", + new UsdAttribute("primvars:displayColor", "color3f[]") + { + Value = UsdTestHelpers.NumberArray(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + }); + + Assert.That( + usda, + Does.Contain("color3f[] primvars:displayColor = [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0)]")); + } + + [Test] + public void Color3fArray_GroupedRows_AreEmittedOnePerElement() + { + // Three already-grouped rows are themselves divisible by the group width, so the + // writer must notice the elements are sequences and not regroup them a second time. + string usda = EmitRootAttribute( + "Mesh", + new UsdAttribute("primvars:displayColor", "color3f[]") + { + Value = UsdTestHelpers.Array( + UsdTestHelpers.NumberTuple(1.0, 0.0, 0.0), + UsdTestHelpers.NumberTuple(0.0, 1.0, 0.0), + UsdTestHelpers.NumberTuple(0.0, 0.0, 1.0)), + }); + + Assert.That( + usda, + Does.Contain( + "color3f[] primvars:displayColor = " + + "[(1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)]")); + } + + [Test] + public void OpaqueAbsentValue_RendersAsEmptyText() + { + bool rendered = UsdaWriter.TryRenderOpaqueValue(UsdValue.Null, out string text); + + Assert.That(rendered, Is.True); + Assert.That(text, Is.Empty); + } + + [Test] + public void CompositeMetadata_IsEmittedInUsdSyntax() + { + // A tuple/array metadata value has no scalar spelling, so it renders through the + // structured renderer rather than being published as a CLR type name. + var stage = new UsdStage("Meta") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Metadata["extent"] = UsdTestHelpers.NumberTuple(1.0, 2.0); + prim.Metadata["order"] = UsdTestHelpers.IntegerArray(1L, 2L); + prim.Metadata["source"] = UsdTestHelpers.Array(UsdValue.FromPathReference("/P/A")); + stage.AddRootPrim(prim); + + string usda = UsdaWriter.Write(stage); + + Assert.That(usda, Does.Contain("extent = (1.0, 2.0)")); + Assert.That(usda, Does.Contain("order = [1, 2]")); + Assert.That(usda, Does.Contain("source = ['/P/A']")); + Assert.That(usda, Does.Not.Contain("System.")); + } + + [Test] + public void BoolAttribute_IsEmittedAsALowerCaseLiteral() + { + string usda = EmitRootAttribute( + "Xform", + new UsdAttribute("visible", "bool") { Value = UsdValue.From(false) }); + + Assert.That(usda, Does.Contain("bool visible = false")); + } } } diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs index a47151378c..65b271080b 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs @@ -89,6 +89,17 @@ public void OpaqueArray_RendersUsdSyntax_NotClrTypeName() Assert.That(rendered, Does.Not.Contain("System.")); } + [Test] + public void OpaqueBoolean_RendersItsUsdSpelling() + { + // A bool carried opaquely must author USD's "true"/"false", never a CLR spelling. + bool ok = Coerce("mvtype", UsdValue.From(true), out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out string rendered), Is.True); + Assert.That(rendered, Is.EqualTo("true")); + } + [Test] public void OpaqueNestedTuple_RendersRecursively() { diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs index 8c4f7aa427..3542dfa3c6 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs @@ -107,6 +107,74 @@ public void Signature_IsSensitiveToCompositionArcOrder() Assert.That(UsdSceneSignature.Compute(b), Is.Not.EqualTo(UsdSceneSignature.Compute(a))); } + [Test] + public void Signature_NormalizesEveryValueKind() + { + // Every kind must reach the normalizer: an unnormalized kind would make two + // different scenes sign identically. + var a = new UsdStage("S"); + UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); + pa.Attributes.Add(new UsdAttribute("flag", "bool") { Value = UsdValue.From(true) }); + + var b = new UsdStage("S"); + UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); + pb.Attributes.Add(new UsdAttribute("flag", "bool") { Value = UsdValue.From(false) }); + + Assert.That(UsdSceneSignature.Compute(b), Is.Not.EqualTo(UsdSceneSignature.Compute(a))); + Assert.That(UsdSceneSignature.FirstDifference(a, b), Is.Not.Null); + } + + [Test] + public void Signature_OfADictionaryValueIsIndependentOfEntryOrder() + { + var a = new UsdStage("S"); + UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); + pa.Attributes.Add(new UsdAttribute("d", "dictionary") + { + Value = Dictionary(("author", UsdValue.FromString("acme")), ("order", UsdValue.From(3L))) + }); + + var b = new UsdStage("S"); + UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); + pb.Attributes.Add(new UsdAttribute("d", "dictionary") + { + Value = Dictionary(("order", UsdValue.From(3L)), ("author", UsdValue.FromString("acme"))) + }); + + Assert.That(UsdSceneSignature.Compute(b), Is.EqualTo(UsdSceneSignature.Compute(a))); + } + + [Test] + public void Signature_DetectsAChangedDictionaryEntry() + { + var a = new UsdStage("S"); + UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); + pa.Attributes.Add(new UsdAttribute("d", "dictionary") + { + Value = Dictionary(("nested", Dictionary(("depth", UsdValue.From(1L))))) + }); + + var b = new UsdStage("S"); + UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); + pb.Attributes.Add(new UsdAttribute("d", "dictionary") + { + Value = Dictionary(("nested", Dictionary(("depth", UsdValue.From(2L))))) + }); + + Assert.That(UsdSceneSignature.Compute(b), Is.Not.EqualTo(UsdSceneSignature.Compute(a))); + } + + private static UsdValue Dictionary(params (string Key, UsdValue Value)[] entries) + { + var map = new System.Collections.Generic.Dictionary( + System.StringComparer.Ordinal); + foreach ((string key, UsdValue value) in entries) + { + map[key] = value; + } + return UsdValue.FromDictionary(map); + } + private static UsdStage BuildStage(bool withNonComposedState) { var stage = new UsdStage("S") diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdValueCoercionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdValueCoercionTests.cs new file mode 100644 index 0000000000..1e346acd6d --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdValueCoercionTests.cs @@ -0,0 +1,589 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Covers across every element type the §6.2 bindings can + /// name, in both directions and at all three ranks (scalar, array and matrix). The two + /// directions are separately typed switches, so a per-element-type case is the only way to + /// prove that neither drops a type or crosses two of them, and that both fail closed rather + /// than coerce a value that does not fit. + /// + [TestFixture] + [Category("OpenUsd")] + [Parallelizable] + public class UsdValueCoercionTests + { + private static readonly BuiltInType[] s_elementTypes = + [ + BuiltInType.Boolean, + BuiltInType.SByte, + BuiltInType.Int32, + BuiltInType.Int64, + BuiltInType.UInt32, + BuiltInType.UInt64, + BuiltInType.Float, + BuiltInType.Double, + BuiltInType.String + ]; + + private static UsdValueTypeMapping Mapping(BuiltInType elementType, int valueRank) + { + return new UsdValueTypeMapping( + Opc.Ua.DataTypeIds.BaseDataType, valueRank, null, elementType, isOpaque: false); + } + + /// + /// The USD value a scalar of the given element type is authored as, chosen so it is + /// representable in every type (a small non-negative integer, or text for a string). + /// + private static UsdValue Authored(BuiltInType elementType) + { + switch (elementType) + { + case BuiltInType.Boolean: + return UsdValue.From(true); + case BuiltInType.Float: + case BuiltInType.Double: + return UsdValue.From(2.5); + case BuiltInType.String: + return UsdValue.FromString("authored"); + default: + return UsdValue.From(7L); + } + } + + private static void AssertScalar(BuiltInType elementType, in Variant value) + { + switch (elementType) + { + case BuiltInType.Boolean: + Assert.That(value.TryGetValue(out bool b), Is.True); + Assert.That(b, Is.True); + break; + case BuiltInType.SByte: + Assert.That(value.TryGetValue(out sbyte sb), Is.True); + Assert.That(sb, Is.EqualTo((sbyte)7)); + break; + case BuiltInType.Int32: + Assert.That(value.TryGetValue(out int i), Is.True); + Assert.That(i, Is.EqualTo(7)); + break; + case BuiltInType.Int64: + Assert.That(value.TryGetValue(out long l), Is.True); + Assert.That(l, Is.EqualTo(7L)); + break; + case BuiltInType.UInt32: + Assert.That(value.TryGetValue(out uint ui), Is.True); + Assert.That(ui, Is.EqualTo(7U)); + break; + case BuiltInType.UInt64: + Assert.That(value.TryGetValue(out ulong ul), Is.True); + Assert.That(ul, Is.EqualTo(7UL)); + break; + case BuiltInType.Float: + Assert.That(value.TryGetValue(out float f), Is.True); + Assert.That(f, Is.EqualTo(2.5f)); + break; + case BuiltInType.Double: + Assert.That(value.TryGetValue(out double d), Is.True); + Assert.That(d, Is.EqualTo(2.5)); + break; + default: + Assert.That(value.TryGetValue(out string s), Is.True); + Assert.That(s, Is.EqualTo("authored")); + break; + } + } + + [Test] + public void CoerceReadsAScalarOfEveryElementType( + [ValueSource(nameof(s_elementTypes))] BuiltInType elementType) + { + bool ok = UsdValueCoercion.TryCoerce( + Authored(elementType), Mapping(elementType, ValueRanks.Scalar), 0, out Variant v); + + Assert.That(ok, Is.True); + AssertScalar(elementType, v); + } + + [Test] + public void CoerceReadsAnArrayOfEveryElementType( + [ValueSource(nameof(s_elementTypes))] BuiltInType elementType) + { + UsdValue authored = UsdTestHelpers.Array(Authored(elementType), Authored(elementType)); + + bool ok = UsdValueCoercion.TryCoerce( + authored, Mapping(elementType, ValueRanks.OneDimension), 0, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TypeInfo.BuiltInType, Is.EqualTo(elementType)); + Assert.That(v.TypeInfo.ValueRank, Is.EqualTo(ValueRanks.OneDimension)); + } + + [Test] + public void CoerceReadsAMatrixOfEveryElementType( + [ValueSource(nameof(s_elementTypes))] BuiltInType elementType) + { + // Two rows of two components each, which is the array-of-tuples shape USD authors a + // rectangular value with. + UsdValue row = UsdTestHelpers.Tuple(Authored(elementType), Authored(elementType)); + UsdValue authored = UsdTestHelpers.Array(row, row); + + bool ok = UsdValueCoercion.TryCoerce( + authored, Mapping(elementType, ValueRanks.TwoDimensions), 2, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TypeInfo.BuiltInType, Is.EqualTo(elementType)); + Assert.That(v.TypeInfo.ValueRank, Is.EqualTo(ValueRanks.TwoDimensions)); + } + + [Test] + public void CoerceFailsClosedForAnUnsupportedElementType() + { + // DateTime has no USD spelling, so every rank must leave the attribute unresolved + // rather than invent a value. + Assert.That( + UsdValueCoercion.TryCoerce( + UsdValue.From(1L), Mapping(BuiltInType.DateTime, ValueRanks.Scalar), 0, out _), + Is.False); + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.IntegerArray(1L), + Mapping(BuiltInType.DateTime, ValueRanks.OneDimension), + 0, + out _), + Is.False); + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.Array(UsdTestHelpers.IntegerTuple(1L)), + Mapping(BuiltInType.DateTime, ValueRanks.TwoDimensions), + 1, + out _), + Is.False); + } + + [Test] + public void CoerceFailsClosedForAnUnsupportedValueRank() + { + const int threeDimensions = 3; + Assert.That( + UsdValueCoercion.TryCoerce( + UsdValue.From(1L), + Mapping(BuiltInType.Int32, threeDimensions), + 0, + out _), + Is.False); + } + + [Test] + public void CoerceRejectsANullMapping() + { + Assert.That( + () => UsdValueCoercion.TryCoerce(UsdValue.From(1L), null!, 0, out _), + Throws.TypeOf()); + } + + [Test] + public void CoerceRejectsAnAbsentValue() + { + Assert.That( + UsdValueCoercion.TryCoerce( + UsdValue.Null, Mapping(BuiltInType.Int32, ValueRanks.Scalar), 0, out _), + Is.False); + } + + [TestCase(BuiltInType.SByte, 300L)] + [TestCase(BuiltInType.SByte, -300L)] + [TestCase(BuiltInType.Int32, long.MaxValue)] + [TestCase(BuiltInType.Int32, long.MinValue)] + [TestCase(BuiltInType.UInt32, -1L)] + [TestCase(BuiltInType.UInt32, 4294967296L)] + [TestCase(BuiltInType.UInt64, -1L)] + public void CoerceFailsClosedForAnIntegerOutsideTheElementRange( + BuiltInType elementType, long authored) + { + Assert.That( + UsdValueCoercion.TryCoerce( + UsdValue.From(authored), Mapping(elementType, ValueRanks.Scalar), 0, out _), + Is.False); + } + + [Test] + public void CoerceFailsClosedForAnOutOfRangeArrayElement() + { + // One unrepresentable element must fail the whole array, not be silently defaulted. + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.IntegerArray(1L, 300L), + Mapping(BuiltInType.SByte, ValueRanks.OneDimension), + 0, + out _), + Is.False); + } + + [Test] + public void CoerceFailsClosedForAnOutOfRangeMatrixElement() + { + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.Array(UsdTestHelpers.IntegerTuple(1L, 300L)), + Mapping(BuiltInType.SByte, ValueRanks.TwoDimensions), + 2, + out _), + Is.False); + } + + [Test] + public void CoerceFailsClosedForAStructuredLeafInATextArray() + { + // A dictionary has no faithful scalar text, so a string array holding one is left + // unresolved rather than rendered to a plausible-but-wrong element. + UsdValue nested = UsdTestHelpers.Dictionary( + new System.Collections.Generic.KeyValuePair( + "k", UsdValue.From(1L))); + + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.Array(UsdValue.FromString("a"), nested), + Mapping(BuiltInType.String, ValueRanks.OneDimension), + 0, + out _), + Is.False); + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.Array(UsdTestHelpers.Tuple(UsdValue.FromString("a"), nested)), + Mapping(BuiltInType.String, ValueRanks.TwoDimensions), + 2, + out _), + Is.False); + Assert.That( + UsdValueCoercion.TryCoerce( + nested, Mapping(BuiltInType.String, ValueRanks.Scalar), 0, out _), + Is.False); + } + + [Test] + public void CoerceRendersEveryLeafKindIntoText() + { + // A string-bound attribute accepts any leaf with a well-defined textual form. + UsdValue authored = UsdTestHelpers.Array( + UsdValue.Null, + UsdValue.FromToken("token"), + UsdValue.From(true), + UsdValue.From(3L), + UsdValue.From(0.5)); + + bool ok = UsdValueCoercion.TryCoerce( + authored, Mapping(BuiltInType.String, ValueRanks.OneDimension), 0, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out ArrayOf text), Is.True); + Assert.That(text.ToArray(), Is.EqualTo(new[] { string.Empty, "token", "true", "3", "0.5" })); + } + + [TestCase("2.5", 2.5)] + [TestCase("-0.25", -0.25)] + public void CoerceReadsANumberAuthoredAsText(string authored, double expected) + { + bool ok = UsdValueCoercion.TryCoerce( + UsdValue.FromString(authored), + Mapping(BuiltInType.Double, ValueRanks.Scalar), + 0, + out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out double d), Is.True); + Assert.That(d, Is.EqualTo(expected)); + } + + [Test] + public void CoerceFailsClosedForTextThatIsNotANumber() + { + Assert.That( + UsdValueCoercion.TryCoerce( + UsdValue.FromString("not a number"), + Mapping(BuiltInType.Double, ValueRanks.Scalar), + 0, + out _), + Is.False); + } + + [Test] + public void CoerceReadsABooleanAuthoredAsANumberOrAsText() + { + bool fromNumber = UsdValueCoercion.TryCoerce( + UsdValue.From(1L), Mapping(BuiltInType.Boolean, ValueRanks.Scalar), 0, out Variant a); + bool fromText = UsdValueCoercion.TryCoerce( + UsdValue.FromToken("true"), + Mapping(BuiltInType.Boolean, ValueRanks.Scalar), + 0, + out Variant b); + + Assert.That(fromNumber, Is.True); + Assert.That(a.TryGetValue(out bool first), Is.True); + Assert.That(first, Is.True); + Assert.That(fromText, Is.True); + Assert.That(b.TryGetValue(out bool second), Is.True); + Assert.That(second, Is.True); + } + + [Test] + public void CoerceFailsClosedForTextThatIsNotABoolean() + { + Assert.That( + UsdValueCoercion.TryCoerce( + UsdValue.FromToken("maybe"), + Mapping(BuiltInType.Boolean, ValueRanks.Scalar), + 0, + out _), + Is.False); + } + + [Test] + public void CoerceFailsClosedForAFloatThatOverflowsTheElement() + { + // A double that does not fit a float must not be published as an infinity. + Assert.That( + UsdValueCoercion.TryCoerce( + UsdValue.From(1e300), + Mapping(BuiltInType.Float, ValueRanks.Scalar), + 0, + out _), + Is.False); + } + + [Test] + public void CoerceKeepsAnInfinityThatWasAuthoredAsOne() + { + bool ok = UsdValueCoercion.TryCoerce( + UsdValue.From(double.PositiveInfinity), + Mapping(BuiltInType.Float, ValueRanks.Scalar), + 0, + out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out float f), Is.True); + Assert.That(float.IsPositiveInfinity(f), Is.True); + } + + [Test] + public void CoerceFailsClosedForANumberTooLargeForAnInteger() + { + Assert.That( + UsdValueCoercion.TryCoerce( + UsdValue.From(1e30), + Mapping(BuiltInType.Int64, ValueRanks.Scalar), + 0, + out _), + Is.False); + } + + [Test] + public void CoerceFailsClosedForAFixedSizeTypeOfTheWrongArity() + { + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.NumberTuple(1.0, 2.0), + Mapping(BuiltInType.Double, ValueRanks.OneDimension), + 3, + out _), + Is.False); + } + + [Test] + public void CoerceFailsClosedForAMatrixRowOfTheWrongArity() + { + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.Array(UsdTestHelpers.NumberTuple(1.0, 2.0)), + Mapping(BuiltInType.Double, ValueRanks.TwoDimensions), + 3, + out _), + Is.False); + } + + [Test] + public void CoerceTreatsAScalarAsASingleElementSequence() + { + bool ok = UsdValueCoercion.TryCoerce( + UsdValue.From(4L), Mapping(BuiltInType.Int32, ValueRanks.OneDimension), 0, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out ArrayOf items), Is.True); + Assert.That(items.ToArray(), Is.EqualTo(new[] { 4 })); + } + + [Test] + public void CoerceCarriesAnAbsentArrayElementAsTheElementDefault() + { + bool ok = UsdValueCoercion.TryCoerce( + UsdTestHelpers.Array(UsdValue.From(1L), UsdValue.Null), + Mapping(BuiltInType.Int32, ValueRanks.OneDimension), + 0, + out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out ArrayOf items), Is.True); + Assert.That(items.ToArray(), Is.EqualTo(new[] { 1, 0 })); + } + + [Test] + public void CoerceReadsABooleanAsANumber() + { + // A bool authored where a number is bound widens to 1/0 rather than failing. + bool ok = UsdValueCoercion.TryCoerce( + UsdValue.From(true), Mapping(BuiltInType.Int32, ValueRanks.Scalar), 0, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out int i), Is.True); + Assert.That(i, Is.EqualTo(1)); + } + + [Test] + public void CoerceReadsAnIntegerAuthoredAsADouble() + { + bool ok = UsdValueCoercion.TryCoerce( + UsdValue.From(3.0), Mapping(BuiltInType.Int64, ValueRanks.Scalar), 0, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out long l), Is.True); + Assert.That(l, Is.EqualTo(3L)); + } + + [TestCase(BuiltInType.Boolean)] + [TestCase(BuiltInType.SByte)] + [TestCase(BuiltInType.Int32)] + [TestCase(BuiltInType.Int64)] + [TestCase(BuiltInType.UInt32)] + [TestCase(BuiltInType.UInt64)] + [TestCase(BuiltInType.Float)] + [TestCase(BuiltInType.Double)] + public void CoerceFailsClosedForAStructuredValueBoundToAScalar(BuiltInType elementType) + { + // A dictionary is neither a number nor text, so no scalar element type may invent a + // value for it. + UsdValue structured = UsdTestHelpers.Dictionary( + new System.Collections.Generic.KeyValuePair( + "k", UsdValue.From(1L))); + + Assert.That( + UsdValueCoercion.TryCoerce( + structured, Mapping(elementType, ValueRanks.Scalar), 0, out _), + Is.False); + } + + [Test] + public void DecoerceReadsAScalarOfEveryElementType( + [ValueSource(nameof(s_elementTypes))] BuiltInType elementType) + { + bool ok = UsdValueCoercion.TryCoerce( + Authored(elementType), Mapping(elementType, ValueRanks.Scalar), 0, out Variant v); + Assert.That(ok, Is.True); + + UsdValue read = UsdValueCoercion.Decoerce(v); + + Assert.That(read.IsNull, Is.False); + // Round trips through the same binding, which is what an export has to reproduce. + Assert.That( + UsdValueCoercion.TryCoerce( + read, Mapping(elementType, ValueRanks.Scalar), 0, out Variant again), + Is.True); + AssertScalar(elementType, again); + } + + [Test] + public void DecoerceReadsAnArrayOfEveryElementType( + [ValueSource(nameof(s_elementTypes))] BuiltInType elementType) + { + UsdValue authored = UsdTestHelpers.Array(Authored(elementType), Authored(elementType)); + Assert.That( + UsdValueCoercion.TryCoerce( + authored, Mapping(elementType, ValueRanks.OneDimension), 0, out Variant v), + Is.True); + + UsdValue read = UsdValueCoercion.Decoerce(v); + + Assert.That(read.TryGetArray(out ArrayOf items), Is.True); + Assert.That(items.Count, Is.EqualTo(2)); + } + + [Test] + public void DecoerceRegroupsAMatrixOfEveryElementTypeIntoRows( + [ValueSource(nameof(s_elementTypes))] BuiltInType elementType) + { + UsdValue row = UsdTestHelpers.Tuple(Authored(elementType), Authored(elementType)); + Assert.That( + UsdValueCoercion.TryCoerce( + UsdTestHelpers.Array(row, row), + Mapping(elementType, ValueRanks.TwoDimensions), + 2, + out Variant v), + Is.True); + + UsdValue read = UsdValueCoercion.Decoerce(v); + + // A matrix is handed back as one tuple per row so the writer can author "[(a, b), …]". + Assert.That(read.TryGetArray(out ArrayOf rows), Is.True); + Assert.That(rows.Count, Is.EqualTo(2)); + Assert.That(rows[0].TryGetTuple(out ArrayOf cells), Is.True); + Assert.That(cells.Count, Is.EqualTo(2)); + } + + [Test] + public void DecoerceReturnsNullForAnUnrepresentableValue() + { + // Every rank of a type with no USD spelling must decoerce to an absent value rather + // than a CLR rendering of it. + // UInt16 is a valid Variant type with no USD spelling: every rank must decoerce to an + // absent value rather than a CLR rendering of it. + Assert.That(UsdValueCoercion.Decoerce(Variant.From((ushort)1)).IsNull, Is.True); + Assert.That( + UsdValueCoercion.Decoerce( + Variant.From((ArrayOf)new ushort[] { 1 })).IsNull, + Is.True); + Assert.That( + UsdValueCoercion.Decoerce( + Variant.From((MatrixOf)new ushort[,] { { 1 } })).IsNull, + Is.True); + } + + [Test] + public void DecoerceReturnsNullForAnAbsentValue() + { + Assert.That(UsdValueCoercion.Decoerce(default).IsNull, Is.True); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs index 5e9ee1cb3f..955604279e 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs @@ -294,6 +294,156 @@ public void DictionaryRendersItsEntriesOrderedByKey() Assert.That(value.ToString(), Is.EqualTo("{author: acme, nested: {depth: 1}, order: 3}")); } + [Test] + public void TryGetMatrixReadsTheRows() + { + UsdValue value = UsdValue.FromMatrix( + new[] + { + UsdValue.FromTuple(new[] { UsdValue.From(1.0), UsdValue.From(0.0) }.ToArrayOf()), + UsdValue.FromTuple(new[] { UsdValue.From(0.0), UsdValue.From(1.0) }.ToArrayOf()) + }.ToArrayOf()); + + Assert.That(value.Kind, Is.EqualTo(UsdValueKind.Matrix)); + Assert.That(value.TryGetMatrix(out ArrayOf rows), Is.True); + Assert.That(rows.Count, Is.EqualTo(2)); + Assert.That(value.TryGetArray(out ArrayOf _), Is.False); + } + + [Test] + public void TryGetNumberRejectsANonNumericKind() + { + Assert.That(UsdValue.FromString("1.5").TryGetNumber(out double value), Is.False); + Assert.That(value, Is.Zero); + Assert.That(UsdValue.Null.TryGetNumber(out double absent), Is.False); + Assert.That(absent, Is.Zero); + } + + [Test] + public void ScalarsRenderTheirInvariantForm() + { + Assert.That(UsdValue.Null.ToString(), Is.Empty); + Assert.That(UsdValue.From(true).ToString(), Is.EqualTo("true")); + Assert.That(UsdValue.From(false).ToString(), Is.EqualTo("false")); + Assert.That(UsdValue.From(-3L).ToString(), Is.EqualTo("-3")); + Assert.That(UsdValue.From(0.5).ToString(), Is.EqualTo("0.5")); + Assert.That(UsdValue.FromToken("vertex").ToString(), Is.EqualTo("vertex")); + Assert.That(UsdValue.FromAssetPath("./a.usda").ToString(), Is.EqualTo("./a.usda")); + Assert.That(UsdValue.FromPathReference("/P/A").ToString(), Is.EqualTo("/P/A")); + } + + [Test] + public void CompositesRenderTheirItems() + { + UsdValue tuple = UsdValue.FromTuple( + new[] { UsdValue.From(1.0), UsdValue.FromString("a") }.ToArrayOf()); + UsdValue array = UsdValue.FromArray( + new[] { UsdValue.From(1L), UsdValue.From(2L) }.ToArrayOf()); + UsdValue matrix = UsdValue.FromMatrix(new[] { tuple }.ToArrayOf()); + + Assert.That(tuple.ToString(), Is.EqualTo("(1, a)")); + Assert.That(array.ToString(), Is.EqualTo("[1, 2]")); + Assert.That(matrix.ToString(), Is.EqualTo("((1, a))")); + Assert.That(UsdValue.FromArray(default).ToString(), Is.EqualTo("[]")); + } + + [Test] + public void AnEmptyDictionaryRendersAsEmptyBraces() + { + UsdValue value = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal)); + + Assert.That(value.ToString(), Is.EqualTo("{}")); + } + + [Test] + public void CompositesOfDifferentLengthsAreNotEqual() + { + UsdValue first = UsdValue.FromArray(new[] { UsdValue.From(1L) }.ToArrayOf()); + UsdValue second = UsdValue.FromArray( + new[] { UsdValue.From(1L), UsdValue.From(2L) }.ToArrayOf()); + + Assert.That(first, Is.Not.EqualTo(second)); + } + + [Test] + public void DictionariesOfDifferentSizesAreNotEqual() + { + UsdValue first = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["a"] = UsdValue.From(1L) + }); + UsdValue second = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["a"] = UsdValue.From(1L), + ["b"] = UsdValue.From(2L) + }); + + Assert.That(first, Is.Not.EqualTo(second)); + } + + [Test] + public void DictionariesThatDifferInAKeyAreNotEqual() + { + UsdValue first = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["a"] = UsdValue.From(1L) + }); + UsdValue second = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal) + { + ["b"] = UsdValue.From(1L) + }); + + Assert.That(first, Is.Not.EqualTo(second)); + } + + [Test] + public void EmptyDictionariesAreEqualAndShareAHashCode() + { + UsdValue first = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal)); + UsdValue second = UsdValue.FromDictionary( + new Dictionary(System.StringComparer.Ordinal)); + + Assert.That(first, Is.EqualTo(second)); + Assert.That(first.GetHashCode(), Is.EqualTo(second.GetHashCode())); + } + + [Test] + public void EveryKindProducesAHashCode() + { + // An absent value and every text kind must hash without reading an unset payload. + UsdValue absent = UsdValue.Null; + UsdValue alsoAbsent = default; + UsdValue path = UsdValue.FromPathReference("/P"); + UsdValue samePath = UsdValue.FromPathReference("/P"); + UsdValue number = UsdValue.From(1.5); + UsdValue sameNumber = UsdValue.From(1.5); + UsdValue flag = UsdValue.From(true); + UsdValue sameFlag = UsdValue.From(true); + + Assert.That(absent.GetHashCode(), Is.EqualTo(alsoAbsent.GetHashCode())); + Assert.That(path.GetHashCode(), Is.EqualTo(samePath.GetHashCode())); + Assert.That(number.GetHashCode(), Is.EqualTo(sameNumber.GetHashCode())); + Assert.That(flag.GetHashCode(), Is.EqualTo(sameFlag.GetHashCode())); + } + + [Test] + public void EqualsAcceptsABoxedValueAndRejectsAnotherType() + { + object boxed = UsdValue.From(1L); + + bool matchesBoxed = UsdValue.From(1L).Equals(boxed); + bool matchesOtherType = UsdValue.From(1L).Equals("1"); + + Assert.That(matchesBoxed, Is.True); + Assert.That(matchesOtherType, Is.False); + } + [Test] public void ValuesOfDifferentKindsAreNotEqual() {