diff --git a/Scripts/clean_meshes.py b/Scripts/clean_meshes.py index 47cbc5fa..33d7c0da 100644 --- a/Scripts/clean_meshes.py +++ b/Scripts/clean_meshes.py @@ -54,9 +54,18 @@ def clean_mesh(mesh): mesh.remove_unreferenced_vertices() try: import networkx # noqa: F401 - trimesh's fix_normals requires it - mesh.fix_normals() except ImportError: - print(" Warning: networkx not installed, skipping fix_normals (GLB may have lighting issues)") + # A GLB exported without fixed normals imports into Unreal with + # degenerate normals/tangents on every mesh. Failing here makes + # convert_mesh() skip the GLB entirely, so the importer falls back + # to the source mesh and Unreal computes clean normals itself. + raise RuntimeError( + "networkx is not installed (required by trimesh.fix_normals); " + "skipping GLB conversion so the source mesh is imported instead. " + "Install it via the plugin's Python-dependency prompt or " + "'python -m pip install networkx'." + ) + mesh.fix_normals() # Rotate -90 degrees around X for GLTF Y-up -> Unreal Z-up rotation_matrix = trimesh.transformations.rotation_matrix(-np.radians(90), [1, 0, 0]) @@ -66,6 +75,19 @@ def clean_mesh(mesh): return mesh +_SCRIPT_MTIME = Path(__file__).stat().st_mtime + + +def glb_up_to_date(output_glb: Path, source_path: Path) -> bool: + """A GLB is stale when older than its source mesh OR older than this + script -- conversion fixes ship with the script, so GLBs produced by an + older version must be regenerated.""" + if not output_glb.exists(): + return False + mtime = output_glb.stat().st_mtime + return mtime > source_path.stat().st_mtime and mtime > _SCRIPT_MTIME + + def convert_mesh(input_path: Path, output_path: Path) -> bool: """Convert a single mesh file to GLB.""" print(f"\n Converting: {input_path.name} -> {output_path.name}") @@ -99,7 +121,10 @@ def convert_mesh(input_path: Path, output_path: Path) -> bool: if np.allclose(size, 0): print(f" Warning: Mesh has zero size!") - cleaned_mesh.export(str(output_path)) + # include_normals: trimesh omits the NORMAL accessor by default and + # Unreal then builds the mesh with zero normals ("degenerate tangent + # bases" / "nearly zero normals" on every import). + cleaned_mesh.export(str(output_path), include_normals=True) print(f" -> Saved: {output_path.name}") return True @@ -346,9 +371,11 @@ def process_xml(xml_path: Path): source_path = mesh_base / file_attr if source_path.exists(): output_glb = source_path.with_suffix(".glb") - if not output_glb.exists() or output_glb.stat().st_mtime < source_path.stat().st_mtime: + if not glb_up_to_date(output_glb, source_path): print(f"\n[flexcomp] Converting mesh: {source_path.name} -> {output_glb.name}") - convert_mesh(source_path, output_glb) + if not convert_mesh(source_path, output_glb) and output_glb.exists(): + output_glb.unlink() + print(f"[flexcomp] Removed stale GLB: {output_glb.name}") else: print(f"\n[flexcomp] Mesh up to date: {output_glb.name}") @@ -446,8 +473,7 @@ def process_xml(xml_path: Path): print(f"\n x Source not found: {actual_source}") continue - # Skip if GLB already exists and is newer than source - if output_glb.exists() and output_glb.stat().st_mtime > actual_source.stat().st_mtime: + if glb_up_to_date(output_glb, actual_source): print(f"\n Skipping '{mesh_name}' (GLB up to date): {output_glb.name}") success_count += 1 continue @@ -457,6 +483,13 @@ def process_xml(xml_path: Path): success_count += 1 else: print(f" x FAILED to convert {actual_source.name}") + # Never leave a stale or partial GLB behind: the importer + # prefers .glb over the source mesh, so a leftover here would + # silently ship the very data the conversion just refused to + # produce. + if output_glb.exists(): + output_glb.unlink() + print(f" -> Removed stale GLB: {output_glb.name}") # Phase 4: Write updated XML output_xml = xml_path.parent / f"{xml_path.stem}_ue.xml" diff --git a/Source/URLab/Private/MuJoCo/Components/Bodies/MjBody.cpp b/Source/URLab/Private/MuJoCo/Components/Bodies/MjBody.cpp index 9460216c..bcf92183 100644 --- a/Source/URLab/Private/MuJoCo/Components/Bodies/MjBody.cpp +++ b/Source/URLab/Private/MuJoCo/Components/Bodies/MjBody.cpp @@ -121,6 +121,27 @@ void UMjBody::ApplyRenderState(const FMjRenderSnapshot& Snap) const FVector MuJoCoWorldPos = MjUtils::MjToUEPosition(&Snap.XPos[PosIdx]); const FQuat MuJoCoWorldQuat = MjUtils::MjToUERotation(&Snap.XQuat[QuatIdx]); + // A zero or non-finite snapshot row would write a degenerate transform + // that NaN-floods the renderer (NIL LocalToWorld in the distance-field + // pass) for every mesh under this body. Skip the frame and name the + // offender once. + if (MuJoCoWorldPos.ContainsNaN() || MuJoCoWorldQuat.ContainsNaN() + || MuJoCoWorldQuat.SizeSquared() < KINDA_SMALL_NUMBER) + { + if (!m_bWarnedDegenerateXform) + { + UE_LOG(LogURLabBind, Warning, + TEXT("MjBody::ApplyRenderState - Body '%s' (id=%d) got a " + "degenerate snapshot transform (pos=%s quat=[%f %f %f %f]); " + "skipping."), + *GetName(), Id, *MuJoCoWorldPos.ToString(), + Snap.XQuat[QuatIdx], Snap.XQuat[QuatIdx + 1], + Snap.XQuat[QuatIdx + 2], Snap.XQuat[QuatIdx + 3]); + m_bWarnedDegenerateXform = true; + } + return; + } + FVector CorrectedPos = MuJoCoWorldPos; if (bIsQuickConverted) diff --git a/Source/URLab/Private/MuJoCo/Components/Geometry/MjGeom.cpp b/Source/URLab/Private/MuJoCo/Components/Geometry/MjGeom.cpp index f94b8e17..b1446a0b 100644 --- a/Source/URLab/Private/MuJoCo/Components/Geometry/MjGeom.cpp +++ b/Source/URLab/Private/MuJoCo/Components/Geometry/MjGeom.cpp @@ -488,6 +488,25 @@ void UMjGeom::UpdateGlobalTransform() mju_mat2Quat(quat, const_cast(&Snap.GeomXMat[MatIdx])); const FQuat WorldRot = MjUtils::MjToUERotation(quat); + // A zero or non-finite snapshot row would write a degenerate + // transform that NaN-floods the renderer (NIL LocalToWorld in the + // distance-field pass). Skip the frame and name the offender once. + if (WorldPos.ContainsNaN() || WorldRot.ContainsNaN() + || WorldRot.SizeSquared() < KINDA_SMALL_NUMBER) + { + if (!m_bWarnedDegenerateXform) + { + UE_LOG(LogURLabBind, Warning, + TEXT("MjGeom::UpdateGlobalTransform - geom '%s' (id=%d) got a " + "degenerate snapshot transform (pos=%s quat=[%f %f %f %f]); " + "skipping."), + *GetName(), Id, *WorldPos.ToString(), + quat[0], quat[1], quat[2], quat[3]); + m_bWarnedDegenerateXform = true; + } + return; + } + SetWorldLocation(WorldPos); SetWorldRotation(WorldRot); }); diff --git a/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjBox.cpp b/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjBox.cpp index 513fb4c9..5e4a23fb 100644 --- a/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjBox.cpp +++ b/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjBox.cpp @@ -96,17 +96,30 @@ void UMjBox::ImportFromXml(const FXmlNode* Node, const FMjCompilerSettings& Comp // --- CODEGEN_IMPORT_END --- Super::ImportFromXml(Node, CompilerSettings); - // MuJoCo box size is 3 half-extents in metres. - Extents = FVector( - size.Num() > 0 ? size[0] : 0.0f, - size.Num() > 1 ? size[1] : 0.0f, - size.Num() > 2 ? size[2] : 0.0f); + SyncEditorScaleFromSize(); +} + +void UMjBox::SyncEditorScaleFromSize() +{ + // MuJoCo box size is 3 half-extents in metres. Sentinel -1 marks a slot + // the fromto canon left unset; treat it like a missing slot. + auto ReadSlot = [this](int32 i) -> float { + if (size.Num() <= i) + return 0.0f; + return size[i] < 0.0f ? 0.0f : size[i]; + }; + Extents = FVector(ReadSlot(0), ReadSlot(1), ReadSlot(2)); + + if (Extents.GetMin() <= 0.0f) + { + // Size not resolvable yet (inherited from a default class); keep the + // current scale rather than baking a degenerate zero into the template. + return; + } - // Sync Unreal scale immediately on import so the editor visual matches the data const float BaseSize = 50.0f; const float UnitScale = 100.0f; - FVector NewScale = (Extents * UnitScale) / BaseSize; - SetRelativeScale3D(NewScale); + SetRelativeScale3D((Extents * UnitScale) / BaseSize); } void UMjBox::ExportTo(mjsGeom* Element, mjsDefault* def) diff --git a/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjCapsule.cpp b/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjCapsule.cpp index 0f574e86..3e173378 100644 --- a/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjCapsule.cpp +++ b/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjCapsule.cpp @@ -140,7 +140,11 @@ void UMjCapsule::ImportFromXml(const FXmlNode* Node, const FMjCompilerSettings& // --- CODEGEN_IMPORT_END --- Super::ImportFromXml(Node, CompilerSettings); + SyncEditorScaleFromSize(); +} +void UMjCapsule::SyncEditorScaleFromSize() +{ // Super already resolves any `fromto` into pos/quat + size[1] (half-length). // Capsule's MJCF `size` is [radius, halflength] — same layout as cylinder. // The codegen fromto canon writes -1.0f sentinels for slots not set @@ -153,6 +157,13 @@ void UMjCapsule::ImportFromXml(const FXmlNode* Node, const FMjCompilerSettings& Radius = ReadSlot(0); HalfLength = ReadSlot(1); + if (Radius <= 0.0f || HalfLength <= 0.0f) + { + // Size not resolvable yet (inherited from a default class); keep the + // current scale rather than baking a degenerate zero into the template. + return; + } + // Map (radius, halflength) → parent scale, mirroring UMjCylinder. FVector NewScale; NewScale.X = NewScale.Y = (Radius * kCmPerM) / kBaseHalf; diff --git a/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjCylinder.cpp b/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjCylinder.cpp index 331d8b3c..92066431 100644 --- a/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjCylinder.cpp +++ b/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjCylinder.cpp @@ -80,6 +80,11 @@ void UMjCylinder::ImportFromXml(const FXmlNode* Node, const FMjCompilerSettings& // --- CODEGEN_IMPORT_END --- Super::ImportFromXml(Node, CompilerSettings); + SyncEditorScaleFromSize(); +} + +void UMjCylinder::SyncEditorScaleFromSize() +{ // Clamp -1.0f sentinels (set by the fromto canon when slot is unset). auto ReadSlot = [this](int32 i) -> float { if (size.Num() <= i) @@ -89,7 +94,13 @@ void UMjCylinder::ImportFromXml(const FXmlNode* Node, const FMjCompilerSettings& Radius = ReadSlot(0); HalfLength = ReadSlot(1); - // Sync Unreal scale immediately on import so the editor visual matches the data + if (Radius <= 0.0f || HalfLength <= 0.0f) + { + // Size not resolvable yet (inherited from a default class); keep the + // current scale rather than baking a degenerate zero into the template. + return; + } + const float BaseSize = 50.0f; const float UnitScale = 100.0f; FVector NewScale; diff --git a/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjSphere.cpp b/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjSphere.cpp index ad46b4a9..84539c78 100644 --- a/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjSphere.cpp +++ b/Source/URLab/Private/MuJoCo/Components/Geometry/Primitives/MjSphere.cpp @@ -80,13 +80,22 @@ void UMjSphere::ImportFromXml(const FXmlNode* Node, const FMjCompilerSettings& C // --- CODEGEN_IMPORT_END --- Super::ImportFromXml(Node, CompilerSettings); - Radius = size.Num() > 0 ? size[0] : 0.0f; + SyncEditorScaleFromSize(); +} + +void UMjSphere::SyncEditorScaleFromSize() +{ + Radius = size.Num() > 0 ? FMath::Max(size[0], 0.0f) : 0.0f; + if (Radius <= 0.0f) + { + // Size not resolvable yet (inherited from a default class); keep the + // current scale rather than baking a degenerate zero into the template. + return; + } - // Sync Unreal scale immediately on import so the editor visual matches the data const float BaseSize = 50.0f; const float UnitScale = 100.0f; - FVector NewScale = FVector((Radius * UnitScale) / BaseSize); - SetRelativeScale3D(NewScale); + SetRelativeScale3D(FVector((Radius * UnitScale) / BaseSize)); } void UMjSphere::ExportTo(mjsGeom* Element, mjsDefault* def) diff --git a/Source/URLab/Private/MuJoCo/Components/QuickConvert/MjQuickConvertComponent.cpp b/Source/URLab/Private/MuJoCo/Components/QuickConvert/MjQuickConvertComponent.cpp index 08c3eb46..2202c2a0 100644 --- a/Source/URLab/Private/MuJoCo/Components/QuickConvert/MjQuickConvertComponent.cpp +++ b/Source/URLab/Private/MuJoCo/Components/QuickConvert/MjQuickConvertComponent.cpp @@ -363,6 +363,14 @@ void UMjQuickConvertComponent::ApplyRenderState(const FMjRenderSnapshot& Snap) const FVector Pos = MjUtils::MjToUEPosition(&Snap.XPos[PosIdx]); const FQuat Quat = MjUtils::MjToUERotation(&Snap.XQuat[QuatIdx]); + // Mirror UMjBody::ApplyRenderState: never apply a zero/NaN snapshot row -- + // it writes a degenerate transform that NaN-floods the renderer. + if (Pos.ContainsNaN() || Quat.ContainsNaN() + || Quat.SizeSquared() < KINDA_SMALL_NUMBER) + { + return; + } + m_actor->SetActorRotation(Quat); m_actor->SetActorLocation(Pos); } diff --git a/Source/URLab/Public/MuJoCo/Components/Bodies/MjBody.h b/Source/URLab/Public/MuJoCo/Components/Bodies/MjBody.h index 19523d56..b8269b32 100644 --- a/Source/URLab/Public/MuJoCo/Components/Bodies/MjBody.h +++ b/Source/URLab/Public/MuJoCo/Components/Bodies/MjBody.h @@ -221,6 +221,10 @@ class URLAB_API UMjBody : public UMjComponent bool m_IsSetup = false; + // One-shot guard so a degenerate (zero/NaN) snapshot transform warns once + // instead of every frame. + bool m_bWarnedDegenerateXform = false; + FVector m_MeshPivotOffset = FVector::ZeroVector; UPROPERTY() diff --git a/Source/URLab/Public/MuJoCo/Components/Geometry/MjGeom.h b/Source/URLab/Public/MuJoCo/Components/Geometry/MjGeom.h index aedfed3e..5b3b8708 100644 --- a/Source/URLab/Public/MuJoCo/Components/Geometry/MjGeom.h +++ b/Source/URLab/Public/MuJoCo/Components/Geometry/MjGeom.h @@ -214,6 +214,15 @@ class URLAB_API UMjGeom : public UMjComponent */ virtual void SyncUnrealTransformFromMj(); + /** + * @brief Recomputes the editor-preview RelativeScale3D from `size`. + * No-op when the needed size slots are missing or non-positive, so a geom + * whose size is inherited from a default class keeps its current scale + * instead of collapsing to zero. Primitive subclasses override this with + * their size-to-scale mapping. + */ + virtual void SyncEditorScaleFromSize() {} + /** @brief Sets visibility for this geom and its child visual components. */ virtual void SetGeomVisibility(bool bNewVisibility); @@ -299,6 +308,10 @@ class URLAB_API UMjGeom : public UMjComponent UPROPERTY() bool bWasImported = false; + /** One-shot guard so a degenerate snapshot transform warns once per geom + * instead of every frame. */ + bool m_bWarnedDegenerateXform = false; + /** @brief Name of the mesh asset if Type is mesh. */ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "MuJoCo|Geom") FString MeshName; diff --git a/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjBox.h b/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjBox.h index fff6931e..99b5ebb4 100644 --- a/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjBox.h +++ b/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjBox.h @@ -55,6 +55,8 @@ class URLAB_API UMjBox : public UMjGeom virtual void ExportTo(mjsGeom* Element, mjsDefault* def = nullptr) override; virtual void SyncUnrealTransformFromMj() override; + + virtual void SyncEditorScaleFromSize() override; virtual void SetGeomVisibility(bool bNewVisibility) override; virtual class UStaticMeshComponent* GetVisualizerMesh() const override diff --git a/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjCapsule.h b/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjCapsule.h index 0197473a..6166e5ec 100644 --- a/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjCapsule.h +++ b/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjCapsule.h @@ -84,6 +84,8 @@ class URLAB_API UMjCapsule : public UMjGeom virtual void ExportTo(mjsGeom* Element, mjsDefault* def = nullptr) override; virtual void SyncUnrealTransformFromMj() override; + + virtual void SyncEditorScaleFromSize() override; virtual void SetGeomVisibility(bool bNewVisibility) override; #if WITH_EDITOR diff --git a/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjCylinder.h b/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjCylinder.h index a2b94fad..8ec0a730 100644 --- a/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjCylinder.h +++ b/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjCylinder.h @@ -59,6 +59,8 @@ class URLAB_API UMjCylinder : public UMjGeom virtual void ExportTo(mjsGeom* Element, mjsDefault* def = nullptr) override; virtual void SyncUnrealTransformFromMj() override; + + virtual void SyncEditorScaleFromSize() override; virtual void SetGeomVisibility(bool bNewVisibility) override; #if WITH_EDITOR diff --git a/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjSphere.h b/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjSphere.h index b6dcc21b..10dcddb3 100644 --- a/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjSphere.h +++ b/Source/URLab/Public/MuJoCo/Components/Geometry/Primitives/MjSphere.h @@ -55,6 +55,8 @@ class URLAB_API UMjSphere : public UMjGeom virtual void ExportTo(mjsGeom* Element, mjsDefault* def = nullptr) override; virtual void SyncUnrealTransformFromMj() override; + + virtual void SyncEditorScaleFromSize() override; virtual void SetGeomVisibility(bool bNewVisibility) override; #if WITH_EDITOR diff --git a/Source/URLabEditor/Private/MjPythonHelper.cpp b/Source/URLabEditor/Private/MjPythonHelper.cpp index d84ba66c..a0729849 100644 --- a/Source/URLabEditor/Private/MjPythonHelper.cpp +++ b/Source/URLabEditor/Private/MjPythonHelper.cpp @@ -141,7 +141,7 @@ bool FMjPythonHelper::CheckPythonPackages(const FString& PythonPath) FString StdOut, StdErr; // PIL is a transitive trimesh dep that only fires on textured OBJ // loads (e.g. menagerie unitree_go2). Check explicitly. - FPlatformProcess::ExecProcess(*PythonPath, TEXT("-c \"import trimesh; import numpy; import scipy; import PIL\""), &ReturnCode, &StdOut, &StdErr); + FPlatformProcess::ExecProcess(*PythonPath, TEXT("-c \"import trimesh; import numpy; import scipy; import networkx; import PIL\""), &ReturnCode, &StdOut, &StdErr); return (ReturnCode == 0); } @@ -159,7 +159,7 @@ bool FMjPythonHelper::InstallPythonPackages(const FString& PythonPath, FString& TEXT("Please install pip first, or choose a different Python interpreter.\n") TEXT("You can also install packages manually:\n") TEXT(" %s -m ensurepip\n") - TEXT(" %s -m pip install trimesh numpy scipy Pillow"), + TEXT(" %s -m pip install trimesh numpy scipy networkx Pillow"), *PythonPath, *PythonPath); UE_LOG(LogURLabEditor, Warning, TEXT("[Python] pip not available: %s"), *PipErr); return false; @@ -168,8 +168,8 @@ bool FMjPythonHelper::InstallPythonPackages(const FString& PythonPath, FString& int32 ReturnCode = -1; FString StdOut, StdErr; - UE_LOG(LogURLabEditor, Log, TEXT("[Python] Installing packages: %s -m pip install trimesh numpy scipy Pillow"), *PythonPath); - FPlatformProcess::ExecProcess(*PythonPath, TEXT("-m pip install trimesh numpy scipy Pillow"), &ReturnCode, &StdOut, &StdErr); + UE_LOG(LogURLabEditor, Log, TEXT("[Python] Installing packages: %s -m pip install trimesh numpy scipy networkx Pillow"), *PythonPath); + FPlatformProcess::ExecProcess(*PythonPath, TEXT("-m pip install trimesh numpy scipy networkx Pillow"), &ReturnCode, &StdOut, &StdErr); OutLog = StdOut + TEXT("\n") + StdErr; if (ReturnCode == 0) { @@ -249,7 +249,7 @@ FString FMjPythonHelper::EnsurePythonReady(bool& bOutCancelled) else { MessageStr = FString::Printf( - TEXT("URLab needs the 'trimesh', 'numpy', 'scipy', and 'Pillow' Python packages to preprocess mesh files.\n\n") + TEXT("URLab needs the 'trimesh', 'numpy', 'scipy', 'networkx', and 'Pillow' Python packages to preprocess mesh files.\n\n") TEXT("Unreal Engine does not natively support all mesh formats used by MuJoCo, ") TEXT("so these packages are used to convert and prepare meshes for import.\n\n") TEXT("These will be installed to %s.\n\n") @@ -257,7 +257,7 @@ FString FMjPythonHelper::EnsurePythonReady(bool& bOutCancelled) TEXT("Note: The editor will be unresponsive during installation. ") TEXT("This may take a minute.\n\n") TEXT("Alternatively, you can install these manually in your preferred Python environment:\n") - TEXT(" -m pip install trimesh numpy scipy Pillow\n") + TEXT(" -m pip install trimesh numpy scipy networkx Pillow\n") TEXT("Then set the path in Config/LocalUnrealRoboticsLab.ini in the plugin directory.\n\n") TEXT("Click 'Yes' to install, 'No' to choose a different interpreter, ") TEXT("or 'Cancel' to cancel the import."), @@ -293,7 +293,7 @@ FString FMjPythonHelper::EnsurePythonReady(bool& bOutCancelled) // Ask to install for the new Python FText InstallMsg = FText::FromString(FString::Printf( - TEXT("Install 'trimesh', 'numpy', 'scipy', and 'Pillow' to:\n%s?\n\n") + TEXT("Install 'trimesh', 'numpy', 'scipy', 'networkx', and 'Pillow' to:\n%s?\n\n") TEXT("The editor will be unresponsive during installation.\n\n") TEXT("Click 'Cancel' to cancel the import."), *PythonPath)); @@ -313,7 +313,7 @@ FString FMjPythonHelper::EnsurePythonReady(bool& bOutCancelled) FMessageDialog::Open(EAppMsgType::Ok, FText::FromString(FString::Printf( TEXT("Failed to install packages. You can install them manually by running:\n\n") - TEXT("%s -m pip install trimesh numpy scipy Pillow\n\n") + TEXT("%s -m pip install trimesh numpy scipy networkx Pillow\n\n") TEXT("Error log:\n%s"), *PythonPath, *InstallLog)), FText::FromString(TEXT("Package Install Failed"))); diff --git a/Source/URLabEditor/Private/MujocoXmlParser.cpp b/Source/URLabEditor/Private/MujocoXmlParser.cpp index fccc4a12..f86f74a2 100644 --- a/Source/URLabEditor/Private/MujocoXmlParser.cpp +++ b/Source/URLabEditor/Private/MujocoXmlParser.cpp @@ -108,6 +108,9 @@ #include "MuJoCo/Components/Geometry/Primitives/MjSphere.h" #include "MuJoCo/Components/Geometry/Primitives/MjCylinder.h" #include "MuJoCo/Components/Geometry/Primitives/MjCapsule.h" +#include "MuJoCo/Components/Geometry/Primitives/MjEllipsoid.h" +#include "MuJoCo/Components/Geometry/Primitives/MjPlane.h" +#include "MuJoCo/Components/Geometry/Primitives/MjSdf.h" #include "MuJoCo/Components/Geometry/MjMeshGeom.h" #include "MuJoCo/Components/Physics/MjInertial.h" #include "MuJoCo/Components/Constraints/MjEquality.h" @@ -480,9 +483,20 @@ void UMujocoGenerationAction::ImportNodeRecursive(const FXmlNode* Node, USCS_Nod } } + // MJCF's global default geom type is sphere. Without this, a bare + // (no type anywhere in its class chain) lands on + // the abstract-ish base UMjGeom and gets no primitive renderer. + // Geom templates inside blocks are exempt: primitive + // subclasses force bOverride_Type, which would bake type=sphere + // into the default class and clobber inheritance. + if (TypeStr.IsEmpty() && !bIsDefaultContext) + { + TypeStr = TEXT("sphere"); + } + if (Name.IsEmpty()) { - FString GeomTypeName = TypeStr.IsEmpty() ? TEXT("Sphere") : TypeStr; + FString GeomTypeName = TypeStr; GeomTypeName[0] = FChar::ToUpper(GeomTypeName[0]); Name = TEXT("Geom_") + GeomTypeName; } @@ -495,6 +509,12 @@ void UMujocoGenerationAction::ImportNodeRecursive(const FXmlNode* Node, USCS_Nod Class = UMjCylinder::StaticClass(); else if (TypeStr == "capsule") Class = UMjCapsule::StaticClass(); + else if (TypeStr == "ellipsoid") + Class = UMjEllipsoid::StaticClass(); + else if (TypeStr == "plane") + Class = UMjPlane::StaticClass(); + else if (TypeStr == "sdf") + Class = UMjSdf::StaticClass(); else if (TypeStr == "mesh") Class = UMjMeshGeom::StaticClass(); @@ -530,6 +550,66 @@ void UMujocoGenerationAction::ImportNodeRecursive(const FXmlNode* Node, USCS_Nod } } + // Resolve `size` through the default-class chain for the editor + // visual. A geom that inherits size from its class imports with an + // empty (or sentinel-holed) size array; the primitive subclasses + // would otherwise bake a zero RelativeScale3D into the component + // template — the source of the "Scale3D is (nearly) zero" physics + // warnings and NIL render matrices. bOverride_size stays false so + // the MuJoCo compile still resolves size through class inheritance. + { + const bool bSizeIncomplete = + GeomComp->size.Num() == 0 || GeomComp->size.Contains(-1.0f); + FString SearchClassName = Node->GetAttribute(TEXT("class")); + if (SearchClassName.IsEmpty() && ParentNode) + { + if (UMjBody* ParentBody = Cast(ParentNode->ComponentTemplate)) + SearchClassName = ParentBody->childclass; + } + if (SearchClassName.IsEmpty()) + SearchClassName = TEXT("main"); + + while (bSizeIncomplete && !SearchClassName.IsEmpty() + && CreatedDefaultNodes.Contains(SearchClassName)) + { + USCS_Node* DefNode = CreatedDefaultNodes[SearchClassName]; + if (!DefNode) + break; + + const UMjGeom* DefGeom = nullptr; + for (USCS_Node* DefChild : DefNode->GetChildNodes()) + { + DefGeom = Cast(DefChild->ComponentTemplate); + if (DefGeom) + break; + } + if (DefGeom && DefGeom->size.Num() > 0) + { + if (GeomComp->size.Num() == 0) + { + GeomComp->size = DefGeom->size; + } + else + { + for (int32 i = 0; i < GeomComp->size.Num(); ++i) + { + if (GeomComp->size[i] < 0.0f && DefGeom->size.Num() > i) + GeomComp->size[i] = DefGeom->size[i]; + } + } + break; + } + + UMjDefault* DefComp = Cast(DefNode->ComponentTemplate); + if (DefComp && !DefComp->ParentClassName.IsEmpty()) + SearchClassName = DefComp->ParentClassName; + else + break; + } + + GeomComp->SyncEditorScaleFromSize(); + } + // Resolve default class transform for visual mesh placement. // Walk the default class hierarchy (child -> parent -> ... -> main) to find // the first default geom with a transform override. diff --git a/Source/URLabEditor/Private/Tests/MjImportTests.cpp b/Source/URLabEditor/Private/Tests/MjImportTests.cpp index 1b6bddbd..b1839b33 100644 --- a/Source/URLabEditor/Private/Tests/MjImportTests.cpp +++ b/Source/URLabEditor/Private/Tests/MjImportTests.cpp @@ -46,6 +46,8 @@ // Component types for FindTemplate<> #include "MuJoCo/Components/Bodies/MjBody.h" #include "MuJoCo/Components/Geometry/MjGeom.h" +#include "MuJoCo/Components/Geometry/Primitives/MjSphere.h" +#include "MuJoCo/Components/Geometry/Primitives/MjPlane.h" #include "MuJoCo/Components/Joints/MjJoint.h" #include "MuJoCo/Components/Sensors/MjSensor.h" #include "MuJoCo/Components/Sensors/MjJointPosSensor.h" @@ -426,6 +428,127 @@ bool FTest_MjImport_URLab_BodyIdentityQuat::RunTest(const FString&) return true; } +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTest_MjImport_URLab_TypelessGeomIsSphere, + "URLab.Import.URLab_TypelessGeomIsSphere", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) +bool FTest_MjImport_URLab_TypelessGeomIsSphere::RunTest(const FString&) +{ + // MJCF's global default geom type is sphere: a bare + // must import as UMjSphere with a real (non-zero) editor scale, not as + // the base UMjGeom with no renderer. + FMjXmlImportSession S; + if (!S.Init(TEXT(R"( + + + + + + + + )"))) + { + AddError(S.LastError); + return false; + } + + UMjSphere* G = S.FindTemplate(TEXT("g1")); + if (!G) + { + AddError(TEXT("typeless geom 'g1' did not import as UMjSphere")); + S.Cleanup(); + return false; + } + + const FVector Scale = G->GetRelativeScale3D(); + TestNearlyEqual(TEXT("scale.X = radius*2 (m->UE units)"), (float)Scale.X, 0.01f, 1e-4f); + TestTrue(TEXT("uniform scale"), Scale.AllComponentsEqual(1e-6f)); + + S.Cleanup(); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTest_MjImport_URLab_ClassInheritedSizeScale, + "URLab.Import.URLab_ClassInheritedSizeScale", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) +bool FTest_MjImport_URLab_ClassInheritedSizeScale::RunTest(const FString&) +{ + // A geom whose size comes entirely from its default class must not bake a + // zero RelativeScale3D into the component template (the source of the + // "Scale3D is (nearly) zero" warnings and NIL render matrices), and must + // keep bOverride_size=false so compile-time inheritance still applies. + FMjXmlImportSession S; + if (!S.Init(TEXT(R"( + + + + + + + + + + + + + )"))) + { + AddError(S.LastError); + return false; + } + + UMjSphere* G = S.FindTemplate(TEXT("g1")); + if (!G) + { + AddError(TEXT("class-typed geom 'g1' did not import as UMjSphere")); + S.Cleanup(); + return false; + } + + const FVector Scale = G->GetRelativeScale3D(); + TestNearlyEqual(TEXT("scale.X from class size 0.06"), (float)Scale.X, 0.12f, 1e-4f); + TestFalse(TEXT("size stays class-inherited (no explicit override)"), G->bOverride_size); + + S.Cleanup(); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTest_MjImport_URLab_PlaneGeomClass, + "URLab.Import.URLab_PlaneGeomClass", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) +bool FTest_MjImport_URLab_PlaneGeomClass::RunTest(const FString&) +{ + // type="plane" must map to UMjPlane (it previously fell through to the + // base UMjGeom). A MuJoCo plane's size can legitimately be "0 0 s" + // (infinite extent), which must not zero the component scale. + FMjXmlImportSession S; + if (!S.Init(TEXT(R"( + + + + + + )"))) + { + AddError(S.LastError); + return false; + } + + UMjPlane* G = S.FindTemplate(TEXT("floor")); + if (!G) + { + AddError(TEXT("plane geom 'floor' did not import as UMjPlane")); + S.Cleanup(); + return false; + } + + const FVector Scale = G->GetRelativeScale3D(); + TestTrue(TEXT("plane scale not degenerate"), + FMath::Min3(Scale.X, Scale.Y, Scale.Z) > 1e-4); + + S.Cleanup(); + return true; +} + IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTest_MjImport_URLab_GeomFriction, "URLab.Import.URLab_GeomFriction", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) diff --git a/Source/URLabEditor/Public/MjPythonHelper.h b/Source/URLabEditor/Public/MjPythonHelper.h index 4e2d4e4b..b7112646 100644 --- a/Source/URLabEditor/Public/MjPythonHelper.h +++ b/Source/URLabEditor/Public/MjPythonHelper.h @@ -53,10 +53,10 @@ class FMjPythonHelper /** @brief Validate a Python binary by running --version. */ static bool ValidatePythonBinary(const FString& PythonPath); - /** @brief Check if trimesh, numpy, scipy, and PIL (Pillow) are importable. */ + /** @brief Check if trimesh, numpy, scipy, networkx, and PIL (Pillow) are importable. */ static bool CheckPythonPackages(const FString& PythonPath); - /** @brief Run pip install trimesh numpy scipy Pillow. Returns true on success. */ + /** @brief Run pip install trimesh numpy scipy networkx Pillow. Returns true on success. */ static bool InstallPythonPackages(const FString& PythonPath, FString& OutLog); /**