Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
135 changes: 135 additions & 0 deletions newton_usd_schemas/generatedSchema.usda
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,141 @@ class NewtonArticulationRootAPI "NewtonArticulationRootAPI" (

When disabled, this is equivalent to applying `PhysicsFilteredPairsAPI` relationships between all bodies in the articulation."""
)

uniform token newton:jointModel = "maximal" (
allowedTokens = ["maximal", "reduced"]
Comment thread
andrewkaufman marked this conversation as resolved.
Outdated
doc = """Coordinate paradigm for joints in this articulation.

"maximal": Joints constrain degrees of freedom between otherwise-free
bodies. Default.

"reduced": Multiple single-DOF joints connecting the same body pair
(stacked joints) grant degrees of freedom to bodies that would
otherwise be rigidly attached. Engines that do not support the stacked
representation may choose to merge them into a compound joint or
emit a warning. DOF ordering within compound joints is determined
by traversal order."""
)
}

class NewtonJointAPI "NewtonJointAPI" (
doc = """`NewtonJointAPI` applies on top of a `PhysicsJoint`, providing
joint configuration for solver behavior, passive dynamics, and limit spring response.

All scalar attributes broadcast uniformly to every DOF of the joint.
Angular attributes use degrees."""
)
{
float newton:armature = 0 (
Comment thread
andrewkaufman marked this conversation as resolved.
doc = """Artificial inertia added to each degree of freedom for solver stability.

In reduced-coordinate solvers this augments the joint-space mass
matrix diagonal. In maximal-coordinate solvers it regularizes the
constraint system along the joint's DOF directions.

The value is broadcast to all DOFs of the joint.

Range: [0, inf)
Units: mass * distance * distance (angular DOFs) or mass (linear DOFs)."""
limits = {
dictionary hard = {
float minimum = 0
}
}
)

float newton:damping = 0 (
doc = """Passive velocity-proportional damping applied to each DOF.

Produces a resistive effort proportional to joint velocity:
`effort = -damping * velocity`. This damping is always active,
regardless of whether limits are violated or drive targets are set.

The value is broadcast to all DOFs of the joint.

Range: [0, inf)
Units: effort * seconds / degrees (angular DOFs) or effort * seconds / distance (linear DOFs)."""
limits = {
dictionary hard = {
float minimum = 0
}
}
)

float newton:friction = 0 (
doc = """Dry (Coulomb) friction effort opposing joint motion.

A constant resistive effort that opposes the direction of motion,
independent of velocity magnitude. The joint must be moving for
this effort to apply.
Comment thread
andrewkaufman marked this conversation as resolved.
Outdated

The value is broadcast to all DOFs of the joint.

Range: [0, inf)
Units: effort."""
limits = {
dictionary hard = {
float minimum = 0
}
}
)

float newton:velocityLimit = inf (
doc = """Maximum allowable DOF velocity.

The solver clamps each DOF's velocity to the range
`[-velocityLimit, +velocityLimit]` at every time step.
A value of `inf` means no velocity clamping is applied.

The value is broadcast to all DOFs of the joint.

Range: (0, inf]
Units: degrees / seconds (angular DOFs) or distance / seconds (linear DOFs)."""
limits = {
dictionary hard = {
float minimum = 0
}
}
)

float newton:limitStiffness = -inf (
doc = """Stiffness of the joint limit spring.

When a DOF's position exceeds the range defined by the joint limits,
a restoring effort is applied: `effort = limitStiffness * penetration`,
where penetration is the signed distance beyond the limit boundary.

A value of `inf` is interpreted as a hard limit: solvers that support
both soft and hard limits enforce the limit as a rigid positional
constraint (no spring). Position-based solvers always enforce hard
Comment thread
andrewkaufman marked this conversation as resolved.
Outdated
positional constraints regardless of this attribute.

A value of `-inf` means the engine's own default stiffness is used.

The value is broadcast to all DOFs of the joint.

Range: [0, inf] when authored (sentinel `-inf` defers to engine default).
Units: effort / degrees (angular DOFs) or effort / distance (linear DOFs)."""
)

float newton:limitDamping = -inf (
doc = """Damping of the joint limit spring.

When a DOF's position exceeds the range defined by the joint limits,
a velocity-dependent dissipative effort is applied:
`effort = -limitDamping * velocity`.
Comment thread
andrewkaufman marked this conversation as resolved.
Outdated

Ignored when `limitStiffness` is `inf` (hard limit): a hard limit has
no spring to damp. Position-based solvers always enforce hard positional
constraints regardless of this attribute.

A value of `-inf` means the engine's own default damping is used.

The value is broadcast to all DOFs of the joint.

Range: [0, inf) when authored (sentinel `-inf` defers to engine default).
Units: effort * seconds / degrees (angular DOFs) or effort * seconds / distance (linear DOFs)."""
)
}

class NewtonMassAPI "NewtonMassAPI" (
Expand Down
14 changes: 14 additions & 0 deletions newton_usd_schemas/plugInfo.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@
],
"schemaKind": "singleApplyAPI"
},
"NewtonPhysicsJointAPI": {
"schemaIdentifier": "NewtonJointAPI",
"alias": {
"UsdSchemaBase": "NewtonJointAPI"
},
"autoGenerated": false,
"bases": [
"UsdAPISchemaBase"
],
"schemaKind": "singleApplyAPI",
"apiSchemaCanOnlyApplyTo": [
"PhysicsJoint"
]
},
"NewtonPhysicsMassAPI": {
"schemaIdentifier": "NewtonMassAPI",
"alias": {
Expand Down
28 changes: 28 additions & 0 deletions tests/test_articulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,34 @@ def test_self_collision_enabled(self):
self.assertTrue(attr.HasAuthoredValue())
self.assertEqual(attr.Get(), False)

def test_joint_model_default(self):
self.prim.ApplyAPI("NewtonArticulationRootAPI")
attr = self.prim.GetAttribute("newton:jointModel")
self.assertIsNotNone(attr)
self.assertFalse(attr.HasAuthoredValue())
self.assertEqual(attr.Get(), "maximal")

allowed = attr.GetMetadata("allowedTokens")
self.assertIn("maximal", allowed)
self.assertIn("reduced", allowed)
self.assertEqual(len(allowed), 2)

def test_joint_model_reduced(self):
self.prim.ApplyAPI("NewtonArticulationRootAPI")
attr = self.prim.GetAttribute("newton:jointModel")
success = attr.Set("reduced")
self.assertTrue(success)
self.assertTrue(attr.HasAuthoredValue())
self.assertEqual(attr.Get(), "reduced")

def test_joint_model_maximal(self):
self.prim.ApplyAPI("NewtonArticulationRootAPI")
attr = self.prim.GetAttribute("newton:jointModel")
success = attr.Set("maximal")
self.assertTrue(success)
self.assertTrue(attr.HasAuthoredValue())
self.assertEqual(attr.Get(), "maximal")


if __name__ == "__main__":
unittest.main()
157 changes: 157 additions & 0 deletions tests/test_joint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers
# SPDX-License-Identifier: Apache-2.0

import math
import unittest

from pxr import Plug, Usd, UsdPhysics

import newton_usd_schemas # noqa: F401

USD_HAS_LIMITS = Usd.GetVersion() >= (0, 25, 11)


class TestNewtonJointAPI(unittest.TestCase):
def setUp(self):
self.stage: Usd.Stage = Usd.Stage.CreateInMemory()
self.revolute: Usd.Prim = UsdPhysics.RevoluteJoint.Define(self.stage, "/Revolute").GetPrim()

def test_api_registered(self):
plug_type = Plug.Registry().FindTypeByName("NewtonPhysicsJointAPI")
self.assertEqual(plug_type.typeName, "NewtonPhysicsJointAPI")
schema_type = Usd.SchemaRegistry().GetSchemaTypeName("NewtonPhysicsJointAPI")
self.assertEqual(schema_type, "NewtonJointAPI")

def test_api_application(self):
self.assertFalse(self.revolute.HasAPI("NewtonJointAPI"))
self.revolute.ApplyAPI("NewtonJointAPI")
self.assertTrue(self.revolute.HasAPI("NewtonJointAPI"))

self.assertTrue(self.revolute.HasAttribute("newton:armature"))
self.assertTrue(self.revolute.HasAttribute("newton:damping"))
self.assertTrue(self.revolute.HasAttribute("newton:friction"))
self.assertTrue(self.revolute.HasAttribute("newton:velocityLimit"))
self.assertTrue(self.revolute.HasAttribute("newton:limitStiffness"))
self.assertTrue(self.revolute.HasAttribute("newton:limitDamping"))

def test_api_application_prismatic(self):
prismatic = UsdPhysics.PrismaticJoint.Define(self.stage, "/Prismatic").GetPrim()
self.assertTrue(prismatic.CanApplyAPI("NewtonJointAPI"))
prismatic.ApplyAPI("NewtonJointAPI")
self.assertTrue(prismatic.HasAPI("NewtonJointAPI"))

def test_api_application_spherical(self):
spherical = UsdPhysics.SphericalJoint.Define(self.stage, "/Spherical").GetPrim()
self.assertTrue(spherical.CanApplyAPI("NewtonJointAPI"))
spherical.ApplyAPI("NewtonJointAPI")
self.assertTrue(spherical.HasAPI("NewtonJointAPI"))

def test_api_application_d6(self):
d6 = UsdPhysics.Joint.Define(self.stage, "/D6").GetPrim()
self.assertTrue(d6.CanApplyAPI("NewtonJointAPI"))
d6.ApplyAPI("NewtonJointAPI")
self.assertTrue(d6.HasAPI("NewtonJointAPI"))

def test_api_limitations(self):
xform: Usd.Prim = self.stage.DefinePrim("/NotJoint", "Xform")
self.assertFalse(xform.CanApplyAPI("NewtonJointAPI"))

def test_armature(self):
self.revolute.ApplyAPI("NewtonJointAPI")
attr = self.revolute.GetAttribute("newton:armature")
self.assertIsNotNone(attr)
self.assertFalse(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 0.0)

success = attr.Set(0.01)
self.assertTrue(success)
self.assertTrue(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 0.01)

if USD_HAS_LIMITS:
hard = attr.GetHardLimits()
self.assertTrue(hard.IsValid())
self.assertAlmostEqual(hard.GetMinimum(), 0.0)
self.assertIsNone(hard.GetMaximum())

def test_damping(self):
self.revolute.ApplyAPI("NewtonJointAPI")
attr = self.revolute.GetAttribute("newton:damping")
self.assertIsNotNone(attr)
self.assertFalse(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 0.0)

success = attr.Set(5.0)
self.assertTrue(success)
self.assertTrue(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 5.0)

if USD_HAS_LIMITS:
hard = attr.GetHardLimits()
self.assertTrue(hard.IsValid())
self.assertAlmostEqual(hard.GetMinimum(), 0.0)
self.assertIsNone(hard.GetMaximum())

def test_friction(self):
self.revolute.ApplyAPI("NewtonJointAPI")
attr = self.revolute.GetAttribute("newton:friction")
self.assertIsNotNone(attr)
self.assertFalse(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 0.0)

success = attr.Set(0.5)
self.assertTrue(success)
self.assertTrue(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 0.5)

if USD_HAS_LIMITS:
hard = attr.GetHardLimits()
self.assertTrue(hard.IsValid())
self.assertAlmostEqual(hard.GetMinimum(), 0.0)
self.assertIsNone(hard.GetMaximum())

def test_velocity_limit(self):
self.revolute.ApplyAPI("NewtonJointAPI")
attr = self.revolute.GetAttribute("newton:velocityLimit")
self.assertIsNotNone(attr)
self.assertFalse(attr.HasAuthoredValue())
self.assertEqual(attr.Get(), math.inf)

success = attr.Set(360.0)
self.assertTrue(success)
self.assertTrue(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 360.0)

if USD_HAS_LIMITS:
hard = attr.GetHardLimits()
self.assertTrue(hard.IsValid())
self.assertAlmostEqual(hard.GetMinimum(), 0.0)
self.assertIsNone(hard.GetMaximum())

def test_limit_stiffness(self):
self.revolute.ApplyAPI("NewtonJointAPI")
attr = self.revolute.GetAttribute("newton:limitStiffness")
self.assertIsNotNone(attr)
self.assertFalse(attr.HasAuthoredValue())
self.assertEqual(attr.Get(), -math.inf)

success = attr.Set(174.5)
self.assertTrue(success)
self.assertTrue(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 174.5)

def test_limit_damping(self):
self.revolute.ApplyAPI("NewtonJointAPI")
attr = self.revolute.GetAttribute("newton:limitDamping")
self.assertIsNotNone(attr)
self.assertFalse(attr.HasAuthoredValue())
self.assertEqual(attr.Get(), -math.inf)

success = attr.Set(0.1745)
self.assertTrue(success)
self.assertTrue(attr.HasAuthoredValue())
self.assertAlmostEqual(attr.Get(), 0.1745)


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion tools/license_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
__identifier = "# SPDX-License-Identifier: Apache-2.0"
# Escape special regex characters in the copyright template
__copyright_template = re.escape(__copyright).replace(re.escape("{years}"), "{years}")
__copyright_years = __copyright_template.replace("{years}", f"(?:2025|(?:20[0-9][0-4])-{datetime.now().year})")
__copyright_years = __copyright_template.replace("{years}", f"(?:{datetime.now().year}|2025|(?:20[0-9][0-4])-{datetime.now().year})")
__copyright_regex = re.compile(f"^{__copyright_years}$")


Expand Down
Loading