Skip to content
Merged
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
47 changes: 40 additions & 7 deletions Scripts/clean_meshes.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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])
Expand All@@ -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}")
Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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}")

Expand DownExpand Up@@ -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
Expand All@@ -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"
Expand Down
21 changes: 21 additions & 0 deletions Source/URLab/Private/MuJoCo/Components/Bodies/MjBody.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
19 changes: 19 additions & 0 deletions Source/URLab/Private/MuJoCo/Components/Geometry/MjGeom.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -488,6 +488,25 @@ void UMjGeom::UpdateGlobalTransform()
mju_mat2Quat(quat, const_cast<mjtNum*>(&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);
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand All@@ -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;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
4 changes: 4 additions & 0 deletions Source/URLab/Public/MuJoCo/Components/Bodies/MjBody.h
Original file line numberDiff line numberDiff line change
Expand Up@@ -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()
Expand Down
13 changes: 13 additions & 0 deletions Source/URLab/Public/MuJoCo/Components/Geometry/MjGeom.h
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);

Expand DownExpand Up@@ -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;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading