Fix OpenUSD sample discovery and migrate artifacts to xRegistry (#4880)

# Description

_Describe the changes here to communicate to the maintainers why they
should accept this pull request. By default - this will become the
Commit message after merging and thus define history._

Fix OpenUSD sample discovery and integrate artifact delivery with the
xRegistry companion model.

- Load xRegistry before OpenUSD in the sample node managers, mount
stages with `Organizes`, and align root-layer identifiers with served
`.usda` files.
- Bootstrap immutable logical Resources and exact Versions in
`Artifacts`. Stage `Assets` folders provide non-owning `Organizes`
views, and explicit container identifiers allow sharing.
- Update the binding model to draft 0.9.0 while preserving existing
NodeIds and the `HasAddIn` correction.
- Disambiguate default container identifiers by stage instance, publish
the required read-only capabilities map, and preserve standard and
xRegistry BrowseName namespaces.
- Add registry, file-delivery, sharing, collision, qualified-browse, and
live-discovery regressions. Update the OpenUSD documentation.

Review fixes are in 6ac47a5b82. All four
review threads have been answered and resolved.

**Local validation**

| Command | Result |
| --- | --- |
| `dotnet test tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj
--no-restore -f net10.0 -p:CustomTestTarget=net10.0 --verbosity quiet` |
Passed: 1,178 tests. |
| `dotnet test tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj -f
net48 -p:CustomTestTarget=net48 --filter
FullyQualifiedName~UsdAssetDeliveryTests --verbosity quiet` | Passed: 36
focused regressions, including build and dependency restore. |
| `dotnet test tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj
--no-build --no-restore -f net48 -p:CustomTestTarget=net48 --verbosity
quiet` | Failed: 937 passed; five generator integration tests failed
during fixture setup because Mono does not implement
`ECDsa.Create(ECCurve)`. |

The full `UA.slnx` suite and coverage measurement were not run locally.

## Related Issues

_Reference all GitHub issues this PR addresses. If there is no issue
yet, open one and link it here._

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

No linked tracking issue.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
This commit is contained in:
Alexander Köpke
2026-10-22 14:45:21 +02:00
committed by GitHub
parent bc97bf7206
commit fb4eece24d
23 changed files with 1169 additions and 366 deletions
+34 -4
View File
@@ -210,10 +210,40 @@ same `HasAddIn` contract. `CreateRepresentation` is the supported authoring path
`CreateInstanceOfOpenUsdRepresentationType` and a manually assigned reference type. The sample servers assert the
reference type in their E2E suites.
`UsdAssetDelivery.AttachStageAssets(context, stage, openUsdNs, assets)` serves artist-authored USD layers through the
address space as read-only Part 5 files with SHA-256 digests, so a generic connector can fetch, verify, cache, and
render the twin with no external asset resolver. `assets` is an `ArrayOf<ServedAsset>` and the method returns the
created `OpenUsdAssetState` nodes as an `ArrayOf<OpenUsdAssetState>`.
The binding model tracks the OPC 12001-1 version 0.9.0 draft from `OPC12000-Metaverse` and now depends on the
stack's xRegistry companion model. The local `IOpenUsdRepresentedType` placeholder retains its `HasAddIn`
correction. Load `nodes.AddOpcUaXRegistry(context)` before `nodes.AddOpcUaOpenUsd(context)` and include both
model namespace URIs in the node manager.
`UsdAssetDelivery.AttachStageAssets(context, stage, openUsdNs, assets, "pumps")` bootstraps an asset container in
`Server/OpenUSD/Artifacts`. The registry organizes typed asset groups; each group organizes stable logical
`OpenUsdAssetType` Resources. Each Resource owns a typed `Versions` folder organizing its first exact Version,
`1`. Both logical and exact files expose the same immutable bytes through read-only Part 5 streaming, with
SHA-256 digests and xRegistry identity metadata. The registry also exposes `SpecVersion` and a read-only
`Capabilities` document declaring `available.entities.mutable` as `false` and an empty `mutable` list.
Runtime registration, deletion, and default-Version switching are not implemented by
this bootstrap helper.
The stage's `Assets` folder contains only explicit `Organizes` references to the logical Resources, never
duplicate or owned file instances. Stages using the same container and normalized `AssetIdentifier` share the
same Resource NodeId. Leading `./` segments are removed; resource BrowseNames use `XRegistryIdentifier`, including
collision disambiguation, rather than the previous underscore substitution. Conflicting bytes, asset kinds,
or content types for an existing Resource are rejected. Empty asset identifiers are rejected.
`assets` is an `ArrayOf<ServedAsset>`; the return value is an `ArrayOf<OpenUsdAssetState>` containing the logical
Resources in input order. The four-argument overload allocates a container identifier from the stage's name,
disambiguates collisions with existing groups, and reuses the allocation for that stage instance. Use the
explicit-container overload for intentional sharing. Call either overload before the facility's final
NodeId assignment and registration.
Supply the stage's complete layer closure, with exactly one `RootLayer` whose `AssetIdentifier` equals
`RootLayerIdentifier`. The sample servers now publish their served `.usda` identifiers rather than unrelated
`asset-repo/*.usd` paths.
Mount each Part 1 stage under `Server/OpenUSD/Stages` with `ReferenceTypeId = ReferenceTypeIds.Organizes`
before calling `AddChild`. The instance factory leaves the reference type unset; `AddChild` alone registers
the child but does not make it browsable. A representation's NodeId-valued `Stage` property does not replace
this hierarchy link. The registry hierarchy and the stage's non-owning `Assets` view let a hierarchy-only
address-space snapshot discover the stage and its shared asset closure.
## Samples
@@ -176,7 +176,7 @@ namespace Pumps
}
}
private const string PlantRootLayerIdentifier = "asset-repo/Plant.usd";
private const string PlantRootLayerIdentifier = "Plant.usda";
/// <summary>
/// Prim scope the configured pumps are composed into. Each pump owns
@@ -222,6 +222,7 @@ namespace Pumps
m_plantStage = SystemContext.CreateInstanceOfOpenUsdStageType(
stages, new QualifiedName("PlantStage", ns));
m_plantStage.ReferenceTypeId = ReferenceTypeIds.Organizes;
stages.AddChild(m_plantStage);
m_plantStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!)
.Value = PlantRootLayerIdentifier;
@@ -259,7 +260,7 @@ namespace Pumps
// §5.15 asset content delivery (OU-AssetDelivery): serve this stage's
// artist-authored USD layer closure so a connector can render the twin
// with no external asset resolver.
UsdAssetDelivery.AttachStageAssets(SystemContext, m_plantStage, ns, servedAssets);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_plantStage, ns, servedAssets, "pumps");
SystemContext.AssignInstanceChildNodeIds(root);
await AddPredefinedNodeAsync(SystemContext, root, cancellationToken)
@@ -44,6 +44,7 @@ using Opc.Ua.Pumps;
using Opc.Ua.Server;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.NodeManager;
using Opc.Ua.XRegistry;
namespace Pumps
{
@@ -99,7 +100,8 @@ namespace Pumps
postSetupRunner,
Opc.Ua.Pumps.Namespaces.Pumps,
Opc.Ua.Machinery.Namespaces.Machinery,
Opc.Ua.OpenUsd.Namespaces.OpenUSD)
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
Opc.Ua.XRegistry.Namespaces.xRegistry)
{
m_options = options?.Value ?? new PumpDeviceIntegrationOptions();
if (m_options.PumpCount is < 1 or > 100)
@@ -173,6 +175,7 @@ namespace Pumps
nodes.AddOpcUaDi(context);
nodes.AddOpcUaMachinery(context);
nodes.AddOpcUaPumps(context);
nodes.AddOpcUaXRegistry(context);
nodes.AddOpcUaOpenUsd(context);
return new ValueTask<NodeStateCollection>(nodes);
}
@@ -538,7 +541,8 @@ namespace Pumps
Opc.Ua.Pumps.Namespaces.Pumps,
Opc.Ua.Machinery.Namespaces.Machinery,
Opc.Ua.Di.Namespaces.OpcUaDi,
Opc.Ua.OpenUsd.Namespaces.OpenUSD
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
Opc.Ua.XRegistry.Namespaces.xRegistry
};
/// <inheritdoc/>
@@ -46,6 +46,7 @@ using Opc.Ua.OpenUsd;
using Opc.Ua.Server;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.NodeManager;
using Opc.Ua.XRegistry;
namespace Generators
{
@@ -148,7 +149,8 @@ namespace Generators
// model reaches into IA. The reduced Machinery copy this
// sample used to carry had that edge stripped out.
Opc.Ua.IA.Namespaces.IA,
Opc.Ua.OpenUsd.Namespaces.OpenUSD)
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
Opc.Ua.XRegistry.Namespaces.xRegistry)
{
m_options = options?.Value ?? new GeneratorDeviceIntegrationOptions();
if (m_options.GeneratorCount is < 1 or > 100)
@@ -229,6 +231,7 @@ namespace Generators
nodes.AddOpcUaIA(context);
nodes.AddOpcUaMachinery(context);
nodes.AddOpcUaGenerators(context);
nodes.AddOpcUaXRegistry(context);
nodes.AddOpcUaOpenUsd(context);
return new ValueTask<NodeStateCollection>(nodes);
}
@@ -564,6 +567,7 @@ namespace Generators
Opc.Ua.Generators.Namespaces.Generators,
Opc.Ua.Machinery.Namespaces.Machinery,
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
Opc.Ua.XRegistry.Namespaces.xRegistry,
}.ToArrayOf();
/// <inheritdoc/>
@@ -98,6 +98,7 @@ namespace Generators
m_powerhouseStage = SystemContext.CreateInstanceOfOpenUsdStageType(
stages,
new QualifiedName("PowerhouseStage", ns));
m_powerhouseStage.ReferenceTypeId = ReferenceTypeIds.Organizes;
stages.AddChild(m_powerhouseStage);
m_powerhouseStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!)
.Value = PowerhouseRootLayerIdentifier;
@@ -125,7 +126,7 @@ namespace Generators
// matching forward reference is added through externalReferences.
root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_powerhouseStage, ns, servedAssets);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_powerhouseStage, ns, servedAssets, "generators");
SystemContext.AssignInstanceChildNodeIds(root);
await AddPredefinedNodeAsync(SystemContext, root, cancellationToken)
@@ -40,6 +40,7 @@ using Opc.Ua.OpenUsd.Server;
using Opc.Ua.Server;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.NodeManager;
using Opc.Ua.XRegistry;
namespace SiteComposition
{
@@ -81,7 +82,7 @@ namespace SiteComposition
/// </remarks>
public sealed partial class SiteNodeManager : FluentNodeManagerBase
{
private const string SiteRootLayerIdentifier = "asset-repo/Site.usd";
private const string SiteRootLayerIdentifier = "Site.usda";
private const string SitePrimPath = "/Site";
private const string PumpHallPrimPath = "/Site/PumpHall";
private const string PowerhousePrimPath = "/Site/Powerhouse";
@@ -108,7 +109,9 @@ namespace SiteComposition
IServerInternal server,
ApplicationConfiguration configuration,
IOptions<SiteCompositionOptions>? options = null)
: base(server, configuration, Namespaces.Site, Opc.Ua.OpenUsd.Namespaces.OpenUSD)
: base(
server, configuration, Namespaces.Site,
Opc.Ua.OpenUsd.Namespaces.OpenUSD, Opc.Ua.XRegistry.Namespaces.xRegistry)
{
m_options = options?.Value ?? new SiteCompositionOptions();
m_log = server.Telemetry.CreateLogger<SiteNodeManager>();
@@ -125,6 +128,7 @@ namespace SiteComposition
CancellationToken cancellationToken = default)
{
var nodes = new NodeStateCollection();
nodes.AddOpcUaXRegistry(context);
nodes.AddOpcUaOpenUsd(context);
return new ValueTask<NodeStateCollection>(nodes);
}
@@ -313,6 +317,7 @@ namespace SiteComposition
m_siteStage = SystemContext.CreateInstanceOfOpenUsdStageType(
stages, new QualifiedName("SiteStage", ns));
m_siteStage.ReferenceTypeId = ReferenceTypeIds.Organizes;
stages.AddChild(m_siteStage);
m_siteStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!)
.Value = SiteRootLayerIdentifier;
@@ -333,7 +338,7 @@ namespace SiteComposition
.Value = OpenUsdDigestAlgorithmEnum.Sha256;
root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_siteStage, ns, served);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_siteStage, ns, served, "site");
SystemContext.AssignInstanceChildNodeIds(root);
await AddPredefinedNodeAsync(SystemContext, root, cancellationToken)
@@ -442,6 +447,7 @@ namespace SiteComposition
{
Namespaces.Site,
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
Opc.Ua.XRegistry.Namespaces.xRegistry,
}.ToArrayOf();
/// <inheritdoc/>
@@ -45,6 +45,7 @@
<Compile Include="..\IntentEnabledRobot\Simulation\SimulatedCollisionModel.cs" Link="Simulation\SimulatedCollisionModel.cs" />
<Compile Include="..\IntentEnabledRobot\Kinematics\ISimulatedArmKinematics.cs" Link="Kinematics\ISimulatedArmKinematics.cs" />
<Compile Include="..\IntentEnabledRobot\Kinematics\SimulatedArmKinematics.cs" Link="Kinematics\SimulatedArmKinematics.cs" />
<Compile Include="..\IntentEnabledRobot\CompanionModelProviders.cs" Link="CompanionModelProviders.cs" />
</ItemGroup>
<ItemGroup>
<!-- The cell stage, palletizer and gripper are embedded and extracted to a
@@ -59,6 +59,7 @@ namespace Vision.BinPickingCell
m_cellStage = SystemContext.CreateInstanceOfOpenUsdStageType(
stages, new QualifiedName("BinPickingCellStage", ns));
m_cellStage.ReferenceTypeId = ReferenceTypeIds.Organizes;
stages.AddChild(m_cellStage);
m_cellStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null).Value = RootLayerIdentifier;
@@ -81,7 +82,7 @@ namespace Vision.BinPickingCell
}
root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_cellStage, ns, servedAssets);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_cellStage, ns, servedAssets, "bin-picking");
SystemContext.AssignInstanceChildNodeIds(root);
_ = await Manager.AddNodeAsync(
SystemContext,
@@ -92,6 +92,8 @@ builder.Services.AddSingleton(_ => new BinPickingPalletizerKinematics
Collisions = BinPickingCellGeometry.CreateCollisionModel()
});
builder.Services.AddSingleton<BinPickingRobotCell>();
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<IRobotIntentModelProvider, Robotics.IntentEnabledRobot.OpenUsdIntentModelProvider>());
builder.Services.AddSingleton<BinPickingWorldState>();
builder.Services.AddSingleton<IBinPickingTargetProvider, BinPickingTargetProvider>();
builder.Services.AddSingleton<BinPickingGroundTruthInferenceProvider>();
@@ -31,6 +31,7 @@ using System;
using Opc.Ua;
using Opc.Ua.OpenUsd;
using Opc.Ua.Robotics.Server;
using Opc.Ua.XRegistry;
namespace Robotics.IntentEnabledRobot
{
@@ -45,7 +46,8 @@ namespace Robotics.IntentEnabledRobot
/// <inheritdoc/>
public ArrayOf<string> NamespaceUris => new string[]
{
Opc.Ua.OpenUsd.Namespaces.OpenUSD
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
Opc.Ua.XRegistry.Namespaces.xRegistry
};
/// <inheritdoc/>
@@ -60,6 +62,7 @@ namespace Robotics.IntentEnabledRobot
throw new ArgumentNullException(nameof(context));
}
nodes.AddOpcUaXRegistry(context);
nodes.AddOpcUaOpenUsd(context);
}
}
@@ -59,6 +59,7 @@ namespace Robotics.IntentEnabledRobot
m_cellStage = SystemContext.CreateInstanceOfOpenUsdStageType(
stages, new QualifiedName("MinimalIntentRobotStage", ns));
m_cellStage.ReferenceTypeId = ReferenceTypeIds.Organizes;
stages.AddChild(m_cellStage);
m_cellStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!).Value = RootLayerIdentifier;
@@ -81,7 +82,7 @@ namespace Robotics.IntentEnabledRobot
}
root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_cellStage, ns, servedAssets);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_cellStage, ns, servedAssets, "intent-robots");
SystemContext.AssignInstanceChildNodeIds(root);
await Manager.AddPredefinedNodeAsync(root, cancellationToken).ConfigureAwait(false);
@@ -280,7 +281,7 @@ namespace Robotics.IntentEnabledRobot
SystemContext.AssignInstanceChildNodeIds(node, previousNodeId, referenceRoot);
}
private const string RootLayerIdentifier = "asset-repo/Bench.usd";
private const string RootLayerIdentifier = "Bench.usda";
private static readonly string[] s_payloadSlotPrimPaths =
[
"/World/Payloads/FixtureStack/Slot01",
@@ -33,6 +33,7 @@ using Opc.Ua.Gpos;
using Opc.Ua.OpenUsd;
using Opc.Ua.Robotics.Server;
using Opc.Ua.Rsl;
using Opc.Ua.XRegistry;
namespace Robotics
{
@@ -47,7 +48,8 @@ namespace Robotics
/// <inheritdoc/>
public ArrayOf<string> NamespaceUris => new string[]
{
Opc.Ua.OpenUsd.Namespaces.OpenUSD
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
Opc.Ua.XRegistry.Namespaces.xRegistry
};
/// <inheritdoc/>
@@ -62,6 +64,7 @@ namespace Robotics
throw new ArgumentNullException(nameof(context));
}
nodes.AddOpcUaXRegistry(context);
nodes.AddOpcUaOpenUsd(context);
}
}
@@ -50,7 +50,7 @@ namespace Robotics
private OpenUsdRootState? m_openUsdRoot;
private OpenUsdStageState? m_cellStage;
private const string CellRootLayerIdentifier = "asset-repo/Cell.usd";
private const string CellRootLayerIdentifier = "Cell.usda";
private async ValueTask MaterialiseOpenUsdFacilityAsync(
CancellationToken cancellationToken)
@@ -70,6 +70,7 @@ namespace Robotics
m_cellStage = SystemContext.CreateInstanceOfOpenUsdStageType(
stages, new QualifiedName("RobotCellStage", ns));
m_cellStage.ReferenceTypeId = ReferenceTypeIds.Organizes;
stages.AddChild(m_cellStage);
m_cellStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!)
.Value = CellRootLayerIdentifier;
@@ -106,7 +107,7 @@ namespace Robotics
// §5.15 asset content delivery (OU-AssetDelivery): serve this stage's
// artist-authored USD layer closure so a connector can render the twin
// with no external asset resolver.
UsdAssetDelivery.AttachStageAssets(SystemContext, m_cellStage, ns, servedAssets);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_cellStage, ns, servedAssets, "robots");
SystemContext.AssignInstanceChildNodeIds(root);
await Manager.AddPredefinedNodeAsync(root, cancellationToken)
+8
View File
@@ -18,6 +18,14 @@ The `Opc.Ua.OpenUsd.Server` namespace helps a server expose a live external twin
Pair these APIs with **Opc.Ua.OpenUsd.Client** on the connector side.
`UsdAssetDelivery.AttachStageAssets(context, stage, openUsdNs, assets, "pumps")`
bootstraps shared logical Resources and their first exact Versions in
`Server/OpenUSD/Artifacts`. Each stage's `Assets` folder only organizes references
to those Resources. Reusing a container and asset identifier reuses its Resource;
conflicting immutable definitions are rejected. The four-argument overload uses
the stage's symbolic name as the container identifier. Call before final NodeId
assignment and address-space registration.
## Scene materialization
The `Opc.Ua.OpenUsd.Server.Scene` namespace materializes a composed USD stage inside
+207 -47
View File
@@ -29,9 +29,12 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using Opc.Ua;
using Opc.Ua.OpenUsd;
using Opc.Ua.XRegistry;
namespace Opc.Ua.OpenUsd.Server
{
@@ -56,63 +59,231 @@ namespace Opc.Ua.OpenUsd.Server
}
/// <summary>
/// Serves the draft OPC UA — OpenUSD Bindings asset-content-delivery capability
/// (spec §5.15): builds a stage's <c>Assets</c> folder of <c>OpenUsdAssetType</c>
/// instances, each exposing an artist-authored USD layer as a read-only Part 5
/// <see cref="FileState"/> (Open/Read/Close streaming) plus a SHA-256 digest, so a
/// generic connector can fetch, verify, cache, compose, and render the twin with no
/// external asset resolver.
/// Bootstraps read-only USD resources and their first exact Versions in the
/// server's artifact registry. Stage Assets folders are non-owning views.
/// </summary>
public static class UsdAssetDelivery
{
/// <summary>
/// Attaches an <c>Assets</c> folder (if absent) to the stage and materialises one
/// <c>OpenUsdAssetType</c> per served layer. Call BEFORE the facility's per-instance
/// NodeId assignment + registration; returns the created assets so a caller can link a
/// component binding's <c>ComponentAssetNode</c> to one after NodeIds are assigned.
/// Allocates a distinct asset container from the stage's name and reuses it for that stage.
/// Call before the facility's NodeId assignment and registration.
/// </summary>
public static ArrayOf<OpenUsdAssetState> AttachStageAssets(
ISystemContext context, OpenUsdStageState stage, ushort openUsdNs, ArrayOf<ServedAsset> assets)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (stage is null)
{
throw new ArgumentNullException(nameof(stage));
}
return AttachStageAssets(
context, stage, openUsdNs, assets,
s_defaultContainerIds.GetValue(stage, key => AllocateStageContainerId(context, key)));
}
/// <summary>
/// Registers immutable layers in an asset container and organizes those logical
/// Resources through the stage's Assets view. Repeated definitions must have the
/// same bytes, kind and content type. Different stages can share one container.
/// Call before the facility's NodeId assignment and registration.
/// </summary>
/// <exception cref="ArgumentException">The container identifier is not symbolic.</exception>
/// <exception cref="InvalidOperationException">
/// The stage is not under an OpenUSD root, or an existing resource has a conflicting definition.
/// </exception>
public static ArrayOf<OpenUsdAssetState> AttachStageAssets(
ISystemContext context,
OpenUsdStageState stage,
ushort openUsdNs,
ArrayOf<ServedAsset> assets,
string assetContainerId)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (stage is null)
{
throw new ArgumentNullException(nameof(stage));
}
if (string.IsNullOrEmpty(assetContainerId) ||
XRegistryIdentifier.FromSourceIdentity(assetContainerId) != assetContainerId)
{
throw new ArgumentException("The asset container identifier must be symbolic.", nameof(assetContainerId));
}
if (stage.Parent is not FolderState { Parent: OpenUsdRootState root })
{
throw new InvalidOperationException("The stage must belong to the OpenUSD facility's Stages folder.");
}
OpenUsdArtifactRegistryState registry = root.Artifacts ?? root.CreateOrReplaceArtifacts(
context, context.CreateOpenUsdRootType_Artifacts(root, forInstance: true));
registry.RegistryId!.Value = "openusd-artifacts";
registry.AddName(context).Name!.Value = "OpenUSD artifacts";
registry.DisplayName = new LocalizedText(registry.Name!.Value);
registry.AddXid(context).Xid!.Value = "/";
registry.AddEpoch(context).Epoch!.Value = 1;
if (registry.Capabilities is null)
{
registry.AddSpecVersion(context).SpecVersion!.Value = "0.1.0";
registry.AddCapabilities(context);
WireReadOnlyFile(
registry.Capabilities!,
Encoding.UTF8.GetBytes(
"{\"available\":{\"entities\":{\"mutable\":false}},\"specversions\":[\"0.1.0\"],\"mutable\":[]}"),
"application/json");
}
OpenUsdAssetGroupState? group = registry.FindChild(
context, new QualifiedName(assetContainerId, openUsdNs)) as OpenUsdAssetGroupState;
if (group is null)
{
group = context.CreateInstanceOfOpenUsdAssetGroupType(
registry, new QualifiedName(assetContainerId, openUsdNs));
group.ReferenceTypeId = ReferenceTypeIds.Organizes;
group.GroupId!.Value = assetContainerId;
group.AssetContainerId!.Value = assetContainerId;
group.Name!.Value = assetContainerId;
group.DisplayName = new LocalizedText(assetContainerId);
group.AddXid(context).Xid!.Value = "/usdassetgroups/" + assetContainerId;
group.AddEpoch(context).Epoch!.Value = 1;
registry.AddChild(group);
}
var created = new List<OpenUsdAssetState>();
// Assets is an optional member, so it is not auto-created by the factory;
// supply a generated node (passing null! leaves it unnamed/untyped).
FolderState folder = stage.Assets ?? stage.CreateOrReplaceAssets(
context, context.CreateOpenUsdStageType_Assets(stage, forInstance: true));
foreach (ServedAsset a in assets)
{
OpenUsdAssetState asset = context.CreateInstanceOfOpenUsdAssetType(
folder, new QualifiedName(SafeBrowseName(a.Identifier), openUsdNs));
asset.ReferenceTypeId = ReferenceTypeIds.HasComponent;
folder.AddChild(asset);
asset.CreateOrReplaceAssetIdentifier(context, null!).Value = a.Identifier;
asset.CreateOrReplaceAssetKind(context, null!).Value = a.Kind;
asset.CreateOrReplaceMediaType(
context, context.CreateOpenUsdAssetType_MediaType(asset, forInstance: true)).Value = a.MediaType;
string identifier = a.Identifier;
while (identifier.StartsWith("./", StringComparison.Ordinal))
{
identifier = identifier[2..];
}
if (string.IsNullOrEmpty(identifier))
{
throw new ArgumentException("A served asset must have an authored identifier.", nameof(assets));
}
byte[] bytes = (byte[])a.Bytes.Clone();
byte[] digest;
#pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility)
using (var sha = System.Security.Cryptography.SHA256.Create())
{
digest = sha.ComputeHash(a.Bytes);
digest = sha.ComputeHash(bytes);
}
#pragma warning restore CA1850
asset.CreateOrReplaceDigest(
context, context.CreateOpenUsdAssetType_Digest(asset, forInstance: true)).Value = (ByteString)digest;
asset.CreateOrReplaceDigestAlgorithm(
context, context.CreateOpenUsdAssetType_DigestAlgorithm(asset, forInstance: true))
.Value = OpenUsdDigestAlgorithmEnum.Sha256;
// The asset node itself IS the Part 5 file (OpenUsdAssetType : FileType);
// wire the read-only streaming handlers directly onto it.
WireReadOnlyFile(asset, a.Bytes, a.MediaType);
var siblings = new List<BaseInstanceState>();
group.GetChildren(context, siblings);
var resourceIds = new List<string>();
OpenUsdAssetState? asset = null;
foreach (BaseInstanceState child in siblings)
{
if (child is OpenUsdAssetState resource)
{
resourceIds.Add(resource.ResourceId!.Value);
if (resource.AssetIdentifier!.Value == identifier)
{
asset = resource;
}
}
}
if (asset is null)
{
string resourceId = XRegistryIdentifier.FromSourceIdentity(identifier, resourceIds);
string xid = group.Xid!.Value + "/usdassets/" + resourceId;
asset = CreateAsset(context, group, openUsdNs, resourceId, identifier, xid, a, bytes, digest);
OpenUsdAssetVersionsState versions = asset.CreateOrReplaceVersions(
context, context.CreateOpenUsdAssetType_Versions(asset, forInstance: true));
OpenUsdAssetState version = CreateAsset(
context, versions, openUsdNs, "1", identifier, xid + "/versions/1", a, bytes, digest);
version.ResourceId!.Value = resourceId;
versions.AddChild(version);
group.AddChild(asset);
}
else if (asset.AssetKind!.Value != a.Kind ||
asset.ContentType!.Value != a.MediaType ||
!asset.Digest!.Value.Span.SequenceEqual(digest))
{
throw new InvalidOperationException(
$"The immutable artifact '{assetContainerId}/{identifier}' is already defined differently.");
}
if (!folder.ReferenceExists(ReferenceTypeIds.Organizes, false, asset.NodeId))
{
folder.AddReference(ReferenceTypeIds.Organizes, false, asset.NodeId);
}
if (!asset.ReferenceExists(ReferenceTypeIds.Organizes, true, folder.NodeId))
{
asset.AddReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
}
created.Add(asset);
}
return created;
}
/// <summary>
/// Allocates a symbolic stage container without taking an existing registry group's identifier.
/// </summary>
private static string AllocateStageContainerId(ISystemContext context, OpenUsdStageState stage)
{
if (stage.Parent is not FolderState { Parent: OpenUsdRootState root })
{
throw new InvalidOperationException("The stage must belong to the OpenUSD facility's Stages folder.");
}
var children = new List<BaseInstanceState>();
root.Artifacts?.GetChildren(context, children);
var containerIds = new List<string>();
foreach (BaseInstanceState child in children)
{
if (child is OpenUsdAssetGroupState group)
{
containerIds.Add(group.GroupId!.Value);
}
}
return XRegistryIdentifier.FromSourceIdentity(
stage.BrowseName.Name ?? throw new ArgumentException("The stage must be named.", nameof(stage)),
containerIds);
}
private static OpenUsdAssetState CreateAsset(
ISystemContext context,
NodeState parent,
ushort openUsdNs,
string browseName,
string identifier,
string xid,
ServedAsset definition,
byte[] bytes,
byte[] digest)
{
OpenUsdAssetState asset = context.CreateInstanceOfOpenUsdAssetType(
parent, new QualifiedName(browseName, openUsdNs));
asset.ReferenceTypeId = ReferenceTypeIds.Organizes;
asset.ResourceId!.Value = browseName;
asset.Name!.Value = identifier;
asset.DisplayName = new LocalizedText(identifier);
asset.AssetIdentifier!.Value = identifier;
asset.AssetKind!.Value = definition.Kind;
asset.AddVersionId(context).VersionId!.Value = "1";
asset.AddXid(context).Xid!.Value = xid;
asset.AddEpoch(context).Epoch!.Value = 1;
asset.AddContentType(context).ContentType!.Value = definition.MediaType;
asset.CreateOrReplaceMediaType(
context, context.CreateOpenUsdAssetType_MediaType(asset, forInstance: true)).Value = definition.MediaType;
asset.CreateOrReplaceDigest(
context, context.CreateOpenUsdAssetType_Digest(asset, forInstance: true)).Value = (ByteString)digest;
asset.CreateOrReplaceDigestAlgorithm(
context, context.CreateOpenUsdAssetType_DigestAlgorithm(asset, forInstance: true))
.Value = OpenUsdDigestAlgorithmEnum.Sha256;
WireReadOnlyFile(asset, bytes, definition.MediaType);
return asset;
}
// Attaches read-only Open/Read/Close/GetPosition/SetPosition handlers over an
// in-memory byte[] to an existing (generator-materialised) FileState.
private static void WireReadOnlyFile(FileState file, byte[] bytes, string mediaType)
@@ -158,21 +329,10 @@ namespace Opc.Ua.OpenUsd.Server
// Read-only: any Write method stays unwired (server rejects the call).
}
// Sanitises an asset identifier into a valid single-segment BrowseName.
private static string SafeBrowseName(string identifier)
{
if (string.IsNullOrEmpty(identifier))
{
return "Asset";
}
var sb = new System.Text.StringBuilder(identifier.Length);
foreach (char c in identifier)
{
sb.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_');
}
string s = sb.ToString();
return char.IsLetter(s[0]) || s[0] == '_' ? s : "_" + s;
}
/// <summary>
/// Remembers default container allocations by stage instance without retaining discarded stages.
/// </summary>
private static readonly ConditionalWeakTable<OpenUsdStageState, string> s_defaultContainerIds = new();
/// <summary>
/// Read-only in-memory backing for a Part 5 <see cref="FileState"/>: hands out
@@ -94,3 +94,22 @@ OpenUsdAssetType_DigestAlgorithm,6064,Variable
OpenUsdStageType_Assets,6065,Object
OpenUsdComponentBindingType_ComponentAssetNode,6066,Variable
OpenUsdLiveBindingType_TargetNodeId,6067,Variable
OpenUsdArtifactRegistryType,1012,ObjectType
OpenUsdAssetGroupType,1013,ObjectType
OpenUsdSchemaPluginGroupType,1014,ObjectType
OpenUsdAssetVersionsType,1015,ObjectType
OpenUsdAssetGroupType_AssetContainerId,6068,Variable
OpenUsdSchemaPluginGroupType_PluginName,6069,Variable
OpenUsdAssetType_DependsOn,6070,Variable
OpenUsdRootType_Artifacts,6071,Object
OpenUsdArtifactRegistryType_AssetContainer_Placeholder,6072,Object
OpenUsdArtifactRegistryType_SchemaPlugin_Placeholder,6073,Object
OpenUsdAssetGroupType_Asset_Placeholder,6074,Object
OpenUsdSchemaPluginGroupType_Asset_Placeholder,6075,Object
http___opcfoundation_org_UA_OpenUSD_,6076,Object
http___opcfoundation_org_UA_OpenUSD__NamespaceUri,6077,Variable
http___opcfoundation_org_UA_OpenUSD__NamespaceVersion,6078,Variable
http___opcfoundation_org_UA_OpenUSD__NamespacePublicationDate,6079,Variable
http___opcfoundation_org_UA_OpenUSD__IsNamespaceSubset,6080,Variable
OpenUsdAssetVersionsType_Version_Placeholder,6081,Object
OpenUsdAssetType_Versions,6082,Object
1 OpenUsdRenderTargetKindEnum 3002 DataType
94 OpenUsdStageType_Assets 6065 Object
95 OpenUsdComponentBindingType_ComponentAssetNode 6066 Variable
96 OpenUsdLiveBindingType_TargetNodeId 6067 Variable
97 OpenUsdArtifactRegistryType 1012 ObjectType
98 OpenUsdAssetGroupType 1013 ObjectType
99 OpenUsdSchemaPluginGroupType 1014 ObjectType
100 OpenUsdAssetVersionsType 1015 ObjectType
101 OpenUsdAssetGroupType_AssetContainerId 6068 Variable
102 OpenUsdSchemaPluginGroupType_PluginName 6069 Variable
103 OpenUsdAssetType_DependsOn 6070 Variable
104 OpenUsdRootType_Artifacts 6071 Object
105 OpenUsdArtifactRegistryType_AssetContainer_Placeholder 6072 Object
106 OpenUsdArtifactRegistryType_SchemaPlugin_Placeholder 6073 Object
107 OpenUsdAssetGroupType_Asset_Placeholder 6074 Object
108 OpenUsdSchemaPluginGroupType_Asset_Placeholder 6075 Object
109 http___opcfoundation_org_UA_OpenUSD_ 6076 Object
110 http___opcfoundation_org_UA_OpenUSD__NamespaceUri 6077 Variable
111 http___opcfoundation_org_UA_OpenUSD__NamespaceVersion 6078 Variable
112 http___opcfoundation_org_UA_OpenUSD__NamespacePublicationDate 6079 Variable
113 http___opcfoundation_org_UA_OpenUSD__IsNamespaceSubset 6080 Variable
114 OpenUsdAssetVersionsType_Version_Placeholder 6081 Object
115 OpenUsdAssetType_Versions 6082 Object
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -11,7 +11,13 @@ binds OPC UA values to it:
- `OpenUsdRootType`, `OpenUsdStageType`, and `OpenUsdRepresentationType`;
- live, alarm, history, and command binding types;
- component composition and aggregation;
- content integrity and Part 5 asset delivery metadata.
- content integrity and Part 5 asset delivery metadata;
- xRegistry-based artifact registries, asset-container groups, and typed exact Versions.
The binding model consumes the xRegistry companion model from `Opc.Ua.XRegistry`.
Servers must load that model before the OpenUSD binding model. Stage `Assets` folders
are non-owning views of logical Resources in `Server/OpenUSD/Artifacts`, not per-stage
file containers.
The generic, dependency-injectable connector lives in **Opc.Ua.OpenUsd.Client**.
+7
View File
@@ -26,6 +26,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Opc.Ua.Server\Opc.Ua.Server.csproj" />
<ProjectReference Include="..\Opc.Ua.XRegistry\Opc.Ua.XRegistry.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\tools\Opc.Ua.SourceGeneration\Opc.Ua.SourceGeneration.csproj">
@@ -40,6 +41,12 @@
<None Remove="Model\Opc.Ua.OpenUsd.Scene.NodeSet2.csv" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\Opc.Ua.XRegistry\Opc.Ua.XRegistry.NodeSet2.xml">
<ModelSourceGeneratorPrefix>Opc.Ua.XRegistry</ModelSourceGeneratorPrefix>
<ModelSourceGeneratorName>XRegistry</ModelSourceGeneratorName>
<ModelSourceGeneratorModelUri>http://opcfoundation.org/UA/xRegistry/</ModelSourceGeneratorModelUri>
<ModelSourceGeneratorIgnore>true</ModelSourceGeneratorIgnore>
</AdditionalFiles>
<AdditionalFiles Include="Model\Opc.Ua.OpenUsd.NodeSet2.xml">
<ModelSourceGeneratorPrefix>Opc.Ua.OpenUsd</ModelSourceGeneratorPrefix>
</AdditionalFiles>
+88 -1
View File
@@ -342,6 +342,93 @@ namespace Opc.Ua.Di.Tests
}
Assert.That(hasRepresentations, Is.True,
"Representations registry is not reachable from Server/OpenUSD.");
ReferenceDescription stages = rootRefs.ToList().Single(reference => reference.BrowseName.Name == "Stages");
var browseStages = new BrowseDescription
{
NodeId = ExpandedNodeId.ToNodeId(stages.NodeId, m_session.NamespaceUris),
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = Opc.Ua.ReferenceTypeIds.Organizes,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Object,
ResultMask = (uint)BrowseResultMask.All
};
BrowseResponse stageChildren = await m_session.BrowseAsync(
null!, null!, 0, new BrowseDescription[] { browseStages }, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(stageChildren.Results[0].StatusCode), Is.True);
Assert.That(stageChildren.Results[0].References, Has.Count.EqualTo(1),
"PlantStage is not reachable through Server/OpenUSD/Stages/Organizes.");
var connector = new OpenUsdConnector(m_session, new MockUsdSink());
OpenUsdConnector.RepresentationInfo? representation = await PumpRepAsync(connector).ConfigureAwait(false);
Assert.That(representation, Is.Not.Null);
NodeId stageId = ExpandedNodeId.ToNodeId(
stageChildren.Results[0].References[0].NodeId, m_session.NamespaceUris);
Assert.That(stageId, Is.EqualTo(representation!.StageNodeId),
"The registered stage must match the representation's Stage value.");
ReferenceDescription artifacts = rootRefs.ToList().Single(
reference => reference.BrowseName.Name == "Artifacts");
Assert.That(
ExpandedNodeId.ToNodeId(artifacts.TypeDefinition, m_session.NamespaceUris),
Is.EqualTo(ExpandedNodeId.ToNodeId(
Opc.Ua.OpenUsd.ObjectTypeIds.OpenUsdArtifactRegistryType, m_session.NamespaceUris)));
ArrayOf<ReferenceDescription> groups = await BrowseObjectsAsync(
ExpandedNodeId.ToNodeId(artifacts.NodeId, m_session.NamespaceUris),
Opc.Ua.ReferenceTypeIds.Organizes).ConfigureAwait(false);
Assert.That(groups, Has.Count.EqualTo(1));
Assert.That(groups[0].BrowseName.Name, Is.EqualTo("pumps"));
NodeId groupId = ExpandedNodeId.ToNodeId(groups[0].NodeId, m_session.NamespaceUris);
ArrayOf<ReferenceDescription> resources = await BrowseObjectsAsync(
groupId, Opc.Ua.ReferenceTypeIds.Organizes).ConfigureAwait(false);
Assert.That(resources, Has.Count.EqualTo(2));
ArrayOf<ReferenceDescription> stageObjects = await BrowseObjectsAsync(
stageId, Opc.Ua.ReferenceTypeIds.HasComponent).ConfigureAwait(false);
ReferenceDescription view = stageObjects.ToList().Single(
reference => reference.BrowseName.Name == "Assets");
ArrayOf<ReferenceDescription> viewResources = await BrowseObjectsAsync(
ExpandedNodeId.ToNodeId(view.NodeId, m_session.NamespaceUris),
Opc.Ua.ReferenceTypeIds.Organizes).ConfigureAwait(false);
Assert.That(viewResources.ToList().Select(reference => reference.NodeId),
Is.EquivalentTo(resources.ToList().Select(reference => reference.NodeId)),
"Stage Assets must reference the same logical resources as the artifact registry.");
foreach (ReferenceDescription resource in resources.ToList())
{
ArrayOf<ReferenceDescription> components = await BrowseObjectsAsync(
ExpandedNodeId.ToNodeId(resource.NodeId, m_session.NamespaceUris),
Opc.Ua.ReferenceTypeIds.HasComponent).ConfigureAwait(false);
ReferenceDescription versions = components.ToList().Single(
reference => reference.BrowseName.Name == "Versions");
ArrayOf<ReferenceDescription> exact = await BrowseObjectsAsync(
ExpandedNodeId.ToNodeId(versions.NodeId, m_session.NamespaceUris),
Opc.Ua.ReferenceTypeIds.Organizes).ConfigureAwait(false);
Assert.That(exact, Has.Count.EqualTo(1));
Assert.That(exact[0].NodeId, Is.Not.EqualTo(resource.NodeId));
Assert.That(exact[0].BrowseName.Name, Is.EqualTo("1"));
}
async Task<ArrayOf<ReferenceDescription>> BrowseObjectsAsync(NodeId nodeId, NodeId referenceType)
{
BrowseResponse response = await m_session!.BrowseAsync(
null!, null!, 0,
new BrowseDescription[]
{
new()
{
NodeId = nodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = referenceType,
IncludeSubtypes = false,
NodeClassMask = (uint)NodeClass.Object,
ResultMask = (uint)BrowseResultMask.All
}
},
CancellationToken.None).ConfigureAwait(false);
Assert.That(StatusCode.IsGood(response.Results[0].StatusCode), Is.True);
return response.Results[0].References;
}
}
[Test]
@@ -353,7 +440,7 @@ namespace Opc.Ua.Di.Tests
Assert.That(rep, Is.Not.Null, "OpenUsdRepresentation not discovered on Pump_1.");
Assert.That(rep!.PrimPath, Is.EqualTo("/Plant/Pumps/Pump_1"));
Assert.That(rep.StageNodeId.IsNull, Is.False);
Assert.That(rep.RootLayerIdentifier, Is.EqualTo("asset-repo/Plant.usd"));
Assert.That(rep.RootLayerIdentifier, Is.EqualTo("Plant.usda"));
// Layout (1) + telemetry (10) + supervision alarms (3) + command (1).
Assert.That(rep.Bindings, Has.Count.EqualTo(15));
}
@@ -548,7 +548,7 @@ namespace Opc.Ua.Di.Tests
// 1 system + 2 robots + 12 axes = 15 representations in the registry.
Assert.That(reps, Has.Count.GreaterThanOrEqualTo(15), "Expected >= 15 representations.");
Assert.That(cell, Is.Not.Null, "System (RobotCell) representation not discovered.");
Assert.That(cell!.RootLayerIdentifier, Is.EqualTo("asset-repo/Cell.usd"));
Assert.That(cell!.RootLayerIdentifier, Is.EqualTo("Cell.usda"));
Assert.That(r1, Is.Not.Null, "Robot R1 representation not discovered.");
Assert.That(axisReps, Is.EqualTo(12), "Expected 12 axis representations (6 per robot).");
// The system carries the RobotsAggregation (Many) component binding.
@@ -72,6 +72,7 @@ namespace Opc.Ua.OpenUsd.Server.Tests
public static (SystemContext Context, ushort Namespace) NewContext()
{
var namespaces = new NamespaceTable();
namespaces.GetIndexOrAppend(Opc.Ua.XRegistry.Namespaces.xRegistry);
var ns = (ushort)namespaces.GetIndexOrAppend(Namespaces.OpenUSD);
var instanceNs = (ushort)namespaces.GetIndexOrAppend(InstanceNamespaceUri);
@@ -107,7 +108,7 @@ namespace Opc.Ua.OpenUsd.Server.Tests
FolderState stages = root.Stages!;
OpenUsdStageState stage = context.CreateInstanceOfOpenUsdStageType(
stages, new QualifiedName(name, ns));
stage.ReferenceTypeId = ReferenceTypeIds.HasComponent;
stage.ReferenceTypeId = ReferenceTypeIds.Organizes;
stages.AddChild(stage);
stage.NodeId = context.NodeIdFactory.New(context, stage);
return stage;
@@ -27,7 +27,12 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.OpenUsd;
@@ -48,7 +53,7 @@ namespace Opc.Ua.OpenUsd.Server.Tests
private const byte WriteMode = 0x02;
[Test]
public void AttachStageAssetsCreatesTheAssetsFolderAndOneAssetPerLayer()
public async Task AttachStageAssetsCreatesTheAssetsFolderAndOneAssetPerLayerAsync()
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns);
@@ -63,12 +68,43 @@ namespace Opc.Ua.OpenUsd.Server.Tests
});
Assert.That(created.Count, Is.EqualTo(2));
Assert.That(
root.FindChild(context, new QualifiedName("Artifacts", ns)),
Is.Not.Null,
"Served layers must be owned by the server-wide artifact registry.");
Assert.That(stage.Assets, Is.Not.Null);
Assert.That(created[0].AssetIdentifier!.Value, Is.EqualTo("Cell.usda"));
Assert.That(created[0].AssetKind!.Value, Is.EqualTo(OpenUsdAssetKindEnum.RootLayer));
Assert.That(created[0].MediaType!.Value, Is.EqualTo("model/vnd.usda"));
Assert.That(created[1].AssetIdentifier!.Value, Is.EqualTo("robot.usda"));
Assert.That(created[1].AssetKind!.Value, Is.EqualTo(OpenUsdAssetKindEnum.Reference));
context.AssignInstanceChildNodeIds(root);
var typeTable = new TypeTable(context.NamespaceUris);
typeTable.AddReferenceSubtype(
ReferenceTypeIds.HierarchicalReferences,
NodeId.Null,
QualifiedName.From(Opc.Ua.BrowseNames.HierarchicalReferences));
typeTable.AddReferenceSubtype(
ReferenceTypeIds.Organizes,
ReferenceTypeIds.HierarchicalReferences,
QualifiedName.From(Opc.Ua.BrowseNames.Organizes));
context.TypeTable = typeTable;
var references = new List<IReference>();
using INodeBrowser browser = stage.Assets!.CreateBrowser(
context, null, ReferenceTypeIds.Organizes, false, BrowseDirection.Forward, default, null, false);
for (IReference? reference = await browser.NextAsync().ConfigureAwait(false);
reference != null;
reference = await browser.NextAsync().ConfigureAwait(false))
{
references.Add(reference);
}
Assert.That(references, Has.Count.EqualTo(created.Count));
foreach (OpenUsdAssetState asset in created)
{
Assert.That(references.Exists(reference => reference.TargetId == asset.NodeId), Is.True);
}
}
[Test]
@@ -88,6 +124,239 @@ namespace Opc.Ua.OpenUsd.Server.Tests
Assert.That(ReferenceEquals(stage.Assets, first), Is.True);
}
[Test]
public void StageViewsShareRegistryResourcesWithoutOwningThem()
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns);
OpenUsdStageState first = OpenUsdAuthoringHarness.NewStage(context, root, ns, "First");
OpenUsdStageState second = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Second");
OpenUsdAssetState firstAsset = UsdAssetDelivery.AttachStageAssets(
context, first, ns, new ServedAsset[] { Asset("./shared.usda") }, "pumps")[0];
OpenUsdAssetState secondAsset = UsdAssetDelivery.AttachStageAssets(
context, second, ns, new ServedAsset[] { Asset("shared.usda") }, "pumps")[0];
context.AssignInstanceChildNodeIds(root);
Assert.That(secondAsset, Is.SameAs(firstAsset));
Assert.That(root.Artifacts, Is.Not.Null);
Assert.That(((OpenUsdAssetGroupState)firstAsset.Parent!).Parent, Is.SameAs(root.Artifacts));
var owned = new List<BaseInstanceState>();
first.Assets!.GetChildren(context, owned);
Assert.That(owned, Is.Empty, "A stage view must not own or duplicate registry resources.");
first.Assets.RemoveReference(ReferenceTypeIds.Organizes, false, firstAsset.NodeId);
Assert.That(
firstAsset.Parent!.FindChild(context, firstAsset.BrowseName),
Is.SameAs(firstAsset),
"Removing view membership must leave the registry resource intact.");
var references = new List<IReference>();
second.Assets!.GetReferences(context, references);
Assert.That(references.Exists(reference =>
reference.ReferenceTypeId == ReferenceTypeIds.Organizes &&
!reference.IsInverse && reference.TargetId == firstAsset.NodeId), Is.True);
}
[Test]
public void RegistryResourcesHaveDistinctExactVersionsWithTheSameReadOnlyContent()
{
byte[] payload = Bytes("immutable layer");
byte[] input = (byte[])payload.Clone();
OpenUsdAssetState asset = SingleAsset(input, out SystemContext context);
input[0] = (byte)'X';
var children = new List<BaseInstanceState>();
asset.Versions!.GetChildren(context, children);
OpenUsdAssetState version = children.OfType<OpenUsdAssetState>().Single();
var group = (OpenUsdAssetGroupState)asset.Parent!;
Assert.That(
asset.FindChild(context, new QualifiedName(
Opc.Ua.XRegistry.BrowseNames.Versions,
(ushort)context.NamespaceUris.GetIndex(Opc.Ua.XRegistry.Namespaces.xRegistry))),
Is.SameAs(asset.Versions),
"Generic xRegistry clients must discover the narrowed Versions component.");
Assert.That(group.AssetContainerId!.Value, Is.EqualTo(group.GroupId!.Value));
Assert.That(group.BrowseName.Name, Is.EqualTo(group.GroupId.Value));
Assert.That(asset.ResourceId!.Value, Is.EqualTo("Cell.usda"));
Assert.That(asset.Name!.Value, Is.EqualTo(asset.AssetIdentifier!.Value));
Assert.That(asset.Xid!.Value, Is.EqualTo("/usdassetgroups/Cell/usdassets/Cell.usda"));
Assert.That(version.Xid!.Value, Is.EqualTo(asset.Xid.Value + "/versions/1"));
Assert.That(version.NodeId, Is.Not.EqualTo(asset.NodeId));
Assert.That(version.BrowseName.Name, Is.EqualTo(version.VersionId!.Value));
Assert.That(version.ResourceId!.Value, Is.EqualTo(asset.ResourceId.Value));
Assert.That(version.AssetIdentifier!.Value, Is.EqualTo(asset.AssetIdentifier.Value));
Assert.That(version.Name!.Value, Is.EqualTo(asset.Name.Value));
Assert.That(version.ContentType!.Value, Is.EqualTo(asset.MediaType!.Value));
Assert.That(version.Epoch!.Value, Is.EqualTo(1));
Assert.That(version.Versions, Is.Null, "An exact Version must not have its own Versions folder.");
Assert.That(version.Writable!.Value, Is.False);
Assert.That(version.UserWritable!.Value, Is.False);
Assert.That(version.Digest!.Value, Is.EqualTo(asset.Digest!.Value));
foreach (OpenUsdAssetState file in new[] { asset, version })
{
uint handle = OpenHandle(file, context);
ByteString data = default;
Assert.That(file.Read!.OnCall!(
context, file.Read, file.NodeId, handle, payload.Length, ref data),
Is.EqualTo(ServiceResult.Good));
Assert.That(data.Span.SequenceEqual(payload), Is.True);
}
}
[Test]
public void RegistryResourcesDisambiguateCollidingSymbolicIdentifiers()
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns);
OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell");
ArrayOf<OpenUsdAssetState> assets = UsdAssetDelivery.AttachStageAssets(
context, stage, ns, new ServedAsset[] { Asset("a b.usda"), Asset("a-b.usda") });
Assert.That(assets[0].ResourceId!.Value, Is.Not.EqualTo(assets[1].ResourceId!.Value));
Assert.That(assets[0].NodeId, Is.Not.EqualTo(assets[1].NodeId));
OpenUsdAssetState repeated = UsdAssetDelivery.AttachStageAssets(
context, stage, ns, new ServedAsset[] { Asset("a-b.usda") })[0];
Assert.That(repeated, Is.SameAs(assets[1]));
}
[TestCase("Cell A", "Cell-A")]
[TestCase("Cell", "cell")]
[TestCase("Cell", "Cell")]
public void DefaultContainersDisambiguateStagesAndReuseTheirAllocations(string firstName, string secondName)
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns);
OpenUsdStageState first = OpenUsdAuthoringHarness.NewStage(context, root, ns, firstName);
OpenUsdStageState second = OpenUsdAuthoringHarness.NewStage(context, root, ns, secondName);
var firstDefinition = new ServedAsset("Cell.usda", OpenUsdAssetKindEnum.RootLayer, Bytes("first layer"));
var secondDefinition = new ServedAsset("Cell.usda", OpenUsdAssetKindEnum.RootLayer, Bytes("second layer"));
OpenUsdAssetState firstAsset = UsdAssetDelivery.AttachStageAssets(
context, first, ns, new[] { firstDefinition })[0];
OpenUsdAssetState secondAsset = UsdAssetDelivery.AttachStageAssets(
context, second, ns, new[] { secondDefinition })[0];
Assert.That(secondAsset.Parent, Is.Not.SameAs(firstAsset.Parent));
Assert.That(
((OpenUsdAssetGroupState)secondAsset.Parent!).GroupId!.Value,
Is.Not.EqualTo(((OpenUsdAssetGroupState)firstAsset.Parent!).GroupId!.Value).IgnoreCase);
Assert.That(
UsdAssetDelivery.AttachStageAssets(context, first, ns, new[] { firstDefinition })[0],
Is.SameAs(firstAsset));
Assert.That(
UsdAssetDelivery.AttachStageAssets(context, second, ns, new[] { secondDefinition })[0],
Is.SameAs(secondAsset));
}
[Test]
public void DefaultContainersDoNotImplicitlyShareExplicitContainers()
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns);
OpenUsdStageState first = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Explicit");
OpenUsdStageState second = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell");
OpenUsdAssetState explicitAsset = UsdAssetDelivery.AttachStageAssets(
context, first, ns, new ServedAsset[] { Asset("Cell.usda") }, "Cell")[0];
OpenUsdAssetState defaultAsset = UsdAssetDelivery.AttachStageAssets(
context, second, ns, new ServedAsset[] { Asset("Cell.usda") })[0];
Assert.That(defaultAsset.Parent, Is.Not.SameAs(explicitAsset.Parent));
string defaultContainer = ((OpenUsdAssetGroupState)defaultAsset.Parent!).GroupId!.Value;
Assert.That(
UsdAssetDelivery.AttachStageAssets(
context, first, ns, new ServedAsset[] { Asset("Cell.usda") }, defaultContainer)[0],
Is.SameAs(defaultAsset),
"Explicit container identifiers must still allow intentional sharing.");
}
[Test]
public void NamespaceMetadataUsesStandardQualifiedBrowseNames()
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
var nodes = new NodeStateCollection();
nodes.AddOpcUaOpenUsd(context);
NodeState metadata = nodes.Single(node => node.NodeId == new NodeId(6076u, ns));
foreach (string name in new[]
{
Opc.Ua.BrowseNames.NamespaceUri,
Opc.Ua.BrowseNames.NamespaceVersion,
Opc.Ua.BrowseNames.NamespacePublicationDate,
Opc.Ua.BrowseNames.IsNamespaceSubset
})
{
BaseInstanceState? property = metadata.FindChild(context, new QualifiedName(name, 0));
Assert.That(property, Is.Not.Null, $"The standard qualified browse path for {name} must resolve.");
Assert.That(property!.NodeId.NamespaceIndex, Is.EqualTo(ns));
}
}
[Test]
public void ExactVersionPlaceholderUsesXRegistryQualifiedBrowseName()
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
var nodes = new NodeStateCollection();
nodes.AddOpcUaOpenUsd(context);
NodeState versionsType = nodes.Single(node => node.NodeId == new NodeId(1015u, ns));
BaseInstanceState? placeholder = versionsType.FindChild(
context, new QualifiedName(
"<Version>", (ushort)context.NamespaceUris.GetIndex(Opc.Ua.XRegistry.Namespaces.xRegistry)));
Assert.That(placeholder, Is.Not.Null);
Assert.That(placeholder!.NodeId.NamespaceIndex, Is.EqualTo(ns));
Assert.That(placeholder.TypeDefinitionId, Is.EqualTo(new NodeId(1006u, ns)));
}
[Test]
public void RegistryBootstrapCapabilitiesAreReadableAsAReadOnlyFile()
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns);
OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell");
UsdAssetDelivery.AttachStageAssets(context, stage, ns, new ServedAsset[] { Asset("Cell.usda") });
Assert.That(root.Artifacts!.SpecVersion!.Value, Is.EqualTo("0.1.0"));
FileState capabilities = root.Artifacts.Capabilities!;
uint metadataHandle = 0;
Assert.That(capabilities.Open!.OnCall!(
context, capabilities.Open, capabilities.NodeId, ReadMode, ref metadataHandle),
Is.EqualTo(ServiceResult.Good));
ByteString json = default;
Assert.That(capabilities.Read!.OnCall!(
context, capabilities.Read, capabilities.NodeId, metadataHandle, 1024, ref json),
Is.EqualTo(ServiceResult.Good));
using JsonDocument document = JsonDocument.Parse(json.Memory);
JsonElement declaration = document.RootElement;
Assert.That(declaration.GetProperty("available").GetProperty("entities").GetProperty("mutable").GetBoolean(),
Is.False);
Assert.That(declaration.GetProperty("mutable").GetArrayLength(), Is.Zero);
Assert.That(declaration.GetProperty("specversions").EnumerateArray().Select(value => value.GetString()),
Is.EquivalentTo(new[] { root.Artifacts.SpecVersion.Value }));
Assert.That(capabilities.Writable!.Value, Is.False);
Assert.That(capabilities.UserWritable!.Value, Is.False);
}
[TestCase("different", OpenUsdAssetKindEnum.Reference, "model/vnd.usda")]
[TestCase("#usda 1.0", OpenUsdAssetKindEnum.Payload, "model/vnd.usda")]
[TestCase("#usda 1.0", OpenUsdAssetKindEnum.Reference, "model/vnd.usdc")]
public void RegistryResourcesRejectConflictingBootstrapDefinitions(
string content, OpenUsdAssetKindEnum kind, string mediaType)
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns);
OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell");
OpenUsdAssetState original = UsdAssetDelivery.AttachStageAssets(
context, stage, ns, new ServedAsset[] { Asset("shared.usda") })[0];
Assert.Throws<System.InvalidOperationException>(() => UsdAssetDelivery.AttachStageAssets(
context, stage, ns,
new ServedAsset[] { new("shared.usda", kind, Bytes(content), mediaType) }));
Assert.That(
original.Parent!.FindChild(context, original.BrowseName),
Is.SameAs(original));
}
[Test]
public void AttachStageAssetsPublishesTheContentDigest()
{
@@ -146,10 +415,10 @@ namespace Opc.Ua.OpenUsd.Server.Tests
Assert.That(created[0].MediaType!.Value, Is.EqualTo("model/vnd.usdc"));
}
[TestCase("robot.usda", "robot_usda")]
[TestCase("assets/sub dir/tool.usda", "assets_sub_dir_tool_usda")]
[TestCase("robot.usda", "robot.usda")]
[TestCase("assets/sub dir/tool.usda", "assets.sub-dir.tool.usda")]
[TestCase("_private", "_private")]
[TestCase("3rdParty", "_3rdParty")]
[TestCase("3rdParty", "3rdParty")]
public void AttachStageAssetsSanitisesTheIdentifierIntoABrowseName(
string identifier, string expected)
{
@@ -165,16 +434,14 @@ namespace Opc.Ua.OpenUsd.Server.Tests
}
[Test]
public void AttachStageAssetsFallsBackToAConstantBrowseNameForAnEmptyIdentifier()
public void AttachStageAssetsRejectsAnEmptyIdentifier()
{
(SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext();
OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns);
OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell");
ArrayOf<OpenUsdAssetState> created = UsdAssetDelivery.AttachStageAssets(
context, stage, ns, new ServedAsset[] { Asset(string.Empty) });
Assert.That(created[0].BrowseName.Name, Is.EqualTo("Asset"));
Assert.Throws<System.ArgumentException>(() => UsdAssetDelivery.AttachStageAssets(
context, stage, ns, new ServedAsset[] { Asset(string.Empty) }));
}
[Test]