Files
Marc Schier da7412c429 Add native xRegistry events and distinct Resource versions (#4396)
## Summary

- support the xRegistry 0.6.0 distinct Resource/Version hierarchy:
  `Registry -> Group -> Resource -> Versions -> Version`
- instantiate stable logical Resource nodes separately from exact
Version nodes
  while preserving existing Version NodeIds
- delegate logical Resource FileType operations and Version-view
metadata to the
selected default Version with handle pinning and shared writer
reservations
- make Resource and Version delete behavior role-stable and use
`MetaEpoch` /
  `Epoch` respectively
- use stable Resource SourceNodes, exact Version SourceNodes and the
  `Server -> Registry -> Group -> Resource -> Version` notifier chain
- keep a model-metadata-gated client fallback for 0.5 flat Servers; 0.6
Servers
  expose no legacy aliases
- target `HasWoTProjection` at the real stable logical Resource node
- use `AsyncCustomNodeManager` for the registration, fast-path, and
federation managers,
  with awaited lifecycle, node mutations, event delivery, and cleanup
- handle startup rollback, reentrant deletion callbacks, undisclosed
file handles,
  and retryable cleanup without blocking unrelated file operations

## Authoritative dependencies

- companion specification: OPCF-Members/spec-drafts#32 at
  `d3399fca025025a73e5a6b599f850a81d985c060`
- OPC UA binding: xregistry/spec#568
- xRegistry event semantics: xregistry/spec#563, merged as
  `66ee31e47fbadff6ab982ae1516f7d9065e46b64`
- companion NodeSet SHA-256:
  `369f5f3f1814b333e4eeb52bfb4b6a730cdff372505a5f91a54c94f0016ac4cf`

## Review feedback

- keep the WoT Connectivity information-model designation at `1.1`;
draft revisions update the publication date without changing the model
version
- eliminate all 198 unique CA1861 diagnostics across 54 files and remove
seven obsolete project suppressions
- fix the analyzer-clean touched-path CA1307 and the hierarchy follow-up
CA1846/CA1851 diagnostics without dropping legacy-TFM compatibility

- recover the reconcile queue after callback faults/cancellation and
continue accepted pending changes
- remove migration guidance for the xRegistry managers, which are new in
2.0

## Compatibility

- public WoT Connectivity model designation remains `1.1`
- generated event-record ABI, constructors, options, NodeIds, and stored
documents remain compatible
- the generic xRegistry managers are new in 2.0 and implement
`IAsyncNodeManager`;
use `IAsyncNodeManagerFactory` and the async lifecycle hooks (see
`docs\XRegistry.md`)
- persistence schema and document blobs remain compatible unless noted
below

## Validation

- merge validation at `980422f76` with upstream `e8bec27d5`: 454
targeted WoT/materialization/model/registry cases and all 327 XRegistry
cases passed on both net10.0 and net48; all 29 net10 aggregation sample
cases passed
- feedback regressions at `308e8ba74`: 55 targeted queue and live
registry event/projection/lifecycle cases passed on each of net10.0 and
net48
- prior CI: all 266 checks passed at `6489a0908`; the unchanged Ubuntu
Core certificate-counter test and dependent jobs passed on the one-time
retry ([run 34117304035, attempt
2](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/34117304035/attempts/2))
- `Opc.Ua.XRegistry.Tests` — 327 passed on net10.0 and net48 for
migration commit `32c914aaf`
- `Opc.Ua.WotCon.Tests` net10.0 — 1348 passed
- `Opc.Ua.WotCon.Bindings.Tests` net10.0 — 683 passed
- `Opc.Ua.SourceGeneration.Core.Tests` net10.0 — 3807 passed, 8 skipped
- `Opc.Ua.SourceGeneration.Tests` net10.0 — 145 passed
- `Opc.Ua.SourceGeneration.Stack.Tests` net10.0 — 94 passed
- affected XRegistry and WoT client/server projects build successfully
- xRegistry NodeSet is byte-identical to the companion artifact
- repository-wide CA1861 inventory — 198 unique diagnostics across 54
files reduced to zero
- targeted changed-fixture validation — 1,098 passed, 3 skipped, 0
failed
- constrained full `UA.slnx` net10.0 Release build — CA1861 = 0
- `Opc.Ua.XRegistry.Client` net472 and netstandard2.1 builds — 0
warnings, 0 errors
- independent shared-array safety review — no significant issues
- `git diff --check`

The final review regressions cover concurrent reconcile/open, synthetic
logical
handle collisions across default switches, cross-session Close
isolation,
shared writer reservations, logical FileType property/change
propagation,
logical Labels delegation, role-fixed deletion, event sources,
model-version
gating, and stable `HasWoTProjection` targets.

The companion specification and protocol binding are reviewed in the
linked PRs above.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5c79a1ed-6dab-4522-a9fc-6548caa78ad5
Copilot-Session: b605797f-a97a-4cd0-8f12-016d33c2fd88
Copilot-Session: 7ef369a5-2307-4f71-8a11-15af0e72f240
Copilot-Session: e716d455-8397-4d22-9c5a-398847ab7317
2026-09-08 04:46:40 +00:00

526 lines
23 KiB
C#

/* ========================================================================
* 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.Linq;
using NUnit.Framework;
using Opc.Ua.OpenUsd.Scene.Conversion;
using Opc.Ua.OpenUsd.Scene;
namespace Opc.Ua.OpenUsd.Tests
{
/// <summary>
/// Regression tests that compose the full export path — build a stage, serialise it with
/// <see cref="UsdaWriter"/>, then assert the emitted <c>.usda</c> text and/or re-parse and
/// compare with <see cref="UsdSceneSignature"/>. This is the path the verification review found
/// no existing test exercised, which is why the H-1 array-emit regression survived: after the
/// document model was corrected, <see cref="UsdValueCoercion.Decoerce"/> hands the writer an
/// <c>object?[]</c> for every array, and the writer keyed its array rendering on the CLR
/// container (<c>List&lt;object?&gt;</c>) instead of the attribute type, so every exported array
/// fell through to the scalar/tuple path and was corrupted into a parenthesised tuple.
/// </summary>
[TestFixture]
public class ConversionEmitPathTests
{
private static string EmitRootAttribute(string primType, UsdAttribute attribute)
{
var stage = new UsdStage("Assets") { DefaultPrim = "P" };
var prim = new UsdPrim("P", primType);
prim.Attributes.Add(attribute);
stage.AddRootPrim(prim);
return UsdaWriter.Write(stage);
}
private static void AssertRoundTripSignature(UsdStage expected)
{
string written = UsdaWriter.Write(expected);
UsdStage reparsed = UsdaReader.Parse(written, expected.StageName);
string? difference = UsdSceneSignature.FirstDifference(expected, reparsed);
Assert.That(
UsdSceneSignature.Compute(reparsed),
Is.EqualTo(UsdSceneSignature.Compute(expected)),
difference ?? "signatures are unexpectedly equal");
}
// ---- H-1: an exported array is emitted as "[...]", never a parenthesised tuple ----
[Test]
public void TokenArray_FromDecoercedShape_EmitsBracketedDoubleQuotedElements()
{
// UsdValueCoercion.Decoerce hands the writer an object?[] for a token[] array.
UsdValue decoerced = UsdValueCoercion.Decoerce(
Variant.From((ArrayOf<string>)s_stringValues1));
string usda = EmitRootAttribute(
"Xform",
new UsdAttribute("xformOpOrder", "token[]")
{
Variability = UsdVariabilityEnum.Uniform,
Value = decoerced,
});
Assert.That(usda, Does.Contain("uniform token[] xformOpOrder = [\"xformOp:translate\"]"));
// The H-1 corruption emitted a single-quoted parenthesised tuple instead of an array.
Assert.That(usda, Does.Not.Contain("('xformOp:translate')"));
Assert.That(usda, Does.Not.Contain("(\"xformOp:translate\")"));
}
[Test]
public void TokenArray_FromObjectArrayShape_EmitsBracketedArray()
{
string usda = EmitRootAttribute(
"Xform",
new UsdAttribute("xformOpOrder", "token[]")
{
Variability = UsdVariabilityEnum.Uniform,
Value = UsdTestHelpers.TokenArray("xformOp:translate", "xformOp:scale"),
});
Assert.That(
usda,
Does.Contain("uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]"));
}
[Test]
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 "((...))".
UsdValue decoerced = UsdValueCoercion.Decoerce(
Variant.From(new float[,] { { 0f, 0f, 1f } }.ToMatrixOf()));
string usda = EmitRootAttribute(
"Mesh",
new UsdAttribute("primvars:displayColor", "color3f[]") { Value = decoerced });
Assert.That(usda, Does.Contain("color3f[] primvars:displayColor = [(0.0, 0.0, 1.0)]"));
Assert.That(usda, Does.Not.Contain("((0.0, 0.0, 1.0))"));
}
[Test]
public void Color3fArray_FromObjectArrayShape_MultipleRows_EmitsEachTuple()
{
string usda = EmitRootAttribute(
"Mesh",
new UsdAttribute("primvars:displayColor", "color3f[]")
{
Value = UsdTestHelpers.Array(
UsdTestHelpers.NumberTuple(0.0, 0.0, 1.0),
UsdTestHelpers.NumberTuple(1.0, 1.0, 0.0)),
});
Assert.That(
usda,
Does.Contain("color3f[] primvars:displayColor = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)]"));
}
[Test]
public void AssetArray_FromDecoercedShape_EmitsAtDelimitedElements()
{
UsdValue decoerced = UsdValueCoercion.Decoerce(
Variant.From((ArrayOf<string>)s_stringValues2));
string usda = EmitRootAttribute(
"Xform",
new UsdAttribute("inputs:files", "asset[]") { Value = decoerced });
Assert.That(usda, Does.Contain("asset[] inputs:files = [@./a.usda@]"));
// The H-1 corruption emitted a single-quoted parenthesised tuple, losing the @...@ form.
Assert.That(usda, Does.Not.Contain("('./a.usda')"));
Assert.That(usda, Does.Not.Contain("(\"./a.usda\")"));
}
[Test]
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.
UsdValue decoerced = UsdValueCoercion.Decoerce(
Variant.From((ArrayOf<double>)s_doubleValues1));
string usda = EmitRootAttribute(
"Xform",
new UsdAttribute("xformOp:translate", "double3") { Value = decoerced });
Assert.That(usda, Does.Contain("double3 xformOp:translate = (1.0, 2.0, 3.0)"));
Assert.That(usda, Does.Not.Contain("[1.0, 2.0, 3.0]"));
}
[Test]
public void FullEmitPath_ArraysAndConnection_RoundTripsUnderSignature()
{
// Composes the whole export path across every array shape plus a value co-authored with
// a connection, then re-parses and compares signatures (the missing coverage that let
// H-1 through, and the M-4 merge check in one).
var stage = new UsdStage("Assets") { DefaultPrim = "World" };
var world = new UsdPrim("World", "Xform");
var mesh = new UsdPrim("Mesh", "Mesh");
mesh.Attributes.Add(new UsdAttribute("xformOpOrder", "token[]")
{
Variability = UsdVariabilityEnum.Uniform,
Value = UsdTestHelpers.TokenArray("xformOp:translate", "xformOp:scale"),
});
mesh.Attributes.Add(new UsdAttribute("primvars:displayColor", "color3f[]")
{
Value = UsdTestHelpers.Array(UsdTestHelpers.NumberTuple(0.0, 0.0, 1.0)),
});
mesh.Attributes.Add(new UsdAttribute("inputs:files", "asset[]")
{
Value = UsdTestHelpers.AssetArray("./a.usda", "./b.usda"),
});
var surface = new UsdAttribute("outputs:surface", "token") { Value = UsdValue.FromString("fallback") };
surface.Connections.Add("/World/Shader.outputs:surface");
mesh.Attributes.Add(surface);
world.AddChild(mesh);
stage.AddRootPrim(world);
AssertRoundTripSignature(stage);
}
// ---- M-4: a default value co-authored with a '.connect' merges into one attribute ----
[Test]
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 = UsdValue.FromString("fallback") };
attr.Connections.Add("/P/Shader.outputs:surface");
prim.Attributes.Add(attr);
stage.AddRootPrim(prim);
UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName);
UsdPrim prim2 = reparsed.Find("/P")!;
List<UsdAttribute> matching = prim2.Attributes
.Where(a => a.Name == "inputs:surface").ToList();
Assert.That(matching, Has.Count.EqualTo(1),
"a value co-authored with a .connect must re-parse as one attribute, not two");
UsdTestHelpers.AssertText(matching[0].Value, "fallback");
Assert.That(matching[0].Connections, Is.EqualTo(s_stringValues3));
}
[Test]
public void ValueSamplesAndConnect_AllCoalesceOntoOneAttribute()
{
var stage = new UsdStage("Conn") { DefaultPrim = "P" };
var prim = new UsdPrim("P", "Xform");
var attr = new UsdAttribute("xformOp:translate", "double3")
{
Value = UsdTestHelpers.NumberTuple(1.0, 2.0, 3.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);
UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName);
UsdPrim prim2 = reparsed.Find("/P")!;
List<UsdAttribute> matching = prim2.Attributes
.Where(a => a.Name == "xformOp:translate").ToList();
Assert.That(matching, Has.Count.EqualTo(1),
"value, time samples and .connect on one attribute must not split into duplicates");
Assert.That(matching[0].Connections, Is.EqualTo(s_stringValues4));
Assert.That(matching[0].TimeSamples, Has.Count.EqualTo(2));
Assert.That(matching[0].Value.IsNull, Is.False);
}
// ---- M-1: a known string/token attribute with a structured value fails closed ----
[Test]
public void KnownTokenArray_WithTupleElements_FailsClosed_NeverPublishesClrTypeName()
{
UsdValueTypeMapping mapping = UsdValueTypeMap.Map("token[]", null);
uint components = UsdValueTypeMap.ComponentCount("token[]");
bool ok = UsdValueCoercion.TryCoerce(
UsdTestHelpers.Array(
UsdTestHelpers.IntegerTuple(1L, 2L),
UsdTestHelpers.IntegerTuple(3L, 4L)),
mapping,
components,
out Variant result);
Assert.That(ok, Is.False, "a structured element cannot be a token string; fail closed");
Assert.That(result.TryGetValue(out ArrayOf<string> _), Is.False);
}
[Test]
public void KnownStringScalar_WithTupleValue_FailsClosed()
{
UsdValueTypeMapping mapping = UsdValueTypeMap.Map("string", null);
uint components = UsdValueTypeMap.ComponentCount("string");
bool ok = UsdValueCoercion.TryCoerce(
UsdTestHelpers.IntegerTuple(1L, 2L), mapping, components, out Variant _);
Assert.That(ok, Is.False);
}
[Test]
public void KnownStringScalar_WithNumber_StillStringifiesFaithfully()
{
UsdValueTypeMapping mapping = UsdValueTypeMap.Map("string", null);
uint components = UsdValueTypeMap.ComponentCount("string");
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);
Assert.That(rendered, Is.EqualTo("42"));
Assert.That(rendered, Does.Not.Contain("System."));
}
[Test]
public void KnownTokenArray_WithStringElements_StillCoercesNormally()
{
UsdValueTypeMapping mapping = UsdValueTypeMap.Map("token[]", null);
uint components = UsdValueTypeMap.ComponentCount("token[]");
bool ok = UsdValueCoercion.TryCoerce(
UsdTestHelpers.TokenArray("a", "b"), mapping, components, out Variant result);
Assert.That(ok, Is.True);
Assert.That(result.TryGetValue(out ArrayOf<string> tokens), Is.True);
Assert.That(tokens.ToArray(), Is.EqualTo(s_stringValues5));
}
// ---- M-3: a ')' inside a quoted metadata string must not truncate the block ----
[Test]
public void CloseParenInsideQuotedMetadataString_DoesNotTruncateBlock()
{
string usda = string.Join("\n",
"#usda 1.0",
"(",
" defaultPrim = \"P\"",
")",
string.Empty,
"def Xform \"P\" (",
" comment = \"note with ) paren and ( too\"",
" kind = \"component\"",
")",
"{",
"}",
string.Empty);
UsdStage stage = UsdaReader.Parse(usda, "Meta");
UsdPrim prim = stage.Find("/P")!;
// 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);
UsdTestHelpers.AssertString(prim.Metadata["comment"], "note with ) paren and ( too");
}
// ---- Defect 9: §6.3 custom prim metadata round-trips through Metadata ----
[Test]
public void CustomScalarMetadata_RoundTripsThroughMetadataDictionary()
{
var stage = new UsdStage("Meta") { DefaultPrim = "P" };
var prim = new UsdPrim("P", "Xform");
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")!;
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]
public void CustomNestedDictionaryMetadata_RoundTripsAsNestedDictionary()
{
var stage = new UsdStage("Meta") { DefaultPrim = "P" };
var prim = new UsdPrim("P", "Xform");
var custom = new Dictionary<string, UsdValue>(StringComparer.Ordinal)
{
["author"] = UsdValue.FromString("acme"),
["weight"] = UsdValue.From(12.5),
["count"] = UsdValue.From(7L),
};
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"].TryGetDictionary(out IReadOnlyDictionary<string, UsdValue> nested),
Is.True);
UsdTestHelpers.AssertString(nested["author"], "acme");
UsdTestHelpers.AssertDouble(nested["weight"], 12.5);
UsdTestHelpers.AssertInteger(nested["count"], 7L);
}
[Test]
public void CustomMetadata_CoexistsWithWellKnownMetadata_WithoutPollution()
{
var stage = new UsdStage("Meta") { DefaultPrim = "P" };
var prim = new UsdPrim("P", "Xform")
{
Kind = UsdPrimKindEnum.Component,
Documentation = "A documented prim",
};
prim.ApiSchemas.Add(new UsdApiSchema("PhysicsRigidBodyAPI"));
prim.Metadata["displayName"] = UsdValue.FromString("Widget");
stage.AddRootPrim(prim);
UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName);
UsdPrim prim2 = reparsed.Find("/P")!;
// Well-known metadata still binds to typed members and is NOT duplicated as custom.
Assert.That(prim2.Kind, Is.EqualTo(UsdPrimKindEnum.Component));
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);
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);
}
[Test]
public void CustomMetadata_WithCloseParenInStringValue_RoundTrips()
{
var stage = new UsdStage("Meta") { DefaultPrim = "P" };
var prim = new UsdPrim("P", "Xform") { Kind = UsdPrimKindEnum.Component };
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));
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"));
}
private static readonly string[] s_stringValues1 = ["xformOp:translate"];
private static readonly string[] s_stringValues2 = ["./a.usda"];
private static readonly double[] s_doubleValues1 = [1.0, 2.0, 3.0];
private static readonly string[] s_stringValues3 = ["/P/Shader.outputs:surface"];
private static readonly string[] s_stringValues4 = ["/P/Rig.outputs:translate"];
private static readonly string[] s_stringValues5 = ["a", "b"];
}
}