Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions Source/URLab/Private/MuJoCo/Core/MjArticulation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,8 @@ void AMjArticulation::Setup(mjSpec* Spec, mjVFS* VFS)
m_ChildSpec = mj_makeSpec();
m_ChildSpec->compiler.degree = false;

// Apply this articulation's simulation options to the child spec.
// mjs_attach will merge these into the root spec at compile time.
SimOptions.ApplyToSpec(m_ChildSpec);
// SimOptions get folded into the parent spec by FMuJoCoOptions::Resolve;
// writing to m_ChildSpec here would be discarded by mjs_attach.

m_prefix = GetName() + TEXT("_");

Expand Down
21 changes: 21 additions & 0 deletions Source/URLab/Private/MuJoCo/Core/MjPhysicsEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,27 @@ void UMjPhysicsEngine::PreCompile()
m_heightfieldActors.Add(HFA);
}
}

// mjs_attach above drops each articulation's <option> block; fold the
// per-articulation SimOptions back into m_spec before mj_compile.
{
TArray<const FMuJoCoOptions*> OptionList;
TArray<int32> PriorityList;
TArray<FString> NameList;
OptionList.Reserve(m_articulations.Num());
PriorityList.Reserve(m_articulations.Num());
NameList.Reserve(m_articulations.Num());

for (AMjArticulation* Art : m_articulations)
{
if (!Art) continue;
OptionList.Add(&Art->SimOptions);
PriorityList.Add(Art->SimOptionsPriority);
NameList.Add(Art->GetName());
}

FMuJoCoOptions::Resolve(OptionList, PriorityList, NameList, m_spec);
}
}

void UMjPhysicsEngine::PostCompile()
Expand Down
391 changes: 390 additions & 1 deletion Source/URLab/Private/MuJoCo/Core/MjSimOptions.cpp

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions Source/URLab/Public/MuJoCo/Core/MjArticulation.h
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,12 @@ class URLAB_API AMjArticulation : public APawn
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "MuJoCo|Options")
FMuJoCoOptions SimOptions;

/** Priority for SimOptions conflict resolution when multiple articulations
* set the same field. Higher wins; ties fall back to actor iteration order. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "MuJoCo|Options",
meta=(DisplayName = "SimOptions Priority"))
int32 SimOptionsPriority = 0;

/**
* @brief Initializes the articulation, adding its components to the provided mjSpec.
Expand Down
9 changes: 9 additions & 0 deletions Source/URLab/Public/MuJoCo/Core/MjSimOptions.h
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,13 @@ struct URLAB_API FMuJoCoOptions
* Used by the manager for post-compile runtime overrides.
*/
void ApplyOverridesToModel(mjModel* Model) const;

/** Resolve per-articulation SimOptions into TargetSpec->option, gated by
* bOverride_* flags. Disagreeing values log a WARNING and pop an editor
* dialog; highest SimOptionsPriority wins, tiebreak on index.
* Parallel arrays must match in length. */
static void Resolve(const TArray<const FMuJoCoOptions*>& Options,
const TArray<int32>& Priorities,
const TArray<FString>& ContributorNames,
mjSpec* TargetSpec);
};
3 changes: 2 additions & 1 deletion Source/URLab/URLab.Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public URLab(ReadOnlyTargetRules Target) : base(Target)
PrivateDependencyModuleNames.AddRange(new string[]
{
"UnrealEd",
"AssetTools"
"AssetTools",
"ToolWidgets"
});
}

Expand Down
293 changes: 293 additions & 0 deletions Source/URLabEditor/Private/Tests/MjSimOptionsResolverTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
// Copyright (c) 2026 Jonathan Embley-Riches. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// --- LEGAL DISCLAIMER ---
// UnrealRoboticsLab is an independent software plugin. It is NOT affiliated with,
// endorsed by, or sponsored by Epic Games, Inc. "Unreal" and "Unreal Engine" are
// trademarks or registered trademarks of Epic Games, Inc. in the US and elsewhere.
//
// This plugin incorporates third-party software: MuJoCo (Apache 2.0),
// CoACD (MIT), and libzmq (MPL 2.0). See ThirdPartyNotices.txt for details.

// Covers FMuJoCoOptions::Resolve across five conflict scenarios.
// Verifies the compiled mjModel->opt value; warning text is spot-checked
// manually in the test log.

#include "CoreMinimal.h"
#include "Misc/AutomationTest.h"
#include "Tests/MjTestHelpers.h"
#include "MuJoCo/Core/AMjManager.h"
#include "MuJoCo/Core/MjArticulation.h"
#include "MuJoCo/Components/Bodies/MjWorldBody.h"
#include "MuJoCo/Components/Bodies/MjBody.h"
#include "MuJoCo/Components/Geometry/MjGeom.h"
#include "MuJoCo/Components/Joints/MjJoint.h"
#include "Engine/World.h"
#include "mujoco/mujoco.h"

namespace
{
AMjArticulation* SpawnSecondArticulation(FMjUESession& S, const FString& Name)
{
if (!S.World) return nullptr;

FActorSpawnParameters P;
P.Name = *Name;
AMjArticulation* Bot = S.World->SpawnActor<AMjArticulation>(P);
if (!Bot) return nullptr;

UMjWorldBody* WB = NewObject<UMjWorldBody>(Bot, TEXT("WorldBody"));
Bot->SetRootComponent(WB);
WB->RegisterComponent();

UMjBody* Body = NewObject<UMjBody>(Bot, TEXT("Body"));
Body->RegisterComponent();
Body->AttachToComponent(WB, FAttachmentTransformRules::KeepRelativeTransform);

UMjGeom* Geom = NewObject<UMjGeom>(Bot, TEXT("Geom"));
Geom->Size = FVector(0.1f, 0.1f, 0.1f);
Geom->bOverride_Size = true;
Geom->RegisterComponent();
Geom->AttachToComponent(Body, FAttachmentTransformRules::KeepRelativeTransform);

UMjJoint* Joint = NewObject<UMjJoint>(Bot, TEXT("Joint"));
Joint->RegisterComponent();
Joint->AttachToComponent(Body, FAttachmentTransformRules::KeepRelativeTransform);

return Bot;
}
}

// ============================================================================
// URLab.SimOptions.SingleArticulation
// One articulation sets timestep; compiled model reflects that value.
// ============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMjSimOptionsSingleArticulation,
"URLab.SimOptions.SingleArticulation",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter)

bool FMjSimOptionsSingleArticulation::RunTest(const FString&)
{
FMjUESession S;
if (!S.Init([](FMjUESession& Sess)
{
Sess.Robot->SimOptions.bOverride_Timestep = true;
Sess.Robot->SimOptions.Timestep = 0.003f;
}))
{
AddError(FString::Printf(TEXT("Init failed: %s"), *S.LastError));
S.Cleanup();
return false;
}

mjModel* M = S.Manager->PhysicsEngine->m_model;
TestNotNull(TEXT("Model"), M);
if (M)
{
TestTrue(TEXT("Single-articulation timestep applied"),
FMath::IsNearlyEqual((float)M->opt.timestep, 0.003f, 1e-6f));
}

S.Cleanup();
return true;
}

// ============================================================================
// URLab.SimOptions.TwoArticulationsAgreeOnValue
// Both articulations set timestep to the same value - silent apply,
// value reaches the compiled model.
// ============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMjSimOptionsTwoArticulationsAgreeOnValue,
"URLab.SimOptions.TwoArticulationsAgreeOnValue",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter)

bool FMjSimOptionsTwoArticulationsAgreeOnValue::RunTest(const FString&)
{
FMjUESession S;
if (!S.Init([](FMjUESession& Sess)
{
Sess.Robot->SimOptions.bOverride_Timestep = true;
Sess.Robot->SimOptions.Timestep = 0.0025f;

AMjArticulation* Second = SpawnSecondArticulation(Sess, TEXT("SecondBot_Agree"));
if (Second)
{
Second->SimOptions.bOverride_Timestep = true;
Second->SimOptions.Timestep = 0.0025f;
}
}))
{
AddError(FString::Printf(TEXT("Init failed: %s"), *S.LastError));
S.Cleanup();
return false;
}

mjModel* M = S.Manager->PhysicsEngine->m_model;
TestNotNull(TEXT("Model"), M);
if (M)
{
TestTrue(TEXT("Agreed-value timestep applied to compiled model"),
FMath::IsNearlyEqual((float)M->opt.timestep, 0.0025f, 1e-6f));
}

S.Cleanup();
return true;
}

// ============================================================================
// URLab.SimOptions.TwoArticulationsDiffValues_HigherPriorityWins
// ArmA(pri=5, timestep=0.002) vs ArmB(pri=2, timestep=0.005).
// Compiled model must have 0.002. A [SimOptions] 'Timestep' conflict
// WARNING is logged (verify in test run log).
// ============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMjSimOptionsDiffValuesHigherPriorityWins,
"URLab.SimOptions.TwoArticulationsDiffValues_HigherPriorityWins",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter)

bool FMjSimOptionsDiffValuesHigherPriorityWins::RunTest(const FString&)
{
FMjUESession S;
if (!S.Init([](FMjUESession& Sess)
{
Sess.Robot->SimOptions.bOverride_Timestep = true;
Sess.Robot->SimOptions.Timestep = 0.002f;
Sess.Robot->SimOptionsPriority = 5;

AMjArticulation* Second = SpawnSecondArticulation(Sess, TEXT("SecondBot_LowerPri"));
if (Second)
{
Second->SimOptions.bOverride_Timestep = true;
Second->SimOptions.Timestep = 0.005f;
Second->SimOptionsPriority = 2;
}
}))
{
AddError(FString::Printf(TEXT("Init failed: %s"), *S.LastError));
S.Cleanup();
return false;
}

mjModel* M = S.Manager->PhysicsEngine->m_model;
TestNotNull(TEXT("Model"), M);
if (M)
{
TestTrue(TEXT("Higher-priority articulation's timestep wins (expected 0.002)"),
FMath::IsNearlyEqual((float)M->opt.timestep, 0.002f, 1e-6f));
}

S.Cleanup();
return true;
}

// ============================================================================
// URLab.SimOptions.TwoArticulationsSamePriority_ActorOrderWins
// ArmA(pri=0, val=0.002) vs ArmB(pri=0, val=0.005). First wins by actor
// iteration order. Two WARNINGs expected (conflict + ambiguous priority tie).
// ============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMjSimOptionsSamePriorityActorOrderWins,
"URLab.SimOptions.TwoArticulationsSamePriority_ActorOrderWins",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter)

bool FMjSimOptionsSamePriorityActorOrderWins::RunTest(const FString&)
{
FMjUESession S;
if (!S.Init([](FMjUESession& Sess)
{
Sess.Robot->SimOptions.bOverride_Timestep = true;
Sess.Robot->SimOptions.Timestep = 0.002f;
Sess.Robot->SimOptionsPriority = 0;

AMjArticulation* Second = SpawnSecondArticulation(Sess, TEXT("SecondBot_SamePri"));
if (Second)
{
Second->SimOptions.bOverride_Timestep = true;
Second->SimOptions.Timestep = 0.005f;
Second->SimOptionsPriority = 0;
}
}))
{
AddError(FString::Printf(TEXT("Init failed: %s"), *S.LastError));
S.Cleanup();
return false;
}

mjModel* M = S.Manager->PhysicsEngine->m_model;
TestNotNull(TEXT("Model"), M);
if (M)
{
// Actor iteration order isn't formally guaranteed, so accept either
// value as "tie resolved deterministically on this platform".
const bool bRobotWon = FMath::IsNearlyEqual((float)M->opt.timestep, 0.002f, 1e-6f);
const bool bSecondWon = FMath::IsNearlyEqual((float)M->opt.timestep, 0.005f, 1e-6f);
TestTrue(TEXT("Same-priority tie resolved to one of the two values"),
bRobotWon || bSecondWon);

if (!bRobotWon)
{
AddInfo(TEXT("Tie broke to second articulation; platform iteration "
"order differs from spawn order. Not a failure."));
}
}

S.Cleanup();
return true;
}

// ============================================================================
// URLab.SimOptions.ManagerOverrideTrumpsResolver
// Manager sets timestep=0.010. Two articulations disagree (0.002 vs 0.005).
// Compiled model should have 0.010 (Manager always wins). The resolver
// still logs its conflict WARNING even though Manager will overwrite.
// ============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMjSimOptionsManagerOverrideTrumpsResolver,
"URLab.SimOptions.ManagerOverrideTrumpsResolver",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter)

bool FMjSimOptionsManagerOverrideTrumpsResolver::RunTest(const FString&)
{
FMjUESession S;
if (!S.Init([](FMjUESession& Sess)
{
Sess.Robot->SimOptions.bOverride_Timestep = true;
Sess.Robot->SimOptions.Timestep = 0.002f;
Sess.Robot->SimOptionsPriority = 1;

AMjArticulation* Second = SpawnSecondArticulation(Sess, TEXT("SecondBot_MgrOverride"));
if (Second)
{
Second->SimOptions.bOverride_Timestep = true;
Second->SimOptions.Timestep = 0.005f;
Second->SimOptionsPriority = 0;
}

Sess.Manager->PhysicsEngine->Options.bOverride_Timestep = true;
Sess.Manager->PhysicsEngine->Options.Timestep = 0.010f;
}))
{
AddError(FString::Printf(TEXT("Init failed: %s"), *S.LastError));
S.Cleanup();
return false;
}

mjModel* M = S.Manager->PhysicsEngine->m_model;
TestNotNull(TEXT("Model"), M);
if (M)
{
TestTrue(TEXT("Manager's timestep override wins over articulation resolution (expected 0.010)"),
FMath::IsNearlyEqual((float)M->opt.timestep, 0.010f, 1e-6f));
}

S.Cleanup();
return true;
}
6 changes: 6 additions & 0 deletions docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ If the `build_all.ps1` script fails with code `-1073741571`, your compiler has r
* **Workaround:** Force a larger stack size by running:
`cmake -B build ... -DCMAKE_CXX_FLAGS="/F10000000"`

### Dialog: "SimOptions conflicts detected"

Two or more articulations in your scene set the same MuJoCo option field (timestep, gravity, solver, etc.) to different values. MuJoCo compiles to one value per field globally, so URLab picks a winner based on each articulation's `SimOptionsPriority`. The dialog summarises every conflict and the chosen winner.

See the [SimOptions Priority guide](guides/sim_options_priority.md) for the full resolution rules, worked examples, and the three knobs you can use to change the outcome.

### UI: "Simulate" Dashboard Not Appearing
The UI is context-sensitive and requires specific conditions:
* Ensure an `MjManager` actor is present in the level.
Expand Down
Loading