mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-26 04:19:53 +08:00
# Description Adds a one-command federated OpenUSD site demo and fixes the composition path that previously produced an empty, overlapping, or poorly framed viewport. - Add `samples/OpenUsd/SiteCompositionServer/run-site-composition-demo.ps1` to build and start the pump, generator, and site servers, wait for every endpoint, publish the connector and viewer side by side, launch the federated viewport, and clean up child processes and artifacts. - Include every fetched root layer in the generated stage and prepare structural component composition before the retained renderer starts. - Continue persisting structural changes while forwarding value-only updates into the live viewport, avoiding renderer topology invalidation. - Fold structured translation, rotation, and scale values into `xformOp:transform` matrices in `UsdFileSink`, including time samples, so multiple pump and generator instances remain distinct when the stage is reopened. - Preserve the latest prior transform components when independently sampled translation, rotation, or scale history adds a new matrix sample. - Reframe the site camera to show all three default pumps and both generator sets. - Move the detailed root sample catalog to `docs/samples.md` and replace it with a concise link to the platform-independent samples and companion sample repository. Validation performed: - Ran the demo script through both its existing-viewer-bundle path and its default side-by-side publish path on isolated ports. - Ran the focused OpenUSD connector and file-sink tests: 30 passed. - Ran the full `Opc.Ua.OpenUsd.Tests` project: 919 passed; the remaining local failure is the existing culture-sensitive decimal formatting assertion on this host's `en-DE` culture. - Ran the focused review-feedback file-sink tests: 19 passed. ## Related Issues - Fixes #4392 ## Checklist - [ ] 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. - [x] 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. --------- Copilot-Session: 797ea461-49e2-4844-b6a4-54565a9469bd
309 lines
12 KiB
C#
309 lines
12 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 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.IO;
|
|
using NUnit.Framework;
|
|
|
|
namespace Opc.Ua.OpenUsd.Client.Tests
|
|
{
|
|
/// <summary>
|
|
/// Unit tests for <see cref="UsdFileSink"/>: USD text authoring, identifier
|
|
/// validation, value formatting, composition metadata and batching.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("OpenUsd")]
|
|
public sealed class UsdFileSinkTests
|
|
{
|
|
private string m_path = string.Empty;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
m_path = Path.Combine(Path.GetTempPath(), "usdfilesink-" + Guid.NewGuid().ToString("N") + ".usda");
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
if (File.Exists(m_path))
|
|
{
|
|
File.Delete(m_path);
|
|
}
|
|
}
|
|
|
|
private string ReadLayer()
|
|
{
|
|
return File.ReadAllText(m_path);
|
|
}
|
|
|
|
[Test]
|
|
public void SetAttributeAuthorsTypedScalarProperty()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Pump", "radius", new Variant(2.5));
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("over \"Pump\""));
|
|
Assert.That(layer, Does.Contain("double radius = 2.5000"));
|
|
}
|
|
|
|
[Test]
|
|
public void SetAttributeDisplayColorAuthorsColor3fArray()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Pump", "primvars:displayColor",
|
|
new Variant((ArrayOf<float>)[1f, 0f, 0f]));
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("color3f[] primvars:displayColor = [(1.0000, 0.0000, 0.0000)]"));
|
|
}
|
|
|
|
[Test]
|
|
public void SetAttributeNonDisplayColorAuthorsColor3fScalar()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Pump", "inputs:emissiveColor",
|
|
new Variant((ArrayOf<float>)[0.1f, 1f, 0.2f]));
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("color3f inputs:emissiveColor = (0.1000, 1.0000, 0.2000)"));
|
|
}
|
|
|
|
[Test]
|
|
public void SetAttributeTokenEscapesControlCharacters()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Pump", "visibility", new Variant("line1\nline2"));
|
|
|
|
string layer = ReadLayer();
|
|
// The newline is escaped so the token value never breaks out of its line.
|
|
Assert.That(layer, Does.Contain("token visibility = \"line1\\nline2\""));
|
|
Assert.That(layer, Does.Not.Contain("line1\nline2"));
|
|
}
|
|
|
|
[Test]
|
|
public void NestedPrimPathBuildsHierarchy()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Plant/Line1/Pump", "radius", new Variant(1.0));
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("over \"Plant\""));
|
|
Assert.That(layer, Does.Contain("over \"Line1\""));
|
|
Assert.That(layer, Does.Contain("over \"Pump\""));
|
|
}
|
|
|
|
[Test]
|
|
public void InvalidPrimPathIsRejectedAndNothingIsWritten()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("1bad/Pump", "radius", new Variant(1.0));
|
|
|
|
Assert.That(File.Exists(m_path), Is.False, "An invalid prim path must not author a layer.");
|
|
}
|
|
|
|
[Test]
|
|
public void PrimPathTraversalSegmentIsRejected()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Pump/../Root", "radius", new Variant(1.0));
|
|
|
|
Assert.That(File.Exists(m_path), Is.False, "A '..' path segment must be rejected.");
|
|
}
|
|
|
|
[Test]
|
|
public void InvalidPropertyNameIsRejected()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Pump", "bad prop", new Variant(1.0));
|
|
|
|
Assert.That(File.Exists(m_path), Is.False, "An invalid property name must be rejected.");
|
|
}
|
|
|
|
[Test]
|
|
public void ComposePrimReferenceAuthorsPrependReferences()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.ComposePrim("/Pump", OpenUsdCompositionArc.Reference, "@pump.usda@", active: true);
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("prepend references = @pump.usda@"));
|
|
Assert.That(layer, Does.Contain("active = true"));
|
|
}
|
|
|
|
[Test]
|
|
public void ComposePrimPayloadAuthorsPrependPayload()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.ComposePrim("/Pump", OpenUsdCompositionArc.Payload, "@pump.usda@", active: true);
|
|
|
|
Assert.That(ReadLayer(), Does.Contain("prepend payload = @pump.usda@"));
|
|
}
|
|
|
|
[Test]
|
|
public void ComposePrimInstanceAuthorsInstanceable()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.ComposePrim("/Pump", OpenUsdCompositionArc.Instance, "@pump.usda@", active: true);
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("instanceable = true"));
|
|
Assert.That(layer, Does.Contain("prepend references = @pump.usda@"));
|
|
}
|
|
|
|
[Test]
|
|
public void ComposePrimInactiveAuthorsActiveFalse()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.ComposePrim("/Pump", OpenUsdCompositionArc.Child, assetReference: null, active: false);
|
|
|
|
Assert.That(ReadLayer(), Does.Contain("active = false"));
|
|
}
|
|
|
|
[Test]
|
|
public void UnsafeAssetReferenceIsNotAuthored()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.ComposePrim("/Pump", OpenUsdCompositionArc.Reference, "bad\"ref", active: true);
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Not.Contain("references"));
|
|
// The prim itself is still authored (active state), only the unsafe ref is dropped.
|
|
Assert.That(layer, Does.Contain("over \"Pump\""));
|
|
}
|
|
|
|
[Test]
|
|
public void TimeSamplesAuthorFrameBlock()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
var t0 = new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc);
|
|
var t1 = new DateTime(1970, 1, 1, 0, 0, 2, DateTimeKind.Utc);
|
|
using (sink.BeginBatch())
|
|
{
|
|
sink.SetTimeSample("/Pump", "radius", t0, new Variant(1.0));
|
|
sink.SetTimeSample("/Pump", "radius", t1, new Variant(2.0));
|
|
}
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("double radius.timeSamples = {"));
|
|
Assert.That(layer, Does.Contain("1.000: 1.0000"));
|
|
Assert.That(layer, Does.Contain("2.000: 2.0000"));
|
|
}
|
|
|
|
[Test]
|
|
public void SetAttributeStructuredTransformAuthorsMatrixProperty()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Pump", "xformOp:translate",
|
|
new Variant((ArrayOf<double>)[1.0, 2.0, 3.0]));
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("matrix4d xformOp:transform ="));
|
|
Assert.That(layer, Does.Contain("(1.0000, 2.0000, 3.0000, 1.0000)"));
|
|
Assert.That(layer, Does.Not.Contain("double3 xformOp:translate"));
|
|
}
|
|
|
|
[Test]
|
|
public void SetAttributeVectorWithMoreThanThreeElementsUsesFirstThree()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
sink.SetAttribute("/Pump", "xformOp:translate",
|
|
new Variant((ArrayOf<double>)[1.0, 2.0, 3.0, 4.0]));
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("double3 xformOp:translate = (1.0000, 2.0000, 3.0000)"));
|
|
}
|
|
|
|
[Test]
|
|
public void TimeSamplesAuthorMatrixTransformFrameBlock()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
var t0 = new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc);
|
|
var t1 = new DateTime(1970, 1, 1, 0, 0, 2, DateTimeKind.Utc);
|
|
using (sink.BeginBatch())
|
|
{
|
|
sink.SetTimeSample("/Pump", "xformOp:translate", t0,
|
|
new Variant((ArrayOf<double>)[1.0, 2.0, 3.0]));
|
|
sink.SetTimeSample("/Pump", "xformOp:translate", t1,
|
|
new Variant((ArrayOf<double>)[4.0, 5.0, 6.0]));
|
|
}
|
|
|
|
string layer = ReadLayer();
|
|
Assert.That(layer, Does.Contain("matrix4d xformOp:transform.timeSamples = {"));
|
|
Assert.That(layer, Does.Contain("1.000:"));
|
|
Assert.That(layer, Does.Contain("(1.0000, 2.0000, 3.0000, 1.0000)"));
|
|
Assert.That(layer, Does.Contain("2.000:"));
|
|
Assert.That(layer, Does.Contain("(4.0000, 5.0000, 6.0000, 1.0000)"));
|
|
Assert.That(layer, Does.Not.Contain("double3 xformOp:translate.timeSamples"));
|
|
}
|
|
|
|
[Test]
|
|
public void TimeSampleTransformUsesLatestPriorComponents()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
var translationTime = new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc);
|
|
var rotationTime = new DateTime(1970, 1, 1, 0, 0, 2, DateTimeKind.Utc);
|
|
using (sink.BeginBatch())
|
|
{
|
|
sink.SetTimeSample(
|
|
"/Pump",
|
|
"xformOp:translate",
|
|
translationTime,
|
|
new Variant((ArrayOf<double>)[1.0, 2.0, 3.0]));
|
|
sink.SetTimeSample(
|
|
"/Pump",
|
|
"xformOp:rotateXYZ",
|
|
rotationTime,
|
|
new Variant((ArrayOf<double>)[0.0, 0.0, 90.0]));
|
|
}
|
|
|
|
string rotationSample = Array.Find(
|
|
ReadLayer().Split('\n'),
|
|
line => line.Contains("2.000:", StringComparison.Ordinal))!;
|
|
|
|
Assert.That(rotationSample, Does.Contain("(-1.0000, 0.0000, 0.0000, 0.0000)"));
|
|
Assert.That(rotationSample, Does.Contain("(1.0000, 2.0000, 3.0000, 1.0000)"));
|
|
}
|
|
|
|
[Test]
|
|
public void BatchDefersWriteUntilScopeDisposed()
|
|
{
|
|
var sink = new UsdFileSink(m_path);
|
|
IDisposable batch = sink.BeginBatch();
|
|
sink.SetAttribute("/Pump", "radius", new Variant(1.0));
|
|
|
|
Assert.That(File.Exists(m_path), Is.False, "Writes must be deferred while a batch is open.");
|
|
batch.Dispose();
|
|
Assert.That(File.Exists(m_path), Is.True, "Closing the batch must flush the layer once.");
|
|
Assert.That(ReadLayer(), Does.Contain("double radius = 1.0000"));
|
|
}
|
|
}
|
|
}
|