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..1afad0265a 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) + UsdValue[] items = sequence.ToArray() ?? []; + UsdValueKind first = UsdValueKind.Null; + for (int ii = 0; ii < items.Length; ii++) { - items.Add(item); - } - object? first = null; - foreach (object? item in items) - { - 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; @@ -700,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( @@ -788,7 +755,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..1c3e7933b2 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,299 @@ 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) + ? FromUInt64(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, FromUInt64) + : 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, FromUInt64) + : 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; + } + } + + /// + /// 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. + /// + /// 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; + 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 +437,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 +516,7 @@ private static bool TryArray( } private static bool TryMatrix( - List flat, + List flat, BuiltInType elementType, int rows, int width, @@ -350,56 +526,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 +598,174 @@ 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) - { - sink.Add(value); - return; - } - if (value is IEnumerable enumerable) + if (value.TryGetItems(out ArrayOf items)) { - 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) + { + if (value.TryGetNumber(out result)) + { + return true; + } + if (value.TryGetText(out string text)) + { + 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) { - try + // 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)) { - result = convert(value, CultureInfo.InvariantCulture); return true; } - catch (Exception ex) when (ex is InvalidCastException or FormatException - or OverflowException or ArgumentException) + if (!TryAsInt64(value, out long signed) || signed < 0L) { - result = default!; + 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 +789,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..565fa469c8 100644 --- a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs @@ -752,52 +752,80 @@ 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 ParseIntegral(v); } 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.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) @@ -812,50 +840,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 +1154,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 +1165,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 +1176,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 +1203,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 +1274,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 +1309,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 +1318,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 +1335,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 = ParseIntegral(token); 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 +1610,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 +1625,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 +1700,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 +1716,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 +1992,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 +2017,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..a09ae5008f 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,82 +621,80 @@ 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); } - 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) + if (value.TryGetItems(out ArrayOf components)) { - case object?[] array: - items = array; - return true; - case List list: - items = list; - return true; + // 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) + ")"; } - items = System.Array.Empty(); - return false; + + return PyStr(value); } /// @@ -692,32 +706,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 +742,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 +767,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/NugetREADME.md b/src/Opc.Ua.OpenUsdScene/NugetREADME.md index cf556b9a42..d178f55ef0 100644 --- a/src/Opc.Ua.OpenUsdScene/NugetREADME.md +++ b/src/Opc.Ua.OpenUsdScene/NugetREADME.md @@ -25,6 +25,46 @@ 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`. + +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/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 new file mode 100644 index 0000000000..71b45ce8a2 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdValue.cs @@ -0,0 +1,633 @@ +/* ======================================================================== + * 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, null, 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, null, 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, null, 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, null, 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, null, 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, null, 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, null, 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.ToArray(), 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.ToArray(), 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.ToArray(), 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, null, 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 = 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 = 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 = 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 = 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: + System.ReadOnlySpan items = Span; + hash.Add(items.Length); + for (int ii = 0; ii < items.Length; ii++) + { + hash.Add(items[ii]); + } + break; + case UsdValueKind.Dictionary: + // 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; + } + 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); + } + + /// + /// 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) + { + 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() + "]"; + case UsdValueKind.Dictionary: + return "{" + JoinEntries() + "}"; + default: + return m_text ?? string.Empty; + } + } + + private UsdValue( + UsdValueKind kind, + long integer, + double number, + string? text, + UsdValue[]? items, + IReadOnlyDictionary? entries) + { + m_kind = kind; + m_integer = integer; + m_double = number; + m_text = text; + m_items = items; + m_entries = entries; + } + + private System.ReadOnlySpan Span => m_items ?? []; + + private ArrayOf Items => (m_items ?? []).ToArrayOf(); + + private string JoinItems() + { + System.ReadOnlySpan 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 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 ?? []; + System.ReadOnlySpan b = right ?? []; + 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 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); + + private readonly UsdValueKind m_kind; + private readonly long m_integer; + private readonly double m_double; + private readonly string? m_text; + private readonly UsdValue[]? 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 + } +} 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..d958a69dbe 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] @@ -163,14 +159,68 @@ 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] 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 +231,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 +251,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 +271,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..22f35d5d62 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,92 @@ 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)"); + } + + [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 a581bde783..65b271080b 100644 --- a/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs @@ -47,12 +47,14 @@ 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] 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 +68,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 +81,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); @@ -87,13 +89,26 @@ 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() { // 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 +119,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 +132,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 +147,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 +169,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 +185,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 +205,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 +221,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 +234,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 +251,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 +267,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 +283,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); @@ -303,5 +314,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/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..3542dfa3c6 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))); } @@ -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") @@ -121,7 +189,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 +197,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/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 new file mode 100644 index 0000000000..955604279e --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdValueTests.cs @@ -0,0 +1,454 @@ +/* ======================================================================== + * 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 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 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() + { + 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"));