Skip to content

Robotics and Asset Utilities

Helpers for actuator math, meshes and point clouds, contact aggregation, terrain generation, procedural meshes and asset download.

Actuators

Reflected inertia, RPM conversions and the Raibert heuristic that the actuator presets use to derive armature and PD gains.

Actuator math helpers (reflected inertia, RPM conversions, Raibert heuristic).

Attributes

NATURAL_FREQ module-attribute

NATURAL_FREQ = 10 * 2.0 * np.pi

Classes

Functions:

reflected_inertia

reflected_inertia(rotor_inertia: float, gear_ratio: float) -> float

Compute reflected inertia of a single-stage gearbox.

Source code in eden/utils/actuator.py
def reflected_inertia(
    rotor_inertia: float,
    gear_ratio: float,
) -> float:
    """Compute reflected inertia of a single-stage gearbox."""
    return rotor_inertia * gear_ratio**2

reflected_inertia_from_two_stage_planetary

reflected_inertia_from_two_stage_planetary(rotor_inertia: tuple[float, float, float], gear_ratio: tuple[float, float, float]) -> float

Compute reflected inertia of a two-stage planetary gearbox.

Source code in eden/utils/actuator.py
def reflected_inertia_from_two_stage_planetary(
    rotor_inertia: tuple[float, float, float],
    gear_ratio: tuple[float, float, float],
) -> float:
    """Compute reflected inertia of a two-stage planetary gearbox."""
    assert gear_ratio[0] == 1
    r1 = rotor_inertia[0] * (gear_ratio[1] * gear_ratio[2]) ** 2
    r2 = rotor_inertia[1] * gear_ratio[2] ** 2
    r3 = rotor_inertia[2]
    return r1 + r2 + r3

rpm_to_rad_per_sec

rpm_to_rad_per_sec(rpm: float) -> float

Convert revolutions per minute to radians per second.

Source code in eden/utils/actuator.py
def rpm_to_rad_per_sec(rpm: float) -> float:
    """Convert revolutions per minute to radians per second."""
    return (rpm * 2 * np.pi) / 60

rad_per_sec_to_rpm

rad_per_sec_to_rpm(rad_per_sec: float) -> float

Convert radians per second to revolutions per minute.

Source code in eden/utils/actuator.py
def rad_per_sec_to_rpm(rad_per_sec: float) -> float:
    """Convert radians per second to revolutions per minute."""
    return (rad_per_sec * 60) / (2 * np.pi)

raibert_heuristic_kp

raibert_heuristic_kp(actuator: type[ActuatorSpecOptions], natural_freq: float = NATURAL_FREQ) -> float

Compute ideal stiffness for a given actuator and natural frequency.

Source code in eden/utils/actuator.py
def raibert_heuristic_kp(actuator: type[ActuatorSpecOptions], natural_freq: float = NATURAL_FREQ) -> float:
    """Compute ideal stiffness for a given actuator and natural frequency."""
    return actuator.ARMATURE() * natural_freq**2

raibert_heuristic_kd

raibert_heuristic_kd(actuator: type[ActuatorSpecOptions], natural_freq: float = NATURAL_FREQ, damping_ratio: float = 2.0) -> float

Compute ideal damping for a given actuator and natural frequency. Defaults to over-damped damping ratio of 2.0.

Source code in eden/utils/actuator.py
def raibert_heuristic_kd(
    actuator: type[ActuatorSpecOptions], natural_freq: float = NATURAL_FREQ, damping_ratio: float = 2.0
) -> float:
    """Compute ideal damping for a given actuator and natural frequency. Defaults to over-damped damping ratio of 2.0."""
    return 2.0 * damping_ratio * actuator.ARMATURE() * natural_freq

raibert_heuristic_action_scale

raibert_heuristic_action_scale(actuator: type[ActuatorSpecOptions], natural_freq: float = NATURAL_FREQ) -> float

0.25 * effort_limit / kp, where kp is computed using the Raibert Heuristic.

Source code in eden/utils/actuator.py
def raibert_heuristic_action_scale(actuator: type[ActuatorSpecOptions], natural_freq: float = NATURAL_FREQ) -> float:
    """``0.25 * effort_limit / kp``, where ``kp`` is computed using the Raibert Heuristic."""
    return 0.25 * actuator.EFFORT_LIMIT / raibert_heuristic_kp(actuator, natural_freq)

Meshes and point clouds

Mesh helpers: link meshes, polygon sampling, support-surface extraction.

Classes

SupportData dataclass

SupportData(polygon: Polygon, transform: ndarray)

Attributes

polygon instance-attribute
polygon: Polygon
transform instance-attribute
transform: ndarray

Methods:

ExtendedSupportData dataclass

ExtendedSupportData(polygon: Polygon, transform: Tensor | None = None, clearance: float = inf, normal: Tensor | None = None, valid_mask: Tensor | None = None, sample_points: Tensor | None = None, num_sample_points: int = 0)

Bases: SupportData

Attributes

clearance class-attribute instance-attribute
clearance: float = math.inf
transform class-attribute instance-attribute
transform: Tensor | None = None
normal class-attribute instance-attribute
normal: Tensor | None = None
valid_mask class-attribute instance-attribute
valid_mask: Tensor | None = None
sample_points class-attribute instance-attribute
sample_points: Tensor | None = None
num_sample_points class-attribute instance-attribute
num_sample_points: int = 0

Methods:

Functions:

get_link_mesh(link: RigidLink, use_visual_mesh: bool = False) -> Trimesh

Get the mesh of a link.

PARAMETER DESCRIPTION
link

Link to get the mesh of

TYPE: RigidLink

use_visual_mesh

Whether to use the visual mesh or the collision mesh

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
trimesh.Trimesh:

Combined mesh of the link

Source code in eden/utils/mesh.py
def get_link_mesh(link: RigidLink, use_visual_mesh: bool = False) -> trimesh.Trimesh:
    """
    Get the mesh of a link.

    Parameters
    ----------
    link : RigidLink
        Link to get the mesh of
    use_visual_mesh : bool
        Whether to use the visual mesh or the collision mesh

    Returns
    -------
    trimesh.Trimesh:
        Combined mesh of the link
    """
    meshes = []
    if use_visual_mesh:
        geoms = link.vgeoms
    else:
        geoms = link.geoms
    for i, geom in enumerate(geoms):
        # Use init_pos and init_quat for canonical pose (for articulated objects)
        # These represent the geom's pose in the link's local frame
        geom_pos = torch.from_numpy(geom.init_pos).to(torch.float32)
        geom_quat = torch.from_numpy(geom.init_quat).to(torch.float32)
        T = trans_quat_to_T(geom_pos.unsqueeze(0), geom_quat.unsqueeze(0))
        if T.ndim == 3:
            T = T[0]  # NOTE: we use the canonical space so batch can be ignored
        mesh = geom.get_trimesh().copy()  # NOTE: avoid in-place write
        mesh.apply_transform(T)
        meshes.append(mesh)
    combined_mesh = trimesh.util.concatenate(meshes)

    return combined_mesh

rejection_sample_uniform

rejection_sample_uniform(polygon: Polygon, num_points: int, scale: ndarray, bias: ndarray) -> tuple[ndarray, int]

Random sample points within the polygon.

Only the candidates that land inside the polygon are kept, so the returned count varies.

PARAMETER DESCRIPTION
polygon

Polygon to sample from

TYPE: Polygon

num_points

Number of points to sample

TYPE: int

scale

Scale for the randomly generated points

TYPE: ndarray

bias

Bias for the randomly generated points

TYPE: ndarray

RETURNS DESCRIPTION
tuple[np.ndarray, int]:

Points sampled from the polygon and the number of points

Source code in eden/utils/mesh.py
def rejection_sample_uniform(
    polygon: shapely.geometry.Polygon,
    num_points: int,
    scale: np.ndarray,
    bias: np.ndarray,
) -> tuple[np.ndarray, int]:
    """
    Random sample points within the polygon.

    Only the candidates that land inside the polygon are kept, so the returned count varies.

    Parameters
    ----------
    polygon : shapely.geometry.Polygon
        Polygon to sample from
    num_points : int
        Number of points to sample
    scale : np.ndarray
        Scale for the randomly generated points
    bias : np.ndarray
        Bias for the randomly generated points

    Returns
    -------
    tuple[np.ndarray, int]:
        Points sampled from the polygon and the number of points
    """
    points = scale * np.random.rand(num_points, 2) + bias
    mask = shapely.contains_xy(polygon, *points.T)
    hit = points[mask]
    hit_count = len(hit)
    return hit, hit_count

rejection_sample_gaussian

rejection_sample_gaussian(polygon: Polygon, num_points: int, offset: float | ndarray = 0.0, std: float = 1.0) -> tuple[ndarray, int]

Random sample points within the polygon.

Only the candidates that land inside the polygon are kept, so the returned count varies.

PARAMETER DESCRIPTION
polygon

Polygon to sample from

TYPE: Polygon

num_points

Number of points to sample

TYPE: int

offset

Offset for the randomly generated points

TYPE: float DEFAULT: 0.0

std

Standard deviation for the randomly generated points

TYPE: float DEFAULT: 1.0

RETURNS DESCRIPTION
tuple[np.ndarray, int]:

Points sampled from the polygon and the number of points

Source code in eden/utils/mesh.py
def rejection_sample_gaussian(
    polygon: shapely.geometry.Polygon,
    num_points: int,
    offset: float | np.ndarray = 0.0,
    std: float = 1.0,
) -> tuple[np.ndarray, int]:
    """
    Random sample points within the polygon.

    Only the candidates that land inside the polygon are kept, so the returned count varies.

    Parameters
    ----------
    polygon : shapely.geometry.Polygon
        Polygon to sample from
    num_points : int
        Number of points to sample
    offset : float
        Offset for the randomly generated points
    std : float
        Standard deviation for the randomly generated points

    Returns
    -------
    tuple[np.ndarray, int]:
        Points sampled from the polygon and the number of points
    """
    points = np.random.normal(np.array(polygon.centroid.coords) + offset, std, size=(num_points, 2))
    mask = shapely.contains_xy(polygon, *points.T)
    hit = points[mask]
    hit_count = len(hit)
    return hit, hit_count

sample_polygon_grid

sample_polygon_grid(polygon: Polygon, grid_size: float, holes: Sequence[Polygon] | None = None) -> ndarray

Sample points from a polygon using a grid.

PARAMETER DESCRIPTION
polygon

Polygon to sample from

TYPE: Polygon

grid_size

Size of the grid

TYPE: float

holes

Holes in the polygon

TYPE: Sequence[Polygon] DEFAULT: None

RETURNS DESCRIPTION
np.ndarray:

Points sampled from the polygon

Source code in eden/utils/mesh.py
def sample_polygon_grid(
    polygon: shapely.geometry.Polygon,
    grid_size: float,
    holes: Sequence[shapely.geometry.Polygon] | None = None,
) -> np.ndarray:
    """
    Sample points from a polygon using a grid.

    Parameters
    ----------
    polygon : shapely.geometry.Polygon
        Polygon to sample from
    grid_size : float
        Size of the grid
    holes : Sequence[shapely.geometry.Polygon], optional
        Holes in the polygon

    Returns
    -------
    np.ndarray:
        Points sampled from the polygon
    """
    # get size of bounding box
    bounds = np.reshape(polygon.bounds, (2, 2))
    scale = np.ptp(bounds, axis=0)
    bias = bounds[0]
    nx = int(scale[0] / grid_size)
    ny = int(scale[1] / grid_size)

    polygon = _polygon_with_holes(polygon, holes)

    # generate grid points
    x = np.linspace(0, 1, nx) * scale[0] + bias[0]
    y = np.linspace(0, 1, ny) * scale[1] + bias[1]
    xv, yv = np.meshgrid(x, y)
    points = np.column_stack([xv.ravel(), yv.ravel()])
    mask = shapely.contains_xy(polygon, *points.T)
    hit = points[mask]
    return hit

sample_polygon_uniform

sample_polygon_uniform(polygon: Polygon, count: int, holes: Sequence[Polygon] | None = None, factor: float = 1.5, max_iter: int = 10) -> ndarray

Use rejection sampling to generate random points inside a polygon.

PARAMETER DESCRIPTION
polygon

Polygon that will contain points

TYPE: Polygon

count

Number of points to return

TYPE: int

holes

Holes to carve out of the polygon before sampling.

TYPE: Sequence[Polygon] DEFAULT: None

factor

Increase the initial count by this factor for rejection sampling

TYPE: float DEFAULT: 1.5

max_iter

Maximum number of extra sampling rounds after the first, so at most int(count * factor) * (max_iter + 1) candidate points are checked.

TYPE: int DEFAULT: 10

RETURNS DESCRIPTION
np.ndarray:

Random points inside polygon where n <= count

Source code in eden/utils/mesh.py
def sample_polygon_uniform(
    polygon: shapely.geometry.Polygon,
    count: int,
    holes: Sequence[shapely.geometry.Polygon] | None = None,
    factor: float = 1.5,
    max_iter: int = 10,
) -> np.ndarray:
    """Use rejection sampling to generate random points inside a polygon.

    Parameters
    ----------
    polygon : shapely.geometry.Polygon
        Polygon that will contain points
    count : int
        Number of points to return
    holes : Sequence[shapely.geometry.Polygon], optional
        Holes to carve out of the polygon before sampling.
    factor : float
        Increase the initial count by this factor for rejection sampling
    max_iter : int
        Maximum number of extra sampling rounds after the first, so at most
        ``int(count * factor) * (max_iter + 1)`` candidate points are checked.

    Returns
    -------
    np.ndarray:
        Random points inside polygon where n <= count
    """
    # get size of bounding box
    bounds = np.reshape(polygon.bounds, (2, 2))
    extents = np.ptp(bounds, axis=0)

    # how many points to check per loop iteration
    per_loop = int(count * factor)
    polygon = _polygon_with_holes(polygon, holes)

    hit, hit_count = rejection_sample_uniform(polygon, num_points=per_loop, scale=extents, bias=bounds[0])

    if hit_count >= count:
        return hit[:count]

    hits = [hit]
    # if we have to do iterations loop here slowly
    for _ in range(max_iter):
        hit, hit_count_ = rejection_sample_uniform(polygon, num_points=per_loop, scale=extents, bias=bounds[0])

        hits.append(hit)
        hit_count += hit_count_

        if hit_count > count:
            break

    # stack the hits into an (n,2) array and truncate
    return np.vstack(hits)[:count]

sample_polygon_gaussian

sample_polygon_gaussian(polygon: Polygon, count: int, holes: Sequence[Polygon] | None = None, factor: float = 1.5, max_iter: int = 10, offset: float | ndarray = 0.0, std: float = 1.0) -> ndarray

Use rejection sampling to generate Gaussian-distributed points inside a polygon.

PARAMETER DESCRIPTION
polygon

Polygon that will contain points

TYPE: Polygon

count

Number of points to return

TYPE: int

holes

Holes to carve out of the polygon before sampling.

TYPE: Sequence[Polygon] DEFAULT: None

factor

Increase the initial count by this factor for rejection sampling

TYPE: float DEFAULT: 1.5

max_iter

Maximum number of extra sampling rounds after the first, so at most int(count * factor) * (max_iter + 1) candidate points are checked.

TYPE: int DEFAULT: 10

offset

Offset added to the polygon centroid to center the Gaussian.

TYPE: float or ndarray DEFAULT: 0.0

std

Standard deviation of the Gaussian used for sampling.

TYPE: float DEFAULT: 1.0

RETURNS DESCRIPTION
np.ndarray:

Random points inside polygon where n <= count

Source code in eden/utils/mesh.py
def sample_polygon_gaussian(
    polygon: shapely.geometry.Polygon,
    count: int,
    holes: Sequence[shapely.geometry.Polygon] | None = None,
    factor: float = 1.5,
    max_iter: int = 10,
    offset: float | np.ndarray = 0.0,
    std: float = 1.0,
) -> np.ndarray:
    """Use rejection sampling to generate Gaussian-distributed points inside a polygon.

    Parameters
    ----------
    polygon : shapely.geometry.Polygon
        Polygon that will contain points
    count : int
        Number of points to return
    holes : Sequence[shapely.geometry.Polygon], optional
        Holes to carve out of the polygon before sampling.
    factor : float
        Increase the initial count by this factor for rejection sampling
    max_iter : int
        Maximum number of extra sampling rounds after the first, so at most
        ``int(count * factor) * (max_iter + 1)`` candidate points are checked.
    offset : float or np.ndarray
        Offset added to the polygon centroid to center the Gaussian.
    std : float
        Standard deviation of the Gaussian used for sampling.

    Returns
    -------
    np.ndarray:
        Random points inside polygon where n <= count
    """
    # how many points to check per loop iteration
    per_loop = int(count * factor)
    polygon = _polygon_with_holes(polygon, holes)

    hit, hit_count = rejection_sample_gaussian(polygon, num_points=per_loop, offset=offset, std=std)

    if hit_count >= count:
        return hit[:count]

    hits = [hit]
    # if we have to do iterations loop here slowly
    for _ in range(max_iter):
        hit, hit_count_ = rejection_sample_gaussian(polygon, num_points=per_loop, offset=offset, std=std)

        hits.append(hit)
        hit_count += hit_count_

        if hit_count > count:
            break

    # stack the hits into an (n,2) array and truncate
    return np.vstack(hits)[:count]

extrude_surface

extrude_surface(polygon: Polygon, polygon_transform: ndarray, mesh: Trimesh, rays_per_area: int = 100, extrude_direction: ndarray = array([0, 0, 1]), distance_above_support: float = 0.001) -> tuple[list[ndarray], list[ndarray]]

Extrudes a support polygon until collision.

PARAMETER DESCRIPTION
polygon

polygon to extrude

TYPE: Polygon

polygon_transform

4x4 transformation matrix of the polygon

TYPE: ndarray

mesh

Mesh the extrusion rays are cast against.

TYPE: Trimesh

rays_per_area

Rays per unit polygon area used to test for collisions.

TYPE: int DEFAULT: 100

extrude_direction

Direction to extrude

TYPE: ndarray DEFAULT: array([0, 0, 1])

distance_above_support

Support polyhedra are above the support polygon by this amount.

TYPE: float DEFAULT: 0.001

RETURNS DESCRIPTION
ndarray

(N, 3) origins of the rays that hit the mesh ([] when none hit).

ndarray

(N, 3) intersection points on the mesh, parallel to the origins.

Source code in eden/utils/mesh.py
def extrude_surface(
    polygon: shapely.geometry.Polygon,
    polygon_transform: np.ndarray,
    mesh: trimesh.Trimesh,
    rays_per_area: int = 100,
    extrude_direction: np.ndarray = np.array([0, 0, 1]),
    distance_above_support: float = 1e-3,
) -> tuple[list[np.ndarray], list[np.ndarray]]:
    """
    Extrudes a support polygon until collision.

    Parameters
    ----------
    polygon : shapely.geometry.Polygon
        polygon to extrude
    polygon_transform : np.ndarray
        4x4 transformation matrix of the polygon
    mesh : trimesh.Trimesh
        Mesh the extrusion rays are cast against.
    rays_per_area : int, optional
        Rays per unit polygon area used to test for collisions.
    extrude_direction : np.ndarray, optional
        Direction to extrude
    distance_above_support : float, optional
        Support polyhedra are above the support polygon by this amount.

    Returns
    -------
    np.ndarray
        ``(N, 3)`` origins of the rays that hit the mesh (``[]`` when none hit).
    np.ndarray
        ``(N, 3)`` intersection points on the mesh, parallel to the origins.
    """
    # for each support polygon, sample raycasts to determine maximum height of extrusion in direction of gravity
    num_rays = int(rays_per_area * polygon.area) + 1
    pts = sample_polygon_uniform(polygon, count=num_rays)

    if len(pts) == 0:
        return [], []

    points = np.c_[pts, np.zeros(len(pts)) + distance_above_support]
    xyz_coords = trimesh.transform_points(points=points, matrix=polygon_transform)

    intersections, ray_ids, _ = _raycasts(
        mesh=mesh,
        origins=xyz_coords,
        directions=np.array(len(pts) * [list(unit_vector(extrude_direction))]),
    )
    if len(intersections) == 0:
        return [], []

    origins = xyz_coords[ray_ids]
    return origins, intersections

get_exposed_support_surfaces

get_exposed_support_surfaces(mesh: Trimesh, local_gravity: ndarray = array([0, 0, -1]), precision: int = 3, gravity_tolerance: float = 0.1, min_area: float = 0.01, erosion_distance: float = 0.0) -> list[SupportData]

Get the polygons representing the support surface in the local gravity direction.

PARAMETER DESCRIPTION
mesh

Mesh to get the support surfaces of

TYPE: Trimesh

local_gravity

Local gravity direction

TYPE: ndarray DEFAULT: array([0, 0, -1])

precision

Precision for the polygon conversion

TYPE: int DEFAULT: 3

gravity_tolerance

Absolute tolerance on dot(facet_normal, -local_gravity) == 1

TYPE: float DEFAULT: 0.1

min_area

Minimum area for the support surfaces

TYPE: float DEFAULT: 0.01

erosion_distance

Distance to erode the support surfaces

TYPE: float DEFAULT: 0.0

RETURNS DESCRIPTION
list[SupportData]:

List of support surfaces

Source code in eden/utils/mesh.py
def get_exposed_support_surfaces(
    mesh: trimesh.Trimesh,
    local_gravity: np.ndarray = np.array([0, 0, -1]),
    precision: int = 3,
    gravity_tolerance: float = 0.1,
    min_area: float = 0.01,
    erosion_distance: float = 0.0,
) -> list[SupportData]:
    """
    Get the polygons representing the support surface in the local gravity direction.

    Parameters
    ----------
    mesh : trimesh.Trimesh
        Mesh to get the support surfaces of
    local_gravity : np.ndarray
        Local gravity direction
    precision : int
        Precision for the polygon conversion
    gravity_tolerance : float
        Absolute tolerance on ``dot(facet_normal, -local_gravity) == 1``
    min_area : float
        Minimum area for the support surfaces
    erosion_distance : float
        Distance to erode the support surfaces

    Returns
    -------
    list[SupportData]:
        List of support surfaces
    """
    support_facet_indices = []
    for idx in np.argsort(mesh.facets_area)[::-1]:
        if mesh.facets_area[idx] <= min_area:
            break
        if np.isclose(
            np.dot(unit_vector(mesh.facets_normal[idx]), (-unit_vector(local_gravity))),
            1.0,
            atol=gravity_tolerance,
        ):
            support_facet_indices.append(idx)

    support_surfaces = []
    for index in support_facet_indices:
        normal = mesh.facets_normal[index]
        origin = mesh.facets_origin[index]

        facet_T = trimesh.geometry.plane_transform(origin, normal)
        facet_T_inv = trimesh.transformations.inverse_matrix(facet_T)

        # find boundary edges for the facet
        edges = mesh.edges_sorted.reshape((-1, 6))[mesh.facets[index]].reshape((-1, 2))
        group = trimesh.grouping.group_rows(edges, require_count=1, digits=precision)
        vertices = trimesh.transform_points(mesh.vertices, facet_T)[:, :2]

        # run the polygon conversion
        polygons = trimesh.path.polygons.edges_to_polygons(edges=edges[group], vertices=vertices)

        for polygon in polygons:
            if polygon.geom_type == "MultiPolygon":
                # This can be recursive!!
                polys = list(polygon.geoms)
            else:
                polys = [polygon]

            for uneroded_poly in polys:
                # erode to avoid object on edges
                eroded_poly = uneroded_poly.buffer(-erosion_distance)

                if eroded_poly.geom_type == "MultiPolygon":
                    eroded_polys = list(eroded_poly.geoms)
                else:
                    eroded_polys = [eroded_poly]

                for poly in eroded_polys:
                    if not poly.is_empty and poly.area > min_area:
                        support_surfaces.append(
                            SupportData(
                                polygon=poly,
                                transform=facet_T_inv,
                            )
                        )
    return support_surfaces

AABB_to_polygon

AABB_to_polygon(AABB: ndarray) -> Polygon

Convert an AABB to a polygon.

PARAMETER DESCRIPTION
AABB

AABB to convert

TYPE: ndarray

RETURNS DESCRIPTION
shapely.geometry.Polygon:

Polygon representing the AABB

Source code in eden/utils/mesh.py
def AABB_to_polygon(AABB: np.ndarray) -> shapely.geometry.Polygon:
    """
    Convert an AABB to a polygon.

    Parameters
    ----------
    AABB : np.ndarray
        AABB to convert

    Returns
    -------
    shapely.geometry.Polygon:
        Polygon representing the AABB
    """
    if AABB.ndim == 3:
        AABB = AABB[0]
    min_x = AABB[0, 0]
    min_y = AABB[0, 1]
    max_x = AABB[1, 0]
    max_y = AABB[1, 1]

    coords = [
        (min_x, min_y),
        (max_x, min_y),
        (max_x, max_y),
        (min_x, max_y),
        (min_x, min_y),
    ]

    return shapely.geometry.Polygon(coords)

Point-cloud surface-sampling helpers for entities and links.

Functions:

distribute_points_proportionally

distribute_points_proportionally(n_points: int, link_areas: list[float]) -> list[int]

Distribute a total number of points among links in proportion to their surface area.

This function uses the largest remainder method (Hamilton method) to ensure the total number of distributed points is exactly n_points.

PARAMETER DESCRIPTION
n_points

The total number of points to be distributed.

TYPE: int

link_areas

A list of the surface areas of the links.

TYPE: list of float

RETURNS DESCRIPTION
list of int

The number of points to sample from each link.

RAISES DESCRIPTION
ValueError

If n_points is negative, link_areas is empty, or contains negative values.

Source code in eden/utils/pc.py
def distribute_points_proportionally(n_points: int, link_areas: list[float]) -> list[int]:
    """Distribute a total number of points among links in proportion to their surface area.

    This function uses the largest remainder method (Hamilton method) to ensure the
    total number of distributed points is exactly ``n_points``.

    Parameters
    ----------
    n_points : int
        The total number of points to be distributed.
    link_areas : list of float
        A list of the surface areas of the links.

    Returns
    -------
    list of int
        The number of points to sample from each link.

    Raises
    ------
    ValueError
        If ``n_points`` is negative, ``link_areas`` is empty, or contains negative values.
    """
    if n_points < 0:
        raise ValueError("n_points cannot be negative.")
    if not link_areas:
        raise ValueError("link_areas cannot be empty.")
    if any(area < 0 for area in link_areas):
        raise ValueError("Link areas cannot be negative.")

    areas = np.array(link_areas)
    total_area = areas.sum()

    if total_area == 0:
        # All areas zero: distribute the points as evenly as possible.
        base_points = n_points // len(areas)
        points_dist = [base_points] * len(areas)
        remainder = n_points % len(areas)
        for i in range(remainder):
            points_dist[i] += 1
        return points_dist

    # 1. Calculate the ideal (proportional) number of points for each link
    proportions = areas / total_area
    ideal_points = proportions * n_points

    # 2. Allocate the initial integer part of the points
    initial_points = ideal_points.astype(int)

    # 3. Calculate the number of remaining points to distribute
    n_remaining = n_points - initial_points.sum()

    if n_remaining > 0:
        # 4. Distribute the remaining points based on the largest remainders
        remainders = ideal_points - initial_points

        # Get the indices of the links with the largest remainders
        remainder_indices = np.argsort(remainders)[-n_remaining:]

        # Add one point to each of these links
        initial_points[remainder_indices] += 1

    assert initial_points.sum() == n_points, "The sum of n_points_per_link is not equal to n_points"

    return initial_points.tolist()

get_entity_surface_points

get_entity_surface_points(entity: RigidEntity, n_points: int = 100, radius=None, use_visual_mesh=False)

Sample surface points from every link of an entity, in each link's canonical (local) frame.

PARAMETER DESCRIPTION
entity

entity to sample points from

TYPE: RigidEntity

n_points

total number of points, split across the links in proportion to their mesh surface area

TYPE: int DEFAULT: 100

radius

minimum distance between samples (trimesh.sample.sample_surface_even); None derives it from area and count

DEFAULT: None

use_visual_mesh

whether to use the visual mesh or the collision mesh

DEFAULT: False

RETURNS DESCRIPTION
points_list

per link, its sampled surface points [n_points_per_link, 3], or None for a zero-area link. sample_surface_even may return fewer points than requested.

TYPE: list[Tensor | None]

Source code in eden/utils/pc.py
def get_entity_surface_points(
    entity: RigidEntity,
    n_points: int = 100,
    radius=None,
    use_visual_mesh=False,
):
    """Sample surface points from every link of an entity, in each link's canonical (local) frame.

    Parameters
    ----------
    entity: RigidEntity
        entity to sample points from
    n_points: int
        total number of points, split across the links in proportion to their mesh surface area
    radius: float
        minimum distance between samples (``trimesh.sample.sample_surface_even``); None derives it from area and count
    use_visual_mesh: bool
        whether to use the visual mesh or the collision mesh

    Returns
    -------
    points_list: list[torch.Tensor | None]
        per link, its sampled surface points [n_points_per_link, 3], or None for a zero-area link.
        ``sample_surface_even`` may return fewer points than requested.
    """
    meshes = []
    for link in entity.links:
        combined = get_link_mesh(link, use_visual_mesh=use_visual_mesh)
        meshes.append(combined)

    areas = [mesh.area for mesh in meshes]
    n_points_list = distribute_points_proportionally(n_points, areas)

    # sample points from the mesh with the area weight
    res = []
    for n_p, mesh in zip(n_points_list, meshes):
        if mesh.area == 0:
            res.append(None)
            continue
        points, _ = trimesh.sample.sample_surface_even(mesh, count=n_p, radius=radius)
        res.append(torch.tensor(points, dtype=gs.tc_float).to(gs.device))
    return res

update_entity_surface_points

update_entity_surface_points(entity: RigidEntity, points_list: list[Tensor])

Transform per-link canonical surface points into world space and concatenate them.

PARAMETER DESCRIPTION
entity

entity to update points from

TYPE: RigidEntity

points_list

per-link canonical points from :func:get_entity_surface_points (None entries are skipped)

TYPE: list[Tensor]

RETURNS DESCRIPTION
points

the points in world space, [B, n_points, 3] for a batched scene, [n_points, 3] otherwise

TYPE: Tensor

Source code in eden/utils/pc.py
def update_entity_surface_points(entity: RigidEntity, points_list: list[torch.Tensor]):
    """Transform per-link canonical surface points into world space and concatenate them.

    Parameters
    ----------
    entity: RigidEntity
        entity to update points from
    points_list: list[torch.Tensor | None]
        per-link canonical points from :func:`get_entity_surface_points` (None entries are skipped)

    Returns
    -------
    points: torch.Tensor
        the points in world space, [B, n_points, 3] for a batched scene, [n_points, 3] otherwise
    """
    res = []
    for link, points in zip(entity.links, points_list):
        if points is None:
            continue
        res.append(update_sampled_points(link, points))
    return torch.cat(res, dim=-2)
get_link_surface_points(link: RigidLink, n_points: int = 100, radius=None, use_visual_mesh=False)

Sample surface points from a single link, in canonical space.

PARAMETER DESCRIPTION
link

link to sample points from

TYPE: RigidLink

n_points

number of points to sample on the link

TYPE: int DEFAULT: 100

radius

minimum distance between samples (trimesh.sample.sample_surface_even); None derives it from area and count

DEFAULT: None

use_visual_mesh

whether to use the visual mesh or the collision mesh

DEFAULT: False

RETURNS DESCRIPTION
points

sampled points on the surface of the link in canonical space [n_points, 3]

TYPE: Tensor

Note

Apply the link's current pose to move the points to world space::

link_points = get_link_surface_points(link, 100)  # [n_points, 3]
update_sampled_points(link, link_points)  # [B, n_points, 3] or [n_points, 3]
Source code in eden/utils/pc.py
def get_link_surface_points(
    link: RigidLink,
    n_points: int = 100,
    radius=None,
    use_visual_mesh=False,
):
    """Sample surface points from a single link, in canonical space.

    Parameters
    ----------
    link: RigidLink
        link to sample points from
    n_points: int
        number of points to sample on the link
    radius: float
        minimum distance between samples (``trimesh.sample.sample_surface_even``); None derives it from area and count
    use_visual_mesh: bool
        whether to use the visual mesh or the collision mesh

    Returns
    -------
    points: torch.Tensor
        sampled points on the surface of the link in canonical space [n_points, 3]

    Note
    ----
    Apply the link's current pose to move the points to world space::

        link_points = get_link_surface_points(link, 100)  # [n_points, 3]
        update_sampled_points(link, link_points)  # [B, n_points, 3] or [n_points, 3]
    """
    combined = get_link_mesh(link, use_visual_mesh=use_visual_mesh)
    points, _ = trimesh.sample.sample_surface_even(combined, count=n_points, radius=radius)
    return torch.tensor(points, dtype=gs.tc_float).to(gs.device)

update_sampled_points

update_sampled_points(link: RigidLink, sampled_points)

Transform canonical sampled points into world space using the link's current pose.

PARAMETER DESCRIPTION
link

link whose current world pose is applied

TYPE: RigidLink

sampled_points

sampled points from get_link_surface_points [n_points_per_link, 3]

RETURNS DESCRIPTION
Tensor

the points in world space, [B, n_points_per_link, 3] for a batched scene, [n_points_per_link, 3] otherwise

Source code in eden/utils/pc.py
def update_sampled_points(link: RigidLink, sampled_points):
    """Transform canonical sampled points into world space using the link's current pose.

    Parameters
    ----------
    link: RigidLink
        link whose current world pose is applied
    sampled_points: torch.Tensor
        sampled points from `get_link_surface_points` [n_points_per_link, 3]

    Returns
    -------
    torch.Tensor
        the points in world space, [B, n_points_per_link, 3] for a batched scene, [n_points_per_link, 3] otherwise
    """
    # relative=False: world frame (the canonical points are transformed into world space).
    # Genesis >=1.1.1 link getters default to the user frame, which would strip the alignment.
    pos = link.get_pos(relative=False)  # B, 3 or 3
    quat = link.get_quat(relative=False)  # B, 4 or 4
    if pos.ndim == 2:
        return transform_by_trans_quat(sampled_points.unsqueeze(0), pos.unsqueeze(1), quat.unsqueeze(1))
    elif pos.ndim == 1:
        return transform_by_trans_quat(sampled_points, pos.unsqueeze(0), quat.unsqueeze(0))
    else:
        gs.raise_exception(f"Invalid input dim: {sampled_points.shape}")

chamfer_distance

chamfer_distance(pts_a: Tensor, pts_b: Tensor, valid_a: Tensor | None = None, valid_b: Tensor | None = None, *, bidirectional: bool = True, empty_distance: float = 0.0) -> Tensor

Masked, batched Chamfer distance between two point clouds.

PARAMETER DESCRIPTION
pts_a

(B, N_a, 3) and (B, N_b, 3). B may be any leading batch shape — the last two dims are points × xyz — but the leading shapes must match exactly with no broadcasting (pts_a.shape[:-2] == pts_b.shape[:-2]; mismatch raises ValueError). N_a and N_b are independent.

TYPE: Tensor

pts_b

(B, N_a, 3) and (B, N_b, 3). B may be any leading batch shape — the last two dims are points × xyz — but the leading shapes must match exactly with no broadcasting (pts_a.shape[:-2] == pts_b.shape[:-2]; mismatch raises ValueError). N_a and N_b are independent.

TYPE: Tensor

valid_a

(B, N_a) / (B, N_b) bool masks selecting which points to include. None is equivalent to all-True.

TYPE: Tensor | None DEFAULT: None

valid_b

(B, N_a) / (B, N_b) bool masks selecting which points to include. None is equivalent to all-True.

TYPE: Tensor | None DEFAULT: None

bidirectional

True (default) returns (forward + backward) / 2. False returns the forward-only mean (a → nearest b).

TYPE: bool DEFAULT: True

empty_distance

Returned per-direction when one side has zero valid points and the other is non-empty. 0.0 (default) treats the asymmetric case as "no signal"; set to a positive sentinel (e.g. 100.0 to mirror the DexMachina contact reward kernel) when missing data should translate into a maximum penalty.

TYPE: float DEFAULT: 0.0

RETURNS DESCRIPTION
Tensor with shape ``pts_a.shape[:-2]`` — i.e. the leading batch
dimensions of the inputs are preserved (a 1-D batch yields a
``(B,)`` output, a 2-D batch yields ``(B1, B2)``, etc.). Both-empty
samples are always ``0.0``; one-empty samples take
``empty_distance``; otherwise the standard masked Chamfer.
Notes

Uses Euclidean distance (p=2) — not squared, matching the DexMachina formulation in reward_utils.chamfer_distance. Per-side means divide by the number of valid points (not the cloud capacity), so reductions stay invariant to padding width.

Source code in eden/utils/pc.py
def chamfer_distance(
    pts_a: torch.Tensor,
    pts_b: torch.Tensor,
    valid_a: torch.Tensor | None = None,
    valid_b: torch.Tensor | None = None,
    *,
    bidirectional: bool = True,
    empty_distance: float = 0.0,
) -> torch.Tensor:
    """Masked, batched Chamfer distance between two point clouds.

    Parameters
    ----------
    pts_a, pts_b
        ``(B, N_a, 3)`` and ``(B, N_b, 3)``. ``B`` may be any leading
        batch shape — the last two dims are points × xyz — but the
        leading shapes must match **exactly** with no broadcasting
        (``pts_a.shape[:-2] == pts_b.shape[:-2]``; mismatch raises
        ``ValueError``). ``N_a`` and ``N_b`` are independent.
    valid_a, valid_b
        ``(B, N_a)`` / ``(B, N_b)`` bool masks selecting which points to
        include. ``None`` is equivalent to all-True.
    bidirectional
        ``True`` (default) returns ``(forward + backward) / 2``. ``False``
        returns the forward-only mean (``a → nearest b``).
    empty_distance
        Returned per-direction when one side has zero valid points and the
        other is non-empty. ``0.0`` (default) treats the asymmetric case as
        "no signal"; set to a positive sentinel (e.g. ``100.0`` to mirror
        the DexMachina contact reward kernel) when missing data should
        translate into a maximum penalty.

    Returns
    -------
    Tensor with shape ``pts_a.shape[:-2]`` — i.e. the leading batch
    dimensions of the inputs are preserved (a 1-D batch yields a
    ``(B,)`` output, a 2-D batch yields ``(B1, B2)``, etc.). Both-empty
    samples are always ``0.0``; one-empty samples take
    ``empty_distance``; otherwise the standard masked Chamfer.

    Notes
    -----
    Uses Euclidean distance (``p=2``) — not squared, matching the
    DexMachina formulation in ``reward_utils.chamfer_distance``. Per-side
    means divide by the number of valid points (not the cloud capacity),
    so reductions stay invariant to padding width.
    """
    if pts_a.shape[-1] != 3 or pts_b.shape[-1] != 3:
        raise ValueError(f"chamfer_distance expects xyz tail dim, got {pts_a.shape=} {pts_b.shape=}")
    if pts_a.shape[:-2] != pts_b.shape[:-2]:
        raise ValueError(f"batch shapes must match, got {pts_a.shape[:-2]=} {pts_b.shape[:-2]=}")

    if valid_a is None:
        valid_a = pts_a.new_ones(pts_a.shape[:-1], dtype=torch.bool)
    if valid_b is None:
        valid_b = pts_b.new_ones(pts_b.shape[:-1], dtype=torch.bool)
    # Masks on the points' device as bool, so ``masked_fill(~valid_*)`` cannot hit a device mismatch;
    # ``valid_*_f`` is the float cast that masks the per-direction sums.
    valid_a = valid_a.to(device=pts_a.device, dtype=torch.bool)
    valid_b = valid_b.to(device=pts_b.device, dtype=torch.bool)
    valid_a_f = valid_a.to(pts_a.dtype)
    valid_b_f = valid_b.to(pts_a.dtype)
    n_a = valid_a_f.sum(dim=-1)  # (B,)
    n_b = valid_b_f.sum(dim=-1)  # (B,)

    dist_ab = torch.cdist(pts_a, pts_b, p=2.0)  # (B, N_a, N_b)
    # Mask invalid columns/rows with +inf so .min skips them. We keep the
    # original distance tensors (no in-place writes) so autograd is clean.
    big = torch.finfo(dist_ab.dtype).max
    dist_ab_masked_b = dist_ab.masked_fill(~valid_b.unsqueeze(-2), big)
    forward_min = dist_ab_masked_b.min(dim=-1).values  # (B, N_a)
    forward_sum = (forward_min * valid_a_f).sum(dim=-1)
    forward = forward_sum / n_a.clamp(min=1)
    # When b is fully empty, ``forward_min`` is all ``big`` and ``forward``
    # overflows to +inf — safe under the final asymmetric-empty ``where``,
    # but we zero it explicitly so the intermediate is finite and a future
    # contributor cannot accidentally invert the where order.
    forward = torch.where(n_b > 0, forward, torch.zeros_like(forward))

    if bidirectional:
        dist_ab_masked_a = dist_ab.masked_fill(~valid_a.unsqueeze(-1), big)
        backward_min = dist_ab_masked_a.min(dim=-2).values  # (B, N_b)
        backward_sum = (backward_min * valid_b_f).sum(dim=-1)
        backward = backward_sum / n_b.clamp(min=1)
        backward = torch.where(n_a > 0, backward, torch.zeros_like(backward))
    else:
        backward = torch.zeros_like(forward)

    out = (forward + backward) / 2.0 if bidirectional else forward
    one_zero = (n_a == 0) ^ (n_b == 0)
    if empty_distance != 0.0:
        out = torch.where(one_zero, out.new_full(out.shape, empty_distance), out)
    # Both-zero is already 0 by the per-direction guards above; no extra
    # ``where`` needed.
    return out

Procedural simulation meshes for deformable bodies, written once to Eden's cache and loaded by path.

An entity takes its mesh as a file= path, so a structured mesh a task generates (a towel sheet, a sponge lattice) is written to ~/.cache/eden/procedural_meshes/ under a name that spells out every parameter, and the path goes into the options. A second call with the same parameters reuses the file.

Tetgen meshes a box primitive into a handful of tetrahedra (nine vertices for a sponge), far too coarse to deform; :func:box_tet_lattice_file builds a regular lattice instead.

Functions:

write_atomically

write_atomically(path: str, text: str) -> None

Write text to path (creating its directory) so no reader ever sees a partial file.

Several processes (DDP ranks, parallel tests) may generate the same cached file at once: each writes its own temporary file and the rename makes whichever lands last the (identical) result.

Source code in eden/utils/procedural_meshes.py
def write_atomically(path: str, text: str) -> None:
    """Write ``text`` to ``path`` (creating its directory) so no reader ever sees a partial file.

    Several processes (DDP ranks, parallel tests) may generate the same cached file at once: each writes its own
    temporary file and the rename makes whichever lands last the (identical) result.
    """
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = f"{path}.{os.getpid()}.tmp"
    with open(tmp, "w") as f:
        f.write(text)
    os.replace(tmp, path)

derived_file_path

derived_file_path(subdir: str, source: str, name: str) -> str

Where Eden caches a file derived from the file source: one path per source file, named name.

A derived file (an asset with parts stripped, a mesh rescaled) often names the source's own files by absolute path, so it is keyed on the source's real path: two checkouts never share one. Put any parameter the derivation takes in name. Callers rewrite the file (with :func:write_atomically) on every call, which costs milliseconds and keeps it following both the source and the code that derives it.

Source code in eden/utils/procedural_meshes.py
def derived_file_path(subdir: str, source: str, name: str) -> str:
    """Where Eden caches a file derived from the file ``source``: one path per source file, named ``name``.

    A derived file (an asset with parts stripped, a mesh rescaled) often names the source's own files by absolute
    path, so it is keyed on the source's real path: two checkouts never share one. Put any parameter the derivation
    takes in ``name``. Callers rewrite the file (with :func:`write_atomically`) on every call, which costs
    milliseconds and keeps it following both the source and the code that derives it.
    """
    key = hashlib.sha1(os.path.realpath(source).encode()).hexdigest()[:16]
    stem, ext = os.path.splitext(name)
    return os.path.join(get_cache_dir(subdir), f"{stem}_{key}{ext}")

box_tet_lattice

box_tet_lattice(size: tuple[float, float, float], cells: tuple[int, int, int]) -> tuple[ndarray, ndarray]

Vertices (n, 3) and positively oriented tetrahedra (m, 4) of a box centred at the origin.

PARAMETER DESCRIPTION
size

Box extents along x, y, z (m).

TYPE: tuple[float, float, float]

cells

Hexahedral cells along x, y, z; each holds five tetrahedra.

TYPE: tuple[int, int, int]

Source code in eden/utils/procedural_meshes.py
def box_tet_lattice(size: tuple[float, float, float], cells: tuple[int, int, int]) -> tuple[np.ndarray, np.ndarray]:
    """Vertices ``(n, 3)`` and positively oriented tetrahedra ``(m, 4)`` of a box centred at the origin.

    Parameters
    ----------
    size : tuple[float, float, float]
        Box extents along x, y, z (m).
    cells : tuple[int, int, int]
        Hexahedral cells along x, y, z; each holds five tetrahedra.
    """
    if any(c < 1 for c in cells):
        raise ValueError(f"box_tet_lattice needs at least one cell per axis, got cells={cells}.")
    axes = [np.linspace(-0.5 * s, 0.5 * s, c + 1) for s, c in zip(size, cells)]
    verts = np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1).reshape(-1, 3)
    _, ny, nz = (c + 1 for c in cells)
    tets = []
    for i in range(cells[0]):
        for j in range(cells[1]):
            for k in range(cells[2]):
                corners = [((i + a) * ny + (j + b)) * nz + (k + c) for c in (0, 1) for b in (0, 1) for a in (0, 1)]
                for tet in _EVEN_CELL_TETS if (i + j + k) % 2 == 0 else _ODD_CELL_TETS:
                    tets.append([corners[t] for t in tet])
    tets = np.asarray(tets)
    p = verts[tets]
    volume = np.einsum("ij,ij->i", np.cross(p[:, 1] - p[:, 0], p[:, 2] - p[:, 0]), p[:, 3] - p[:, 0])
    flip = volume < 0.0
    tets[flip, 1], tets[flip, 2] = tets[flip, 2], tets[flip, 1].copy()
    return verts, tets

box_tet_lattice_file

box_tet_lattice_file(size: tuple[float, float, float], cells: tuple[int, int, int]) -> str

Path of a legacy ASCII VTK file holding :func:box_tet_lattice (a MochiElasticMaterialOptions mesh).

Source code in eden/utils/procedural_meshes.py
def box_tet_lattice_file(size: tuple[float, float, float], cells: tuple[int, int, int]) -> str:
    """Path of a legacy ASCII VTK file holding :func:`box_tet_lattice` (a ``MochiElasticMaterialOptions`` mesh)."""
    name = "box_{:g}x{:g}x{:g}_{}x{}x{}.vtk".format(*size, *cells)
    path = os.path.join(get_cache_dir("procedural_meshes"), name)
    if not os.path.exists(path):
        verts, tets = box_tet_lattice(size, cells)
        lines = ["# vtk DataFile Version 2.0", name, "ASCII", "DATASET UNSTRUCTURED_GRID"]
        lines.append(f"POINTS {len(verts)} double")
        lines += [f"{x:.9g} {y:.9g} {z:.9g}" for x, y, z in verts]
        lines.append(f"CELLS {len(tets)} {5 * len(tets)}")
        lines += [f"4 {a} {b} {c} {d}" for a, b, c, d in tets]
        lines.append(f"CELL_TYPES {len(tets)}")
        lines += ["10"] * len(tets)
        write_atomically(path, "\n".join(lines) + "\n")
    return path

grid_sheet

grid_sheet(size: tuple[float, float], cells: tuple[int, int]) -> tuple[ndarray, ndarray]

Vertices (n, 3) and consistently wound triangles (m, 3) of a flat sheet in z = 0, centred.

Source code in eden/utils/procedural_meshes.py
def grid_sheet(size: tuple[float, float], cells: tuple[int, int]) -> tuple[np.ndarray, np.ndarray]:
    """Vertices ``(n, 3)`` and consistently wound triangles ``(m, 3)`` of a flat sheet in z = 0, centred."""
    if any(c < 1 for c in cells):
        raise ValueError(f"grid_sheet needs at least one cell per axis, got cells={cells}.")
    axes = [np.linspace(-0.5 * s, 0.5 * s, c + 1) for s, c in zip(size, cells)]
    grid = np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1).reshape(-1, 2)
    verts = np.concatenate([grid, np.zeros((len(grid), 1))], axis=1)
    ny = cells[1] + 1
    faces = []
    for i in range(cells[0]):
        for j in range(cells[1]):
            a = i * ny + j
            faces.append([a, a + ny, a + ny + 1])
            faces.append([a, a + ny + 1, a + 1])
    return verts, np.asarray(faces)

grid_sheet_file

grid_sheet_file(size: tuple[float, float], cells: tuple[int, int]) -> str

Path of a Wavefront OBJ holding :func:grid_sheet (a MochiShellMaterialOptions mesh).

Source code in eden/utils/procedural_meshes.py
def grid_sheet_file(size: tuple[float, float], cells: tuple[int, int]) -> str:
    """Path of a Wavefront OBJ holding :func:`grid_sheet` (a ``MochiShellMaterialOptions`` mesh)."""
    name = "sheet_{:g}x{:g}_{}x{}.obj".format(*size, *cells)
    path = os.path.join(get_cache_dir("procedural_meshes"), name)
    if not os.path.exists(path):
        verts, faces = grid_sheet(size, cells)
        lines = [f"v {x:.9g} {y:.9g} {z:.9g}" for x, y, z in verts]
        lines += [f"f {a + 1} {b + 1} {c + 1}" for a, b, c in faces]
        write_atomically(path, "\n".join(lines) + "\n")
    return path

swept_tube

swept_tube(path: ndarray, radius: float, sides: int = 24) -> tuple[ndarray, ndarray]

Vertices and outward-wound triangles of a closed tube of circular section swept along a planar polyline.

The ends are capped, so the mesh is watertight (a signed-distance collider needs an inside). The path must lie in a plane; the section turns with the path, one ring of sides vertices per path point, and a bend made of short segments reads as a smooth elbow.

PARAMETER DESCRIPTION
path

Points (n, 3) of the tube's centreline, n >= 2, no two consecutive ones equal.

TYPE: ndarray

radius

Section radius (m).

TYPE: float

sides

Vertices per ring.

TYPE: int DEFAULT: 24

Source code in eden/utils/procedural_meshes.py
def swept_tube(path: np.ndarray, radius: float, sides: int = 24) -> tuple[np.ndarray, np.ndarray]:
    """Vertices and outward-wound triangles of a closed tube of circular section swept along a planar polyline.

    The ends are capped, so the mesh is watertight (a signed-distance collider needs an inside). The path must
    lie in a plane; the section turns with the path, one ring of ``sides`` vertices per path point, and a bend
    made of short segments reads as a smooth elbow.

    Parameters
    ----------
    path : np.ndarray
        Points ``(n, 3)`` of the tube's centreline, ``n >= 2``, no two consecutive ones equal.
    radius : float
        Section radius (m).
    sides : int
        Vertices per ring.
    """
    path = np.asarray(path, dtype=np.float64)
    if path.ndim != 2 or path.shape[1] != 3 or len(path) < 2:
        raise ValueError(f"swept_tube needs a path of shape (n >= 2, 3), got {path.shape}.")
    segments = np.diff(path, axis=0)
    lengths = np.linalg.norm(segments, axis=1)
    if np.any(lengths <= 0.0):
        raise ValueError(f"swept_tube's path repeats a point at index {int(np.argmin(lengths))}.")
    segments /= lengths[:, None]
    # Tangent at each point: the mean of the segments meeting there, so a ring bisects the bend.
    tangents = np.concatenate([segments[:1], segments[:-1] + segments[1:], segments[-1:]])
    tangents /= np.linalg.norm(tangents, axis=1, keepdims=True)
    # The plane's normal is one fixed axis of every ring.
    normal = np.zeros(3)
    for i in range(len(segments) - 1):
        normal = np.cross(segments[i], segments[i + 1])
        if np.linalg.norm(normal) > 1e-9:
            break
    if np.linalg.norm(normal) <= 1e-9:  # a straight path: any axis across it
        normal = np.cross(segments[0], [1.0, 0.0, 0.0] if abs(segments[0][0]) < 0.9 else [0.0, 1.0, 0.0])
    normal /= np.linalg.norm(normal)
    angles = 2.0 * np.pi * np.arange(sides) / sides
    rings = []
    for point, tangent in zip(path, tangents):
        # A ring bisecting a bend of angle a is radius / cos(a / 2) across it; at the few degrees a segment of
        # an elbow turns, the tube stays round to well under a percent.
        side = np.cross(tangent, normal)
        rings.append(point + radius * (np.cos(angles)[:, None] * normal + np.sin(angles)[:, None] * side))
    verts = np.concatenate(rings + [path[:1], path[-1:]])
    faces = []
    for i in range(len(path) - 1):
        for k in range(sides):
            a, b = i * sides + k, i * sides + (k + 1) % sides
            c, d = a + sides, b + sides
            faces += [[a, c, b], [b, c, d]]
    start, end = len(path) * sides, len(path) * sides + 1
    last = (len(path) - 1) * sides
    for k in range(sides):
        faces.append([start, k, (k + 1) % sides])
        faces.append([end, last + (k + 1) % sides, last + k])
    faces = np.asarray(faces)
    # Wind outward: a closed mesh wound inward has negative signed volume.
    p = verts[faces]
    if np.einsum("ij,ij->i", p[:, 0], np.cross(p[:, 1], p[:, 2])).sum() < 0.0:
        faces = faces[:, ::-1]
    return verts, faces

swept_tube_file

swept_tube_file(name: str, path: ndarray, radius: float, sides: int = 24) -> str

Path of a Wavefront OBJ holding :func:swept_tube, cached under name (which must spell out the path).

Source code in eden/utils/procedural_meshes.py
def swept_tube_file(name: str, path: np.ndarray, radius: float, sides: int = 24) -> str:
    """Path of a Wavefront OBJ holding :func:`swept_tube`, cached under ``name`` (which must spell out the path)."""
    file = os.path.join(get_cache_dir("procedural_meshes"), f"tube_{name}_r{radius:g}_{sides}.obj")
    if not os.path.exists(file):
        verts, faces = swept_tube(path, radius, sides)
        lines = [f"v {x:.9g} {y:.9g} {z:.9g}" for x, y, z in verts]
        lines += [f"f {a + 1} {b + 1} {c + 1}" for a, b, c in faces]
        write_atomically(file, "\n".join(lines) + "\n")
    return file

Contacts

The tensor backend of RigidEntity.get_grouped_contacts.

Pure-tensor contact-aggregation helpers (backend of RigidEntity.get_grouped_contacts).

These have no dependency on the entity abstraction, so they live with the other math-only helpers (next to :mod:eden.utils.pc) and can be unit-tested without importing :mod:eden.entities.rigid (which pulls in the support-surface mixin and triggers genesis.utils.array_class top-level code that requires gs.init()).

Scaling note: the (B, C, n_a, n_b) broadcast in :func:aggregate_grouped_contacts is fine at link-level pair counts (≤ ~30 per side) but must become a scatter_add before geom-level use (~900 pairs).

Functions:

resolve_local_link_idx(idx: Tensor | list[int] | None, num_links: int, device: device) -> Tensor

Normalise a local-link-index argument into a 1-D long tensor on device.

PARAMETER DESCRIPTION
idx

Local link indices; None is expanded to torch.arange(num_links).

TYPE: Tensor | list[int] | None

num_links

Exclusive upper bound for valid indices.

TYPE: int

device

Device the returned tensor must live on.

TYPE: device

Hot

Callers

should

cached

Source code in eden/utils/contacts.py
def resolve_local_link_idx(
    idx: torch.Tensor | list[int] | None,
    num_links: int,
    device: torch.device,
) -> torch.Tensor:
    """Normalise a local-link-index argument into a 1-D ``long`` tensor on ``device``.

    Parameters
    ----------
    idx : torch.Tensor | list[int] | None
        Local link indices; ``None`` is expanded to ``torch.arange(num_links)``.
    num_links : int
        Exclusive upper bound for valid indices.
    device : torch.device
        Device the returned tensor must live on.

    Hot-path note: when ``idx`` is already a 1-D ``long`` tensor on
    ``device``, this returns it directly with no copy or H2D transfer.
    Callers that hit the per-step path (reward / observation terms)
    should pre-resolve the indices once at ``build()`` time and pass the
    cached tensor each step — see the docstring on
    :meth:`RigidEntity.get_grouped_contacts`.
    """
    if idx is None:
        return torch.arange(num_links, device=device)
    if (
        isinstance(idx, torch.Tensor)
        and idx.dtype == torch.long
        and idx.device == device
        and idx.ndim == 1
        and idx.numel() > 0
    ):
        # Fast path: caller pre-resolved at build time. Skip dtype/device
        # checks (they are no-ops here) and the ``.tolist()`` round-trip
        # in the bounds error message. Bounds are verified on the first call
        # for this tensor only: reading the check back is a GPU->CPU sync, and
        # the same cached tensor comes back every step.
        # The version counter is part of the check, so an in-place write re-checks it; an inference tensor
        # tracks none and is checked on every call.
        try:
            version = idx._version
        except RuntimeError:
            version = None
        checked = _BOUNDS_CHECKED.get(id(idx))
        if checked is not None and checked[0]() is idx and checked[1:] == (num_links, version):
            return idx
        if (idx < 0).any() or (idx >= num_links).any():
            raise IndexError(f"link index out of range [0, {num_links}): {idx.tolist()}")
        if version is not None:
            _BOUNDS_CHECKED[id(idx)] = (
                weakref.ref(idx, lambda _, key=id(idx), cache=_BOUNDS_CHECKED: cache.pop(key, None)),
                num_links,
                version,
            )
        return idx
    t = idx if isinstance(idx, torch.Tensor) else torch.as_tensor(idx)
    t = t.to(device=device, dtype=torch.long)
    if t.ndim != 1:
        raise ValueError(f"link index must be 1-D, got shape {tuple(t.shape)}")
    if t.numel() == 0:
        raise ValueError("link index cannot be empty")
    if (t < 0).any() or (t >= num_links).any():
        raise IndexError(f"link index out of range [0, {num_links}): {t.tolist()}")
    return t

aggregate_grouped_contacts

aggregate_grouped_contacts(*, link_a: Tensor, link_b: Tensor, position: Tensor, force: Tensor, valid_mask: Tensor, a_global: Tensor, b_global: Tensor) -> dict[str, Tensor]

Force-norm-weighted per-link-pair contact aggregation.

PARAMETER DESCRIPTION
link_a

(B, C) global link indices of each contact's two sides, as returned by the parallelised Genesis get_contacts (env-leading).

TYPE: Tensor

link_b

(B, C) global link indices of each contact's two sides, as returned by the parallelised Genesis get_contacts (env-leading).

TYPE: Tensor

position

(B, C, 3) world-frame contact positions.

TYPE: Tensor

force

(B, C, 3) contact force vectors; only their norms are used.

TYPE: Tensor

valid_mask

(B, C) bool mask of real (non-padding) contacts.

TYPE: Tensor

a_global

1-D global link indices selecting which links populate axes 1 and 2 of the output. Must be disjoint (see Raises). Both Genesis orderings (a→A, b→B) and (a→B, b→A) are folded into the same pair so the caller never has to care which side Genesis labelled "a".

TYPE: Tensor

b_global

1-D global link indices selecting which links populate axes 1 and 2 of the output. Must be disjoint (see Raises). Both Genesis orderings (a→A, b→B) and (a→B, b→A) are folded into the same pair so the caller never has to care which side Genesis labelled "a".

TYPE: Tensor

Genesis

contact

this

RETURNS DESCRIPTION
dict with:
- ``position`` : ``(B, n_a, n_b, 3)`` — force-norm-weighted mean

contact position; zero where valid is False.

- ``valid`` : ``(B, n_a, n_b)`` bool — True iff at least one

contact pair contributed a non-zero force-norm sum (computed as force_norm_sum > 0, matching the DexMachina reference). Pairs whose contacts all carry zero force — e.g. the first frame after make/break or quasi-static initial overlap — are reported invalid because no force-weighted mean position is defined for them. valid_strict_geometry = pair_mask.any(...) is not exposed; callers needing geometric-only contact detection should bool-AND the raw get_contacts(...)["valid_mask"] themselves.

- ``force_norm_sum`` : ``(B, n_a, n_b)`` — Σ ||f_i|| over contacts

contributing to the pair (the denominator used to weight the position mean). Note this is the sum of magnitudes, not the magnitude of the summed force vector — opposing-direction contacts do not cancel here.

RAISES DESCRIPTION
ValueError

If a_global and b_global overlap (checked for CPU tensors only — on-device groups skip the check to avoid a per-step GPU sync; there disjointness is a caller-guaranteed precondition, which RigidEntity.get_grouped_contacts establishes structurally by rejecting self-contact and offsetting per-entity link ranges).

Source code in eden/utils/contacts.py
def aggregate_grouped_contacts(
    *,
    link_a: torch.Tensor,
    link_b: torch.Tensor,
    position: torch.Tensor,
    force: torch.Tensor,
    valid_mask: torch.Tensor,
    a_global: torch.Tensor,
    b_global: torch.Tensor,
) -> dict[str, torch.Tensor]:
    """Force-norm-weighted per-link-pair contact aggregation.

    Parameters
    ----------
    link_a, link_b : torch.Tensor
        ``(B, C)`` global link indices of each contact's two sides, as
        returned by the parallelised Genesis ``get_contacts`` (env-leading).
    position : torch.Tensor
        ``(B, C, 3)`` world-frame contact positions.
    force : torch.Tensor
        ``(B, C, 3)`` contact force vectors; only their norms are used.
    valid_mask : torch.Tensor
        ``(B, C)`` bool mask of real (non-padding) contacts.
    a_global, b_global : torch.Tensor
        1-D global link indices selecting which links populate axes 1 and 2
        of the output. Must be disjoint (see Raises). Both Genesis
        orderings (a→A, b→B) and (a→B, b→A) are folded into the same pair so
        the caller never has to care which side Genesis labelled "a".

    Genesis's ``get_contacts(with_entity=other)`` does **not** canonicalize
    contact-pair ordering: its ``valid_mask`` is built as
    ``(a∈self ∨ b∈self) ∧ (a∈other ∨ b∈other)``
    (see ``RigidEntity.get_contacts`` in genesis), so both orderings reach
    this function — handling both is required, not optional.

    Returns
    -------
    dict with:

    - ``position`` : ``(B, n_a, n_b, 3)`` — force-norm-weighted mean
      contact position; zero where ``valid`` is False.
    - ``valid`` : ``(B, n_a, n_b)`` bool — True iff at least one
      contact pair contributed a **non-zero** force-norm sum
      (computed as ``force_norm_sum > 0``, matching the DexMachina
      reference). Pairs whose contacts all carry zero force — e.g. the
      first frame after make/break or quasi-static initial overlap —
      are reported invalid because no force-weighted mean position is
      defined for them. ``valid_strict_geometry = pair_mask.any(...)``
      is **not** exposed; callers needing geometric-only contact
      detection should bool-AND the raw ``get_contacts(...)["valid_mask"]``
      themselves.
    - ``force_norm_sum`` : ``(B, n_a, n_b)`` — Σ ||f_i|| over contacts
      contributing to the pair (the denominator used to weight the
      position mean). Note this is the sum of magnitudes, **not** the
      magnitude of the summed force vector — opposing-direction
      contacts do not cancel here.

    Raises
    ------
    ValueError
        If ``a_global`` and ``b_global`` overlap (checked for CPU tensors
        only — on-device groups skip the check to avoid a per-step GPU sync;
        there disjointness is a caller-guaranteed precondition, which
        ``RigidEntity.get_grouped_contacts`` establishes structurally by
        rejecting self-contact and offsetting per-entity link ranges).
    """
    # Disjointness guard, CPU groups only: an `if` on a CUDA bool would force a
    # GPU->host sync every call on the per-step contact-reward path — where the
    # condition is structurally impossible anyway (RigidEntity.get_grouped_contacts
    # rejects self-contact and distinct entities have disjoint global link
    # ranges). Direct callers (tests, offline tools) typically pass CPU tensors
    # and get the loud failure.
    if a_global.device.type == "cpu" and torch.isin(a_global, b_global).any():
        raise ValueError(
            "a_global and b_global must be disjoint link-index groups — a link on "
            "both sides would double-count each of its contacts into two output "
            "cells. For self-contact queries use RigidEntity.get_contacts(with_entity=<entity>)."
        )
    la = link_a.unsqueeze(-1)  # (B, C, 1)
    lb = link_b.unsqueeze(-1)  # (B, C, 1)
    # Each match tensor below is (B, C, n_a) or (B, C, n_b); they combine into
    # the full (B, C, n_a, n_b) pair mask via outer-AND on the membership axes.
    a_in_self = (la == a_global.view(1, 1, -1)).unsqueeze(-1)  # (B, C, n_a, 1)
    b_in_other = (lb == b_global.view(1, 1, -1)).unsqueeze(-2)  # (B, C, 1,  n_b)
    a_in_other = (la == b_global.view(1, 1, -1)).unsqueeze(-2)  # (B, C, 1,  n_b)
    b_in_self = (lb == a_global.view(1, 1, -1)).unsqueeze(-1)  # (B, C, n_a, 1)
    pair_mask = (a_in_self & b_in_other) | (b_in_self & a_in_other)
    pair_mask = pair_mask & valid_mask[..., None, None]  # (B, C, n_a, n_b)

    force_norm = force.norm(dim=-1)  # (B, C)
    # Select, never multiply by the mask: the padding rows past a env's real contacts carry whatever the solver's
    # buffer held, NaN included, and NaN * 0 is NaN -- one stale slot would poison the whole pair's sum (a NaN reward
    # while the thresholded contact flags, NaN > x being False, stay finite).
    weight = torch.where(
        pair_mask,
        force_norm.unsqueeze(-1).unsqueeze(-1),
        torch.zeros((), dtype=force_norm.dtype, device=force_norm.device),
    )
    weighted_pos = torch.where(
        pair_mask.unsqueeze(-1),
        position.unsqueeze(-2).unsqueeze(-2),
        torch.zeros((), dtype=position.dtype, device=position.device),
    )
    weighted_pos_sum = (weighted_pos * weight.unsqueeze(-1)).sum(dim=1)  # (B, n_a, n_b, 3)
    force_norm_sum = weight.sum(dim=1)  # (B, n_a, n_b)
    valid = force_norm_sum > 0
    mean_pos = torch.where(
        valid.unsqueeze(-1),
        weighted_pos_sum / force_norm_sum.unsqueeze(-1).clamp(min=1e-12),
        torch.zeros_like(weighted_pos_sum),
    )
    return {"position": mean_pos, "valid": valid, "force_norm_sum": force_norm_sum}

Terrains

Terrain generators register in TERRAIN_GENERATOR_REGISTRY and compose into a TerrainOptions (entity options). See the scenes and terrains guide.

Base terrain generator and flat terrain.

Attributes

TERRAIN_GENERATOR_REGISTRY module-attribute

TERRAIN_GENERATOR_REGISTRY = Registry('TERRAIN_GENERATOR')

Classes

TerrainFields dataclass

TerrainFields(height_field: ndarray, goals: ndarray | None, goals_count: ndarray | None, slope_vectors: ndarray | None)

Outputs of a terrain-grid generation pass.

Beyond the stitched height field, goal-producing generators (the Parkour* course generators) contribute per-cell waypoint goals; HalfSlopedTerrain contributes a slope_vector. Both are optional side channels: grids built from plain generators leave them None.

Attributes

height_field instance-attribute
height_field: ndarray

(X, Y) stitched height field in vertical_scale units (dim0 = x, dim1 = y).

goals instance-attribute
goals: ndarray | None

(rows, cols, max_goals, 3) float32 — per-cell waypoints in subterrain-local meters (xy from the cell's [0, 0] corner; z from the stitched height field, in meters). Cells with fewer goals are padded by repeating their last real goal. None when no generator in the grid produced goals.

goals_count instance-attribute
goals_count: ndarray | None

(rows, cols) int64 — number of real (unpadded) goals per cell; None with goals.

slope_vectors instance-attribute
slope_vectors: ndarray | None

(rows, cols, 3) float32 — normalized slope direction per cell, zeros where a generator set none; None when no generator in the grid produced one.

Methods:

TerrainGenerator

TerrainGenerator(options: TerrainTermOptions)

Bases: ConfigurableMixin

Terrain generator for Eden, wrapping Genesis' terrain utils.

Note: Uses unparameterized ConfigurableMixin to avoid a circular import with eden.options.entities. options_class is resolved lazily.

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Methods:

configure classmethod
configure(**kwargs)
Source code in eden/utils/terrains/base.py
@classmethod
def configure(cls, **kwargs):
    if cls._options_class_ is None:
        from eden.options.entities import TerrainTermOptions

        cls._options_class_ = TerrainTermOptions
    return super().configure(**kwargs)
compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/base.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return subterrain

FlatTerrain

FlatTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Flat terrain generator.

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/base.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    subterrain.height_field_raw *= 0.0
    return subterrain

Functions:

generate_terrain_fields

generate_terrain_fields(terrain_generators: list[list[TerrainTermOptions]], subterrain_size: Vec2FType, n_subterrains: Vec2IType, horizontal_scale: float, vertical_scale: float, randomize: bool) -> TerrainFields

Generate the height field plus goal/slope side channels for the terrain grid.

terrain_generators must be a non-empty rectangular grid whose shape equals n_subterrains (rows x cols).

Source code in eden/utils/terrains/base.py
def generate_terrain_fields(
    terrain_generators: list[list[TerrainTermOptions]],
    subterrain_size: Vec2FType,
    n_subterrains: Vec2IType,
    horizontal_scale: float,
    vertical_scale: float,
    randomize: bool,
) -> TerrainFields:
    """Generate the height field plus goal/slope side channels for the terrain grid.

    ``terrain_generators`` must be a non-empty rectangular grid whose shape
    equals ``n_subterrains`` (rows x cols).
    """
    n_rows = len(terrain_generators)
    if n_rows == 0 or n_rows != n_subterrains[0] or any(len(row) != n_subterrains[1] for row in terrain_generators):
        raise ValueError(
            "terrain_generators must be a non-empty rectangular 2D list with shape "
            f"n_subterrains={tuple(n_subterrains)}; got rows of lengths "
            f"{[len(row) for row in terrain_generators]}."
        )
    subterrain_rows = int(subterrain_size[0] / horizontal_scale + gs.EPS) + 1
    subterrain_cols = int(subterrain_size[1] / horizontal_scale + gs.EPS) + 1
    heightfield = np.full(
        (
            n_subterrains[0] * (subterrain_rows - 1) + 1,
            n_subterrains[1] * (subterrain_cols - 1) + 1,
        ),
        fill_value=float("-inf"),
        dtype=gs.np_float,
    )
    cell_goals: dict[tuple[int, int], np.ndarray] = {}
    cell_slope_vectors: dict[tuple[int, int], np.ndarray] = {}

    for i, j in zip(*map(np.ravel, np.meshgrid(*map(range, n_subterrains), indexing="ij"))):
        subterrain = tu.SubTerrain(
            width=subterrain_rows,
            length=subterrain_cols,
            vertical_scale=vertical_scale,
            horizontal_scale=horizontal_scale,
        )
        if not randomize:
            saved_state = np.random.get_state()
            np.random.seed(0)

        term_option: TerrainTermOptions = terrain_generators[i][j]
        term = TERRAIN_GENERATOR_REGISTRY.get(term_option.name)(options=term_option)
        try:
            subterrain = term.compute(subterrain)
        except ValueError as e:
            # Say which cell. A generator only knows its own name and extent, but a grid is
            # usually heterogeneous — eden/tasks/extreme_parkour varies difficulty down the rows —
            # so the row and column are what point at the offending config.
            raise ValueError(f"terrain cell (row {i}, col {j}): {e}") from e
        finally:
            # compute() can raise (a parkour course that does not fit its subterrain), and the
            # seed(0) above is process-global: without the finally the caller's RNG stream would
            # be left mid-way through the seed-0 stream, silently rerolling every later draw.
            if not randomize:
                np.random.set_state(saved_state)

        data = subterrain.height_field_raw
        subterrain_heightfield = heightfield[
            i * (subterrain_rows - 1) : (i + 1) * (subterrain_rows - 1) + 1,
            j * (subterrain_cols - 1) : (j + 1) * (subterrain_cols - 1) + 1,
        ]
        subterrain_heightfield[:] = np.maximum(subterrain_heightfield, data)

        goals = getattr(subterrain, "goals", None)
        if goals is not None:
            cell_goals[(int(i), int(j))] = np.asarray(goals, dtype=np.float32)
        slope_vector = getattr(subterrain, "slope_vector", None)
        if slope_vector is not None:
            cell_slope_vectors[(int(i), int(j))] = np.asarray(slope_vector, dtype=np.float32)

    return TerrainFields(
        height_field=heightfield,
        **_assemble_goals(cell_goals, terrain_generators, n_subterrains, heightfield, horizontal_scale, vertical_scale),
        slope_vectors=_assemble_slope_vectors(cell_slope_vectors, n_subterrains),
    )

generate_height_field

generate_height_field(terrain_generators: list[list[TerrainTermOptions]], subterrain_size: Vec2FType, n_subterrains: Vec2IType, horizontal_scale: float, vertical_scale: float, randomize: bool) -> ndarray

Generate only the stitched height field (see :func:generate_terrain_fields).

Source code in eden/utils/terrains/base.py
def generate_height_field(
    terrain_generators: list[list[TerrainTermOptions]],
    subterrain_size: Vec2FType,
    n_subterrains: Vec2IType,
    horizontal_scale: float,
    vertical_scale: float,
    randomize: bool,
) -> np.ndarray:
    """Generate only the stitched height field (see :func:`generate_terrain_fields`)."""
    return generate_terrain_fields(
        terrain_generators=terrain_generators,
        subterrain_size=subterrain_size,
        n_subterrains=n_subterrains,
        horizontal_scale=horizontal_scale,
        vertical_scale=vertical_scale,
        randomize=randomize,
    ).height_field

Height-field terrain generators (fractal, sloped, obstacles, ...).

Attributes

Classes

FractalTerrain

FractalTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Fractal terrain generator.

PARAMETER DESCRIPTION
levels

Granularity of the fractal terrain. Defaults to 8.

TYPE: int

scale

Scales vertical variation. Defaults to 1.0.

TYPE: float

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

levels class-attribute instance-attribute
levels: int = 8
scale class-attribute instance-attribute
scale: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.fractal_terrain(
        subterrain,
        levels=self.levels,
        scale=self.scale,
    )

RandomUniformTerrain

RandomUniformTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

min_height class-attribute instance-attribute
min_height: float = -0.1
max_height class-attribute instance-attribute
max_height: float = 0.1
step class-attribute instance-attribute
step: float = 0.1
downsampled_scale class-attribute instance-attribute
downsampled_scale: float = 0.5

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.random_uniform_terrain(
        subterrain,
        min_height=self.min_height,
        max_height=self.max_height,
        step=self.step,
        downsampled_scale=self.downsampled_scale,
    )

SlopedTerrain

SlopedTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

slope class-attribute instance-attribute
slope: float = -0.5

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.sloped_terrain(subterrain, slope=self.slope)

PyramidSlopedTerrain

PyramidSlopedTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

slope class-attribute instance-attribute
slope: float = -0.1

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.pyramid_sloped_terrain(subterrain, slope=self.slope)

DiscreteObstaclesTerrain

DiscreteObstaclesTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

max_height class-attribute instance-attribute
max_height: float = 0.05
min_size class-attribute instance-attribute
min_size: float = 1.0
max_size class-attribute instance-attribute
max_size: float = 5.0
num_rects class-attribute instance-attribute
num_rects: int = 20

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.discrete_obstacles_terrain(
        subterrain,
        max_height=self.max_height,
        min_size=self.min_size,
        max_size=self.max_size,
        num_rects=self.num_rects,
    )

WaveTerrain

WaveTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Sinusoidal waves along BOTH axes — not a one-dimensional corrugation.

The height field is (amplitude / 2) * (cos(y / div) + sin(x / div)) (isaacgym-legacy formula), so the surface undulates along x and y with peak deviation of about amplitude meters, and there are side-slopes everywhere — a robot traversing along +x is continuously perturbed laterally, so a consistent sideways drift on this terrain is geometry, not a policy or porting error. div is derived from the subterrain length only (length / (num_waves * 2 * pi)) and applied to both axes, so on a non-square subterrain the wave count along x differs from num_waves.

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

num_waves class-attribute instance-attribute
num_waves: float = 2.0
amplitude class-attribute instance-attribute
amplitude: float = 0.1

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.wave_terrain(
        subterrain,
        num_waves=self.num_waves,
        amplitude=self.amplitude,
    )

StairsTerrain

StairsTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

step_width class-attribute instance-attribute
step_width: float = 0.75
step_height class-attribute instance-attribute
step_height: float = -0.1

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.stairs_terrain(
        subterrain,
        step_width=self.step_width,
        step_height=self.step_height,
    )

PyramidStairsTerrain

PyramidStairsTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

step_width class-attribute instance-attribute
step_width: float = 0.75
step_height class-attribute instance-attribute
step_height: float = -0.1

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.pyramid_stairs_terrain(
        subterrain,
        step_width=self.step_width,
        step_height=self.step_height,
    )

SteppingStonesTerrain

SteppingStonesTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

stone_size class-attribute instance-attribute
stone_size: float = 1.0
stone_distance class-attribute instance-attribute
stone_distance: float = 0.25
max_height class-attribute instance-attribute
max_height: float = 0.2
platform_size class-attribute instance-attribute
platform_size: float = 0.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/heightfield_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    return tu.stepping_stones_terrain(
        subterrain,
        stone_size=self.stone_size,
        stone_distance=self.stone_distance,
        max_height=self.max_height,
        platform_size=self.platform_size,
    )

Procedural box/Perlin terrain generators.

Attributes

Classes

PerlinNoiseTerrain

PerlinNoiseTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Fractal Perlin noise terrain with layered octaves for natural-looking surfaces.

PARAMETER DESCRIPTION
octaves

Number of noise octaves to layer. More octaves add finer detail.

TYPE: int

persistence

Amplitude multiplier per octave (controls roughness).

TYPE: float

lacunarity

Frequency multiplier per octave (controls detail density).

TYPE: float

scale

Base period of the noise in height-field cells (larger = smoother features).

TYPE: float

amplitude

Overall height amplitude in meters (the octave sum is normalized by its total amplitude first).

TYPE: float

seed

Random seed for reproducible noise. Use -1 for random.

TYPE: int

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

octaves class-attribute instance-attribute
octaves: int = 6
persistence class-attribute instance-attribute
persistence: float = 0.5
lacunarity class-attribute instance-attribute
lacunarity: float = 2.0
scale class-attribute instance-attribute
scale: float = 20.0
amplitude class-attribute instance-attribute
amplitude: float = 1.0
seed class-attribute instance-attribute
seed: int = -1

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    rows, cols = subterrain.height_field_raw.shape
    seed = self.seed if self.seed >= 0 else np.random.randint(0, 2**31)
    rng = np.random.default_rng(seed)

    noise = np.zeros((rows, cols), dtype=np.float64)
    freq = 1.0
    amp = 1.0
    max_amp = 0.0

    for _ in range(self.octaves):
        noise += amp * _perlin_2d((rows, cols), self.scale / freq, rng)
        max_amp += amp
        amp *= self.persistence
        freq *= self.lacunarity

    # Normalize by the summed octave amplitudes, then scale to meters / raw units.
    noise /= max_amp
    subterrain.height_field_raw[:] = (noise * self.amplitude / subterrain.vertical_scale).astype(
        subterrain.height_field_raw.dtype
    )
    return subterrain

GapTerrain

GapTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Terrain with parallel gaps (chasms) across the surface.

PARAMETER DESCRIPTION
gap_width

Width of each gap in meters.

TYPE: float

gap_depth

Depth of each gap in meters.

TYPE: float

platform_width

Width of flat platform between gaps in meters.

TYPE: float

num_gaps

Number of gaps to place across the terrain.

TYPE: int

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

gap_width class-attribute instance-attribute
gap_width: float = 0.3
gap_depth class-attribute instance-attribute
gap_depth: float = 1.0
platform_width class-attribute instance-attribute
platform_width: float = 1.0
num_gaps class-attribute instance-attribute
num_gaps: int = 4

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows = hf.shape[0]
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    # Start with flat terrain
    hf[:] = 0

    gap_w_cells = max(int(self.gap_width / hs), 1)
    platform_w_cells = max(int(self.platform_width / hs), 1)
    depth_units = int(self.gap_depth / vs)

    # Distribute gaps evenly along rows
    stride = gap_w_cells + platform_w_cells
    total_width = self.num_gaps * stride
    start = max((rows - total_width) // 2, 0)

    for g in range(self.num_gaps):
        gap_start = start + g * stride + platform_w_cells
        gap_end = min(gap_start + gap_w_cells, rows)
        if gap_start < rows:
            hf[gap_start:gap_end, :] = -depth_units

    return subterrain

PitTerrain

PitTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Terrain with rectangular pits at random positions.

PARAMETER DESCRIPTION
pit_depth

Depth of each pit in meters.

TYPE: float

pit_size_range

Min and max side length of pits in meters.

TYPE: tuple of float

num_pits

Number of pits to place on the terrain.

TYPE: int

platform_size

Size of a safe flat zone in the center in meters. Set 0 to disable.

TYPE: float

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

pit_depth class-attribute instance-attribute
pit_depth: float = 0.5
pit_size_range class-attribute instance-attribute
pit_size_range: Vec2FType = (0.5, 1.5)
num_pits class-attribute instance-attribute
num_pits: int = 8
platform_size class-attribute instance-attribute
platform_size: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows, cols = hf.shape
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    hf[:] = 0
    depth_units = int(self.pit_depth / vs)

    # Safe platform in center
    platform_cells = int(self.platform_size / hs)
    center_r, center_c = rows // 2, cols // 2
    safe_r_min = center_r - platform_cells // 2
    safe_r_max = center_r + platform_cells // 2
    safe_c_min = center_c - platform_cells // 2
    safe_c_max = center_c + platform_cells // 2

    for _ in range(self.num_pits):
        size_cells = np.random.randint(
            int(self.pit_size_range[0] / hs),
            max(
                int(self.pit_size_range[1] / hs),
                int(self.pit_size_range[0] / hs) + 1,
            )
            + 1,
        )
        r0 = np.random.randint(0, max(rows - size_cells, 1))
        c0 = np.random.randint(0, max(cols - size_cells, 1))
        r1 = min(r0 + size_cells, rows)
        c1 = min(c0 + size_cells, cols)

        # Skip if overlaps safe platform
        if self.platform_size > 0:
            if r1 > safe_r_min and r0 < safe_r_max and c1 > safe_c_min and c0 < safe_c_max:
                continue

        hf[r0:r1, c0:c1] = -depth_units

    return subterrain

RidgeTerrain

RidgeTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Terrain with parallel sharp ridges.

PARAMETER DESCRIPTION
ridge_height

Height of each ridge in meters.

TYPE: float

ridge_width

Width of each ridge in meters.

TYPE: float

ridge_spacing

Spacing between ridges in meters.

TYPE: float

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

ridge_height class-attribute instance-attribute
ridge_height: float = 0.15
ridge_width class-attribute instance-attribute
ridge_width: float = 0.1
ridge_spacing class-attribute instance-attribute
ridge_spacing: float = 0.5

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows = hf.shape[0]
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    hf[:] = 0
    height_units = int(self.ridge_height / vs)
    ridge_w_cells = max(int(self.ridge_width / hs), 1)
    spacing_cells = max(int(self.ridge_spacing / hs), 1)
    stride = ridge_w_cells + spacing_cells

    row = spacing_cells // 2
    while row < rows:
        end = min(row + ridge_w_cells, rows)
        # Triangular cross-section: peak in center of ridge
        for r in range(row, end):
            mid = (row + end) / 2.0
            dist = abs(r - mid)
            half_w = (end - row) / 2.0
            if half_w > 0:
                frac = 1.0 - dist / half_w
            else:
                frac = 1.0
            hf[r, :] = int(height_units * frac)
        row += stride

    return subterrain

CraterTerrain

CraterTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Terrain with smooth circular crater depressions.

PARAMETER DESCRIPTION
crater_depth

Maximum depth of craters in meters.

TYPE: float

crater_radius_range

Min and max radius of craters in meters.

TYPE: tuple of float

num_craters

Number of craters to place on the terrain.

TYPE: int

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

crater_depth class-attribute instance-attribute
crater_depth: float = 0.3
crater_radius_range class-attribute instance-attribute
crater_radius_range: Vec2FType = (0.5, 2.0)
num_craters class-attribute instance-attribute
num_craters: int = 6

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows, cols = hf.shape
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    hf[:] = 0
    depth_units = int(self.crater_depth / vs)

    # Pre-compute row and col coordinate arrays
    row_coords = np.arange(rows)
    col_coords = np.arange(cols)

    for _ in range(self.num_craters):
        radius_cells = np.random.uniform(
            self.crater_radius_range[0] / hs,
            self.crater_radius_range[1] / hs,
        )
        cr = np.random.randint(0, rows)
        cc = np.random.randint(0, cols)

        # Compute distance from crater center for all cells
        dr = row_coords - cr
        dc = col_coords - cc
        dist_sq = dr[:, None] ** 2 + dc[None, :] ** 2
        r_sq = radius_cells**2

        # Smooth cosine depression inside radius
        mask = dist_sq < r_sq
        dist = np.sqrt(dist_sq)
        depression = np.zeros_like(hf, dtype=np.float64)
        depression[mask] = -depth_units * 0.5 * (1 + np.cos(np.pi * dist[mask] / radius_cells))

        # Accumulate (craters can overlap)
        hf[:] = np.minimum(hf, hf + depression.astype(hf.dtype))

    return subterrain

NarrowBeamsTerrain

NarrowBeamsTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Radial beams extending from a central platform across a pit.

Inspired by mjlab's BoxNarrowBeamsTerrainCfg.

PARAMETER DESCRIPTION
num_beams

Number of beams radiating from the center.

TYPE: int

beam_width

Width of each beam in meters.

TYPE: float

beam_height

Height of the beams and central platform above the zero level in meters (the pit floor is at -floor_depth).

TYPE: float

platform_width

Side length of the central platform in meters.

TYPE: float

floor_depth

Depth of the surrounding pit in meters.

TYPE: float

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

num_beams class-attribute instance-attribute
num_beams: int = 16
beam_width class-attribute instance-attribute
beam_width: float = 0.3
beam_height class-attribute instance-attribute
beam_height: float = 0.2
platform_width class-attribute instance-attribute
platform_width: float = 1.5
floor_depth class-attribute instance-attribute
floor_depth: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows, cols = hf.shape
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    floor_units = -int(self.floor_depth / vs)
    beam_units = int(self.beam_height / vs)
    beam_w_cells = max(int(self.beam_width / hs / 2), 1)
    platform_cells = int(self.platform_width / hs / 2)

    # Start with pit floor
    hf[:] = floor_units

    cr, cc = rows // 2, cols // 2

    # Central platform
    hf[
        cr - platform_cells : cr + platform_cells,
        cc - platform_cells : cc + platform_cells,
    ] = beam_units

    # Radial beams
    row_coords = np.arange(rows) - cr
    col_coords = np.arange(cols) - cc

    for i in range(self.num_beams):
        angle = 2 * np.pi * i / self.num_beams
        dx = np.cos(angle)
        dy = np.sin(angle)

        # For each cell, compute perpendicular distance to the beam line
        # and distance along beam direction
        perp_dist = np.abs(row_coords[:, None] * dx - col_coords[None, :] * dy)
        along_dist = row_coords[:, None] * dy + col_coords[None, :] * dx

        mask = (perp_dist <= beam_w_cells) & (along_dist >= 0)
        hf[mask] = beam_units

    return subterrain

RandomGridTerrain

RandomGridTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Grid of cells with random heights, creating a blocky uneven surface.

Inspired by mjlab's BoxRandomGridTerrainCfg.

PARAMETER DESCRIPTION
grid_width

Side length of each grid cell in meters.

TYPE: float

grid_height_range

Min and max height of grid cells in meters.

TYPE: tuple of float

platform_width

Side length of the central flat platform in meters.

TYPE: float

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

grid_width class-attribute instance-attribute
grid_width: float = 0.5
grid_height_range class-attribute instance-attribute
grid_height_range: Vec2FType = (0.0, 0.3)
platform_width class-attribute instance-attribute
platform_width: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows, cols = hf.shape
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    hf[:] = 0
    cell_size = max(int(self.grid_width / hs), 1)
    platform_cells = int(self.platform_width / hs / 2)
    cr, cc = rows // 2, cols // 2

    n_cells_r = rows // cell_size
    n_cells_c = cols // cell_size

    for gi in range(n_cells_r):
        for gj in range(n_cells_c):
            r0 = gi * cell_size
            c0 = gj * cell_size
            r1 = min(r0 + cell_size, rows)
            c1 = min(c0 + cell_size, cols)

            height_m = np.random.uniform(*self.grid_height_range)
            hf[r0:r1, c0:c1] = int(height_m / vs)

    # Flat central platform
    hf[
        cr - platform_cells : cr + platform_cells,
        cc - platform_cells : cc + platform_cells,
    ] = 0

    return subterrain

RandomBoxesTerrain

RandomBoxesTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Randomly scattered raised boxes on a flat surface.

Inspired by mjlab's BoxRandomSpreadTerrainCfg.

PARAMETER DESCRIPTION
num_boxes

Number of boxes to scatter.

TYPE: int

box_size_range

Min and max side length of boxes in meters.

TYPE: tuple of float

box_height_range

Min and max height of boxes in meters.

TYPE: tuple of float

platform_width

Side length of the safe central platform in meters.

TYPE: float

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

num_boxes class-attribute instance-attribute
num_boxes: int = 40
box_size_range class-attribute instance-attribute
box_size_range: Vec2FType = (0.3, 1.0)
box_height_range class-attribute instance-attribute
box_height_range: Vec2FType = (0.05, 0.5)
platform_width class-attribute instance-attribute
platform_width: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows, cols = hf.shape
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    hf[:] = 0
    platform_cells = int(self.platform_width / hs / 2)
    cr, cc = rows // 2, cols // 2
    safe_r_min = cr - platform_cells
    safe_r_max = cr + platform_cells
    safe_c_min = cc - platform_cells
    safe_c_max = cc + platform_cells

    for _ in range(self.num_boxes):
        w = np.random.uniform(*self.box_size_range)
        h_m = np.random.uniform(*self.box_height_range)
        w_cells = max(int(w / hs), 1)
        h_units = int(h_m / vs)

        r0 = np.random.randint(0, max(rows - w_cells, 1))
        c0 = np.random.randint(0, max(cols - w_cells, 1))
        r1 = min(r0 + w_cells, rows)
        c1 = min(c0 + w_cells, cols)

        # Skip if overlaps safe platform
        if r1 > safe_r_min and r0 < safe_r_max and c1 > safe_c_min and c0 < safe_c_max:
            continue

        # Boxes stack (take max height)
        hf[r0:r1, c0:c1] = np.maximum(hf[r0:r1, c0:c1], h_units)

    return subterrain

TiltedGridTerrain

TiltedGridTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Grid of cells with tilted surfaces creating uneven, sloped patches.

Inspired by mjlab's BoxTiltedGridTerrainCfg.

PARAMETER DESCRIPTION
grid_width

Side length of each grid cell in meters.

TYPE: float

tilt_range

Maximum tilt angle in degrees.

TYPE: float

height_range

Additional height variation in meters.

TYPE: float

platform_width

Side length of the central flat platform in meters.

TYPE: float

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

grid_width class-attribute instance-attribute
grid_width: float = 1.0
tilt_range class-attribute instance-attribute
tilt_range: float = 15.0
height_range class-attribute instance-attribute
height_range: float = 0.1
platform_width class-attribute instance-attribute
platform_width: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows, cols = hf.shape
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    hf[:] = 0
    cell_size = max(int(self.grid_width / hs), 1)
    platform_cells = int(self.platform_width / hs / 2)
    cr, cc = rows // 2, cols // 2

    n_cells_r = rows // cell_size
    n_cells_c = cols // cell_size

    # Local coordinates within a cell [0, 1]
    local_r = np.linspace(0, 1, cell_size)
    local_c = np.linspace(0, 1, cell_size)
    lr, lc = np.meshgrid(local_r, local_c, indexing="ij")

    for gi in range(n_cells_r):
        for gj in range(n_cells_c):
            r0 = gi * cell_size
            c0 = gj * cell_size
            r1 = min(r0 + cell_size, rows)
            c1 = min(c0 + cell_size, cols)
            actual_r = r1 - r0
            actual_c = c1 - c0

            # Random tilt direction and magnitude
            tilt_deg = np.random.uniform(0, self.tilt_range)
            tilt_rad = np.radians(tilt_deg)
            tilt_dir = np.random.uniform(0, 2 * np.pi)

            # Height offset from base
            base_h = np.random.uniform(0, self.height_range)

            # Tilted plane: height = base + tan(tilt) * (dx*cos + dy*sin) * cell_width
            slope = np.tan(tilt_rad) * self.grid_width
            tile = lr[:actual_r, :actual_c] * np.cos(tilt_dir) + lc[:actual_r, :actual_c] * np.sin(tilt_dir)
            heights = (base_h + slope * tile) / vs

            hf[r0:r1, c0:c1] = heights.astype(hf.dtype)

    # Flat central platform
    hf[
        cr - platform_cells : cr + platform_cells,
        cc - platform_cells : cc + platform_cells,
    ] = 0

    return subterrain

NestedRingsTerrain

NestedRingsTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Concentric rectangular rings forming a nested obstacle pattern.

Inspired by mjlab's BoxNestedRingsTerrainCfg.

PARAMETER DESCRIPTION
num_rings

Number of concentric rings.

TYPE: int

ring_width

Thickness of each ring in meters.

TYPE: float

gap_width

Space between rings in meters.

TYPE: float

height_range

Min and max height of rings in meters.

TYPE: tuple of float

platform_width

Side length of the central platform in meters.

TYPE: float

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

num_rings class-attribute instance-attribute
num_rings: int = 5
ring_width class-attribute instance-attribute
ring_width: float = 0.4
gap_width class-attribute instance-attribute
gap_width: float = 0.2
height_range class-attribute instance-attribute
height_range: Vec2FType = (0.1, 0.4)
platform_width class-attribute instance-attribute
platform_width: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/procedural_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    hf = subterrain.height_field_raw
    rows, cols = hf.shape
    hs = subterrain.horizontal_scale
    vs = subterrain.vertical_scale

    hf[:] = 0
    cr, cc = rows // 2, cols // 2
    platform_cells = int(self.platform_width / hs / 2)
    ring_w = max(int(self.ring_width / hs), 1)
    gap_w = max(int(self.gap_width / hs), 1)
    stride = ring_w + gap_w

    # Precompute Chebyshev distances from center
    dr = np.abs(np.arange(rows) - cr)
    dc = np.abs(np.arange(cols) - cc)
    dist_r = dr[:, None]  # (rows, 1)
    dist_c = dc[None, :]  # (1, cols)

    for i in range(self.num_rings):
        h_m = np.random.uniform(*self.height_range)
        h_units = int(h_m / vs)

        outer = platform_cells + (i + 1) * stride
        inner = outer - ring_w

        # Rectangular ring: inside outer box AND touching at least one edge of inner box
        in_outer = (dist_r < outer) & (dist_c < outer)
        in_inner = (dist_r < inner) & (dist_c < inner)
        mask = in_outer & ~in_inner

        hf[mask] = h_units

    return subterrain

Parkour / locomotion-obstacle terrain generators (ported from extreme-parkour).

These height-field generators carve corridor-style courses (stepping stones, gaps, hurdles, steps) plus a few standalone obstacles.

Conventions: height_field_raw is indexed (x, y) where dim0 is the course (forward) direction and dim1 is lateral; generators are shape-aware, so non-square subterrains (e.g. 16 x 4 m lanes) place features correctly. The course generators attach goals — an (N, 2) float array of waypoints in subterrain-local meters (measured from the cell's [0, 0] corner) — and HalfSlopedTerrain attaches a normalized slope_vector. Both are collected by :func:eden.utils.terrains.base.generate_terrain_fields and surfaced world-frame on the :class:~eden.entities.terrain.Terrain entity for goal-following command terms.

goals come out strictly ordered along the course axis and inside the walkable strip (clear of the far-end :func:_pad_borders wall). The walk itself is not bounded by the subterrain, so :func:_fit_final_goal_x pulls the last waypoint back when it overruns the lane — preferring the course corridor over the bare far-end limit — and raises once the lane is too short to hold an ordered sequence. :func:_validate_goals then checks the assembled array, so the guarantee holds even for option values that make the walk step backwards rather than being inferred from goals[-2] alone.

Three things it deliberately does not police:

  • Obstacle relief. Height-field writes that overrun the strip are clipped (see :func:_set_height_block), so a short lane can still drop its last obstacle silently. A relocated final goal prefers the corridor but is not guaranteed it — see :func:_pull_back_onto_corridor for the lane shape that defeats the search.
  • Spacing between interior goals, which is the generator's own layout, set by its x_range and stone/gap counts. tests/utils/terrains/test_parkour_terrains.py pins the minimum each generator actually produces at its shortest supported subterrain, and tests/tasks/extreme_parkour/teacher/test_config.py pins it for the shipped teacher lanes.
  • Lateral placement. Only the course axis is bounded; a course whose y_range outruns a narrow lane can still put a waypoint in — or past — the lateral wall.

Attributes

MIN_GOAL_MARGIN_M module-attribute

MIN_GOAL_MARGIN_M = 0.5

MIN_GOAL_SPACING_M module-attribute

MIN_GOAL_SPACING_M = 0.5

Classes

RoughTerrainGenerator

RoughTerrainGenerator(options: TerrainTermOptions)

Bases: TerrainGenerator

Base for course generators with difficulty-scaled surface roughness (not registered).

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

apply_roughness class-attribute instance-attribute
apply_roughness: bool = False
noise_range class-attribute instance-attribute
noise_range: tuple[float, float] = (0.02, 0.06)
noise_step class-attribute instance-attribute
noise_step: float = 0.005
downsampled_scale class-attribute instance-attribute
downsampled_scale: float = 0.075
difficulty class-attribute instance-attribute
difficulty: float = 1.0

Methods:

corridor_height_raw
corridor_height_raw(subterrain: SubTerrain) -> int

Raw height of the flat ground this course walks along between its obstacles.

The corridor generators (gap, hurdle, step, demo) carve their relief into a field that starts at zero and only paint platform_height onto the start platform, so zero is what the robot walks on and what the lane runs out onto. ParkourTerrain inverts that and overrides this.

Used by :func:_pull_back_onto_corridor to place a relocated final goal on ground the robot can stop on. Declared by the generator rather than read off the height field because no single reading is right for all five courses.

Source code in eden/utils/terrains/parkour_terrains.py
def corridor_height_raw(self, subterrain: tu.SubTerrain) -> int:
    """Raw height of the flat ground this course walks along between its obstacles.

    The corridor generators (gap, hurdle, step, demo) carve their relief into a field that
    starts at zero and only paint ``platform_height`` onto the *start* platform, so zero is
    what the robot walks on and what the lane runs out onto. ``ParkourTerrain`` inverts that
    and overrides this.

    Used by :func:`_pull_back_onto_corridor` to place a relocated final goal on ground the
    robot can stop on. Declared by the generator rather than read off the height field
    because no single reading is right for all five courses.
    """
    return 0

GapParkourTerrain

GapParkourTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

difficulty class-attribute instance-attribute
difficulty: float = 0.5
platform_size class-attribute instance-attribute
platform_size: float = 4.0
gap_depth class-attribute instance-attribute
gap_depth: float = 2.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    gap_size = 0.1 + 0.3 * self.difficulty
    gap_size = int(gap_size / subterrain.horizontal_scale)
    platform_size = int(self.platform_size / subterrain.horizontal_scale)

    num_x, num_y = subterrain.height_field_raw.shape
    center_x = num_x // 2
    center_y = num_y // 2
    x1 = (num_x - platform_size) // 2
    x2 = x1 + gap_size
    y1 = (num_y - platform_size) // 2
    y2 = y1 + gap_size

    gap_depth = -round(self.gap_depth / subterrain.vertical_scale)
    subterrain.height_field_raw[center_x - x2 : center_x + x2, center_y - y2 : center_y + y2] = gap_depth
    subterrain.height_field_raw[center_x - x1 : center_x + x1, center_y - y1 : center_y + y1] = 0
    return subterrain

ParkourTerrain

ParkourTerrain(options: TerrainTermOptions)

Bases: RoughTerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

platform_len class-attribute instance-attribute
platform_len: float = 2.5
platform_height class-attribute instance-attribute
platform_height: float = 0.0
num_stones class-attribute instance-attribute
num_stones: int = 8
x_range class-attribute instance-attribute
x_range: tuple[float, float] = (1.8, 1.9)
y_range class-attribute instance-attribute
y_range: tuple[float, float] = (0.0, 0.1)
z_range class-attribute instance-attribute
z_range: tuple[float, float] = (-0.2, 0.2)
stone_len class-attribute instance-attribute
stone_len: tuple[float, float] = (1.0, 1.0)
stone_width class-attribute instance-attribute
stone_width: float = 0.6
pad_width class-attribute instance-attribute
pad_width: float = 0.1
pad_height class-attribute instance-attribute
pad_height: float = 0.5
incline_height class-attribute instance-attribute
incline_height: float = 0.1
last_incline_height class-attribute instance-attribute
last_incline_height: float = 0.6
last_stone_len class-attribute instance-attribute
last_stone_len: float = 1.6
pit_depth class-attribute instance-attribute
pit_depth: tuple[float, float] = (0.5, 1.0)

Methods:

corridor_height_raw
corridor_height_raw(subterrain: SubTerrain) -> int

This course floods the lane with pit_depth and paints platforms on top of it.

So unlike its siblings the walkable ground is platform_height, not the field's initial value — the start platform and the run-out platform past the last stone are both painted there, and a goal pulled back onto the pit would be at the bottom of it.

Source code in eden/utils/terrains/parkour_terrains.py
def corridor_height_raw(self, subterrain: tu.SubTerrain) -> int:
    """This course floods the lane with ``pit_depth`` and paints platforms on top of it.

    So unlike its siblings the walkable ground is ``platform_height``, not the field's initial
    value — the start platform and the run-out platform past the last stone are both painted
    there, and a goal pulled back onto the pit would be at the bottom of it.
    """
    return round(self.platform_height / subterrain.vertical_scale)
compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    goals = np.zeros((self.num_stones + 2, 2))
    subterrain.height_field_raw[:] = -round(
        np.random.uniform(self.pit_depth[0], self.pit_depth[1]) / subterrain.vertical_scale
    )

    mid_y = subterrain.height_field_raw.shape[1] // 2
    stone_len = np.random.uniform(*self.stone_len)
    stone_len = 2 * round(stone_len / 2.0, 1)
    stone_len = round(stone_len / subterrain.horizontal_scale)
    dis_x_min = stone_len + round(self.x_range[0] / subterrain.horizontal_scale)
    dis_x_max = stone_len + round(self.x_range[1] / subterrain.horizontal_scale)
    dis_y_min = round(self.y_range[0] / subterrain.horizontal_scale)
    dis_y_max = round(self.y_range[1] / subterrain.horizontal_scale)
    dis_z_min = round(self.z_range[0] / subterrain.vertical_scale)
    dis_z_max = round(self.z_range[1] / subterrain.vertical_scale)
    # Ensure randint bounds stay valid even when ranges collapse after rounding.
    if dis_y_max <= dis_y_min:
        dis_y_max = dis_y_min + 1
    if dis_z_max <= dis_z_min:
        dis_z_max = dis_z_min + 1

    platform_len = round(self.platform_len / subterrain.horizontal_scale)
    platform_height = round(self.platform_height / subterrain.vertical_scale)
    subterrain.height_field_raw[0:platform_len, :] = platform_height

    stone_width = round(self.stone_width / subterrain.horizontal_scale)
    last_stone_len = round(self.last_stone_len / subterrain.horizontal_scale)

    incline_height = round(self.incline_height / subterrain.vertical_scale)
    last_incline_height = round(self.last_incline_height / subterrain.vertical_scale)

    dis_x = platform_len - np.random.randint(dis_x_min, dis_x_max) + stone_len // 2
    goals[0] = [platform_len - stone_len // 2, mid_y]
    left_right_flag = np.random.randint(0, 2)
    dis_z = np.random.randint(dis_z_min, dis_z_max)

    for i in range(self.num_stones):
        dis_x += np.random.randint(dis_x_min, dis_x_max)
        pos_neg = round(2 * (left_right_flag - 0.5))
        dis_y = mid_y + pos_neg * np.random.randint(dis_y_min, dis_y_max)
        if i == self.num_stones - 1:
            dis_x += last_stone_len // 4
            heights = (
                np.tile(
                    np.linspace(-last_incline_height, last_incline_height, stone_width),
                    (last_stone_len, 1),
                )
                * pos_neg
            )
            x0 = dis_x - last_stone_len // 2
            x1 = dis_x + last_stone_len // 2
            y0 = dis_y - stone_width // 2
            y1 = dis_y + stone_width // 2
        else:
            heights = (
                np.tile(
                    np.linspace(-incline_height, incline_height, stone_width),
                    (stone_len, 1),
                )
                * pos_neg
            )
            x0 = dis_x - stone_len // 2
            x1 = dis_x + stone_len // 2
            y0 = dis_y - stone_width // 2
            y1 = dis_y + stone_width // 2

        _set_height_block(subterrain, x0, x1, y0, y1, heights.astype(int) + dis_z)

        goals[i + 1] = [dis_x, dis_y]
        left_right_flag = 1 - left_right_flag
    final_platform_start = dis_x + last_stone_len // 2 + round(0.05 // subterrain.horizontal_scale)
    subterrain.height_field_raw[final_platform_start:, :] = platform_height

    final_dis_x = dis_x + 2 * np.random.randint(dis_x_min, dis_x_max)
    self._finish_course(subterrain, goals, final_dis_x, mid_y)
    _pad_borders(subterrain, self.pad_width, self.pad_height)
    return subterrain

ParkourGapTerrain

ParkourGapTerrain(options: TerrainTermOptions)

Bases: RoughTerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

platform_len class-attribute instance-attribute
platform_len: float = 2.5
platform_height class-attribute instance-attribute
platform_height: float = 0.0
num_gaps class-attribute instance-attribute
num_gaps: int = 8
gap_size class-attribute instance-attribute
gap_size: float = 0.3
x_range class-attribute instance-attribute
x_range: tuple[float, float] = (1.6, 2.4)
y_range class-attribute instance-attribute
y_range: tuple[float, float] = (-1.2, 1.2)
half_valid_width class-attribute instance-attribute
half_valid_width: tuple[float, float] = (0.6, 1.2)
gap_depth class-attribute instance-attribute
gap_depth: tuple[float, float] = (0.5, 1.0)
pad_width class-attribute instance-attribute
pad_width: float = 0.1
pad_height class-attribute instance-attribute
pad_height: float = 0.5
flat class-attribute instance-attribute
flat: bool = False

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    goals = np.zeros((self.num_gaps + 2, 2))
    mid_y = subterrain.height_field_raw.shape[1] // 2

    dis_y_min = round(self.y_range[0] / subterrain.horizontal_scale)
    dis_y_max = round(self.y_range[1] / subterrain.horizontal_scale)

    platform_len = round(self.platform_len / subterrain.horizontal_scale)
    platform_height = round(self.platform_height / subterrain.vertical_scale)
    gap_depth = -round(np.random.uniform(self.gap_depth[0], self.gap_depth[1]) / subterrain.vertical_scale)

    half_valid_width = round(
        np.random.uniform(
            self.half_valid_width[0],
            self.half_valid_width[1],
        )
        / subterrain.horizontal_scale
    )

    subterrain.height_field_raw[0:platform_len, :] = platform_height

    gap_size = round(self.gap_size / subterrain.horizontal_scale)
    dis_x_min = round(self.x_range[0] / subterrain.horizontal_scale) + gap_size
    dis_x_max = round(self.x_range[1] / subterrain.horizontal_scale) + gap_size

    dis_x = platform_len
    goals[0] = [platform_len - 1, mid_y]
    last_dis_x = dis_x
    for i in range(self.num_gaps):
        rand_x = np.random.randint(dis_x_min, dis_x_max)
        dis_x += rand_x
        rand_y = np.random.randint(dis_y_min, dis_y_max)
        if not self.flat:
            subterrain.height_field_raw[dis_x - gap_size // 2 : dis_x + gap_size // 2, :] = gap_depth

        subterrain.height_field_raw[last_dis_x:dis_x, : mid_y + rand_y - half_valid_width] = gap_depth
        subterrain.height_field_raw[last_dis_x:dis_x, mid_y + rand_y + half_valid_width :] = gap_depth

        last_dis_x = dis_x
        goals[i + 1] = [dis_x - rand_x // 2, mid_y + rand_y]
    final_dis_x = dis_x + np.random.randint(dis_x_min, dis_x_max)
    self._finish_course(subterrain, goals, final_dis_x, mid_y)
    _pad_borders(subterrain, self.pad_width, self.pad_height)
    return subterrain

ParkourHurdleTerrain

ParkourHurdleTerrain(options: TerrainTermOptions)

Bases: RoughTerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

platform_len class-attribute instance-attribute
platform_len: float = 2.5
platform_height class-attribute instance-attribute
platform_height: float = 0.0
num_stones class-attribute instance-attribute
num_stones: int = 8
stone_len class-attribute instance-attribute
stone_len: float = 0.3
x_range class-attribute instance-attribute
x_range: tuple[float, float] = (1.5, 2.4)
y_range class-attribute instance-attribute
y_range: tuple[float, float] = (-0.4, 0.4)
half_valid_width class-attribute instance-attribute
half_valid_width: tuple[float, float] = (0.4, 0.8)
hurdle_height_range class-attribute instance-attribute
hurdle_height_range: tuple[float, float] = (0.2, 0.3)
pad_width class-attribute instance-attribute
pad_width: float = 0.1
pad_height class-attribute instance-attribute
pad_height: float = 0.5
flat class-attribute instance-attribute
flat: bool = False

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    goals = np.zeros((self.num_stones + 2, 2))
    mid_y = subterrain.height_field_raw.shape[1] // 2

    dis_x_min = round(self.x_range[0] / subterrain.horizontal_scale)
    dis_x_max = round(self.x_range[1] / subterrain.horizontal_scale)
    dis_y_min = round(self.y_range[0] / subterrain.horizontal_scale)
    dis_y_max = round(self.y_range[1] / subterrain.horizontal_scale)

    half_valid_width = round(
        np.random.uniform(
            self.half_valid_width[0],
            self.half_valid_width[1],
        )
        / subterrain.horizontal_scale
    )
    hurdle_height_max = round(self.hurdle_height_range[1] / subterrain.vertical_scale)
    hurdle_height_min = round(self.hurdle_height_range[0] / subterrain.vertical_scale)

    platform_len = round(self.platform_len / subterrain.horizontal_scale)
    platform_height = round(self.platform_height / subterrain.vertical_scale)
    subterrain.height_field_raw[0:platform_len, :] = platform_height

    stone_len = round(self.stone_len / subterrain.horizontal_scale)
    # Keep the hurdle at least one cell wide; at coarse horizontal_scale a thin
    # hurdle (e.g. 0.3 m) rounds to stone_len // 2 == 0 and draws nothing.
    half_stone_len = max(stone_len // 2, 1)

    dis_x = platform_len
    goals[0] = [platform_len - 1, mid_y]
    for i in range(self.num_stones):
        rand_x = np.random.randint(dis_x_min, dis_x_max)
        rand_y = np.random.randint(dis_y_min, dis_y_max)
        dis_x += rand_x
        if not self.flat:
            subterrain.height_field_raw[dis_x - half_stone_len : dis_x + half_stone_len, :] = np.random.randint(
                hurdle_height_min, hurdle_height_max
            )
            subterrain.height_field_raw[
                dis_x - half_stone_len : dis_x + half_stone_len,
                : mid_y + rand_y - half_valid_width,
            ] = 0
            subterrain.height_field_raw[
                dis_x - half_stone_len : dis_x + half_stone_len,
                mid_y + rand_y + half_valid_width :,
            ] = 0
        goals[i + 1] = [dis_x - rand_x // 2, mid_y + rand_y]
    final_dis_x = dis_x + np.random.randint(dis_x_min, dis_x_max)
    self._finish_course(subterrain, goals, final_dis_x, mid_y)
    _pad_borders(subterrain, self.pad_width, self.pad_height)
    return subterrain

ParkourStepTerrain

ParkourStepTerrain(options: TerrainTermOptions)

Bases: RoughTerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

platform_len class-attribute instance-attribute
platform_len: float = 2.5
platform_height class-attribute instance-attribute
platform_height: float = 0.0
num_stones class-attribute instance-attribute
num_stones: int = 8
x_range class-attribute instance-attribute
x_range: tuple[float, float] = (0.2, 0.4)
y_range class-attribute instance-attribute
y_range: tuple[float, float] = (-0.15, 0.15)
half_valid_width class-attribute instance-attribute
half_valid_width: tuple[float, float] = (0.45, 0.5)
step_height class-attribute instance-attribute
step_height: float = 0.2
pad_width class-attribute instance-attribute
pad_width: float = 0.1
pad_height class-attribute instance-attribute
pad_height: float = 0.5

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    goals = np.zeros((self.num_stones + 2, 2))
    mid_y = subterrain.height_field_raw.shape[1] // 2

    dis_x_min = round((self.x_range[0] + self.step_height) / subterrain.horizontal_scale)
    dis_x_max = round((self.x_range[1] + self.step_height) / subterrain.horizontal_scale)
    dis_y_min = round(self.y_range[0] / subterrain.horizontal_scale)
    dis_y_max = round(self.y_range[1] / subterrain.horizontal_scale)
    # Keep randint bounds valid after rounding small ranges.
    if dis_x_max <= dis_x_min:
        dis_x_max = dis_x_min + 1
    if dis_y_max <= dis_y_min:
        dis_y_max = dis_y_min + 1

    step_height = round(self.step_height / subterrain.vertical_scale)
    half_valid_width = round(
        np.random.uniform(
            self.half_valid_width[0],
            self.half_valid_width[1],
        )
        / subterrain.horizontal_scale
    )

    platform_len = round(self.platform_len / subterrain.horizontal_scale)
    platform_height = round(self.platform_height / subterrain.vertical_scale)
    subterrain.height_field_raw[0:platform_len, :] = platform_height

    dis_x = platform_len
    last_dis_x = dis_x
    stair_height = 0
    goals[0] = [platform_len - round(1 / subterrain.horizontal_scale), mid_y]
    for i in range(self.num_stones):
        rand_x = np.random.randint(dis_x_min, dis_x_max)
        rand_y = np.random.randint(dis_y_min, dis_y_max)
        if i < self.num_stones // 2:
            stair_height += step_height
        elif i > self.num_stones // 2:
            stair_height -= step_height
        subterrain.height_field_raw[dis_x : dis_x + rand_x, :] = stair_height
        dis_x += rand_x
        subterrain.height_field_raw[last_dis_x:dis_x, : mid_y + rand_y - half_valid_width] = 0
        subterrain.height_field_raw[last_dis_x:dis_x, mid_y + rand_y + half_valid_width :] = 0

        last_dis_x = dis_x
        goals[i + 1] = [dis_x - rand_x // 2, mid_y + rand_y]
    final_dis_x = dis_x + np.random.randint(dis_x_min, dis_x_max)
    self._finish_course(subterrain, goals, final_dis_x, mid_y)
    _pad_borders(subterrain, self.pad_width, self.pad_height)
    return subterrain

ParkourDemoTerrain

ParkourDemoTerrain(options: TerrainTermOptions)

Bases: RoughTerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    goals = np.zeros((8, 2))
    mid_y = subterrain.height_field_raw.shape[1] // 2

    platform_length = round(2 / subterrain.horizontal_scale)
    hurdle_depth = round(np.random.uniform(0.35, 0.4) / subterrain.horizontal_scale)
    hurdle_height = round(np.random.uniform(0.3, 0.36) / subterrain.vertical_scale)
    hurdle_width = round(np.random.uniform(1, 1.2) / subterrain.horizontal_scale)
    goals[0] = [platform_length + hurdle_depth / 2, mid_y]
    subterrain.height_field_raw[
        platform_length : platform_length + hurdle_depth,
        round(mid_y - hurdle_width / 2) : round(mid_y + hurdle_width / 2),
    ] = hurdle_height

    platform_length += round(np.random.uniform(1.5, 2.5) / subterrain.horizontal_scale)
    first_step_depth = round(np.random.uniform(0.45, 0.8) / subterrain.horizontal_scale)
    first_step_height = round(np.random.uniform(0.35, 0.45) / subterrain.vertical_scale)
    first_step_width = round(np.random.uniform(1, 1.2) / subterrain.horizontal_scale)
    goals[1] = [platform_length + first_step_depth / 2, mid_y]
    subterrain.height_field_raw[
        platform_length : platform_length + first_step_depth,
        round(mid_y - first_step_width / 2) : round(mid_y + first_step_width / 2),
    ] = first_step_height

    platform_length += first_step_depth
    second_step_depth = round(np.random.uniform(0.45, 0.8) / subterrain.horizontal_scale)
    second_step_height = first_step_height
    second_step_width = first_step_width
    goals[2] = [platform_length + second_step_depth / 2, mid_y]
    subterrain.height_field_raw[
        platform_length : platform_length + second_step_depth,
        round(mid_y - second_step_width / 2) : round(mid_y + second_step_width / 2),
    ] = second_step_height

    platform_length += second_step_depth
    gap_size = round(np.random.uniform(0.5, 0.8) / subterrain.horizontal_scale)

    platform_length += gap_size
    third_step_depth = round(np.random.uniform(0.25, 0.6) / subterrain.horizontal_scale)
    third_step_height = first_step_height
    third_step_width = round(np.random.uniform(1, 1.2) / subterrain.horizontal_scale)
    goals[3] = [platform_length + third_step_depth / 2, mid_y]
    subterrain.height_field_raw[
        platform_length : platform_length + third_step_depth,
        round(mid_y - third_step_width / 2) : round(mid_y + third_step_width / 2),
    ] = third_step_height

    platform_length += third_step_depth
    forth_step_depth = round(np.random.uniform(0.25, 0.6) / subterrain.horizontal_scale)
    forth_step_height = first_step_height
    forth_step_width = third_step_width
    goals[4] = [platform_length + forth_step_depth / 2, mid_y]
    subterrain.height_field_raw[
        platform_length : platform_length + forth_step_depth,
        round(mid_y - forth_step_width / 2) : round(mid_y + forth_step_width / 2),
    ] = forth_step_height

    platform_length += forth_step_depth
    gap_size = round(np.random.uniform(0.1, 0.4) / subterrain.horizontal_scale)
    platform_length += gap_size

    left_y = mid_y + round(np.random.uniform(0.15, 0.3) / subterrain.horizontal_scale)
    right_y = mid_y - round(np.random.uniform(0.15, 0.3) / subterrain.horizontal_scale)

    slope_height = round(np.random.uniform(0.15, 0.22) / subterrain.vertical_scale)
    slope_depth = round(np.random.uniform(0.75, 0.85) / subterrain.horizontal_scale)
    slope_width = round(1.0 / subterrain.horizontal_scale)

    platform_height = slope_height + np.random.randint(0, 0.2 / subterrain.vertical_scale)

    goals[5] = [platform_length + slope_depth / 2, left_y]
    heights = np.tile(np.linspace(-slope_height, slope_height, slope_width), (slope_depth, 1))
    _set_height_block(
        subterrain,
        platform_length,
        platform_length + slope_depth,
        left_y - slope_width // 2,
        left_y + slope_width // 2,
        heights.astype(int) + platform_height,
    )

    platform_length += slope_depth + gap_size
    goals[6] = [platform_length + slope_depth / 2, right_y]
    heights = np.tile(np.linspace(-slope_height, slope_height, slope_width), (slope_depth, 1)) * -1
    _set_height_block(
        subterrain,
        platform_length,
        platform_length + slope_depth,
        right_y - slope_width // 2,
        right_y + slope_width // 2,
        heights.astype(int) + platform_height,
    )

    platform_length += slope_depth + gap_size + round(0.4 / subterrain.horizontal_scale)
    self._finish_course(subterrain, goals, platform_length, left_y)
    return subterrain

HalfSlopedTerrain

HalfSlopedTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

wall_width class-attribute instance-attribute
wall_width: float = 4.0
start2center class-attribute instance-attribute
start2center: float = 0.7
max_height class-attribute instance-attribute
max_height: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    num_x = subterrain.height_field_raw.shape[0]
    wall_width_int = max(int(self.wall_width / subterrain.horizontal_scale), 1)
    max_height_int = int(self.max_height / subterrain.vertical_scale)
    slope_start = int(self.start2center / subterrain.horizontal_scale + num_x // 2)
    height2width_ratio = max_height_int / wall_width_int
    xs = np.arange(slope_start, num_x)
    heights = (height2width_ratio * (xs - slope_start)).clip(max=max_height_int).astype(np.int16)
    subterrain.height_field_raw[slope_start:num_x, :] = heights[:, None]
    subterrain.slope_vector = np.array(
        [wall_width_int * subterrain.horizontal_scale, 0.0, self.max_height],
        dtype=np.float32,
    )
    subterrain.slope_vector /= np.linalg.norm(subterrain.slope_vector)
    return subterrain

HalfPlatformTerrain

HalfPlatformTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

start2center class-attribute instance-attribute
start2center: float = 2.0
max_height class-attribute instance-attribute
max_height: float = 1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    num_x, num_y = subterrain.height_field_raw.shape
    max_height_int = int(self.max_height / subterrain.vertical_scale)
    offset = int(self.start2center / subterrain.horizontal_scale)
    slope_start_x = offset + num_x // 2
    slope_start_y = offset + num_y // 2
    subterrain.height_field_raw[:, :] = max_height_int
    subterrain.height_field_raw[-slope_start_x:slope_start_x, -slope_start_y:slope_start_y] = 0
    return subterrain

CustomSteppingStonesTerrain

CustomSteppingStonesTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

stone_size class-attribute instance-attribute
stone_size: float = 1.0
stone_distance class-attribute instance-attribute
stone_distance: float = 1.0
max_height class-attribute instance-attribute
max_height: float = 0.2
platform_size class-attribute instance-attribute
platform_size: float = 1.0
depth class-attribute instance-attribute
depth: float = -1.0

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/parkour_terrains.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    def get_rand_dis_int(scale: float) -> int:
        low = int(-scale / subterrain.horizontal_scale + 1)
        high = int(scale / subterrain.horizontal_scale)
        if high <= low:
            high = low + 1
        return np.random.randint(low, high)

    stone_size = int(self.stone_size / subterrain.horizontal_scale)
    stone_distance = int(self.stone_distance / subterrain.horizontal_scale)
    max_height = int(self.max_height / subterrain.vertical_scale)
    platform_size = int(self.platform_size / subterrain.horizontal_scale)
    height_range = np.arange(-max_height - 1, max_height, step=1)

    start_x = 0
    start_y = 0
    subterrain.height_field_raw[:, :] = int(self.depth / subterrain.vertical_scale)
    if subterrain.length >= subterrain.width:
        while start_y < subterrain.length:
            stop_y = min(subterrain.length, start_y + stone_size)
            start_x = np.random.randint(0, stone_size)
            stop_x = max(0, start_x - stone_distance - get_rand_dis_int(0.2))
            subterrain.height_field_raw[0:stop_x, start_y:stop_y] = np.random.choice(height_range)
            while start_x < subterrain.width:
                stop_x = min(subterrain.width, start_x + stone_size)
                subterrain.height_field_raw[start_x:stop_x, start_y:stop_y] = np.random.choice(height_range)
                start_x += stone_size + stone_distance + get_rand_dis_int(0.2)
            start_y += stone_size + stone_distance + get_rand_dis_int(0.2)
    elif subterrain.width > subterrain.length:
        while start_x < subterrain.width:
            stop_x = min(subterrain.width, start_x + stone_size)
            start_y = np.random.randint(0, stone_size)
            stop_y = max(0, start_y - stone_distance)
            subterrain.height_field_raw[start_x:stop_x, 0:stop_y] = np.random.choice(height_range)
            while start_y < subterrain.length:
                stop_y = min(subterrain.length, start_y + stone_size)
                subterrain.height_field_raw[start_x:stop_x, start_y:stop_y] = np.random.choice(height_range)
                start_y += stone_size + stone_distance
            start_x += stone_size + stone_distance

    x1 = (subterrain.width - platform_size) // 2
    x2 = (subterrain.width + platform_size) // 2
    y1 = (subterrain.length - platform_size) // 2
    y2 = (subterrain.length + platform_size) // 2
    subterrain.height_field_raw[x1:x2, y1:y2] = 0
    return subterrain

Terrain generator that loads an exported height-field .npz verbatim.

Closes the loop on :mod:eden.utils.terrains.io: a terrain exported via :func:~eden.utils.terrains.io.export_height_field can be rebuilt as a scene entity, so offline tools (e.g. the TCRS motion adapter) and the simulated scene share exactly one surface. Use :func:~eden.utils.terrains.io.terrain_options_from_height_field_file to get matching TerrainOptions in one call.

Attributes

Classes

HeightFieldFileTerrain

HeightFieldFileTerrain(options: TerrainTermOptions)

Bases: TerrainGenerator

Fill the subterrain with the grid from an exported height-field .npz.

The enclosing TerrainOptions must match the file: the same horizontal_scale, vertical_scale=1.0 (the stored grid is already in meters), and subterrain_size = ((H-1)*hs, (W-1)*hs). The file's origin is not applied here — set default_root_pos to it (or build the options via terrain_options_from_height_field_file).

Source code in eden/utils/terrains/base.py
def __init__(self, options: TerrainTermOptions):
    ConfigurableMixin.__init__(self, options=options)

Attributes

file class-attribute instance-attribute
file: str = ''

Methods:

compute
compute(subterrain: SubTerrain) -> SubTerrain
Source code in eden/utils/terrains/height_field_file_terrain.py
def compute(self, subterrain: tu.SubTerrain) -> tu.SubTerrain:
    from eden.utils.terrains.io import load_height_field

    if not self.file:
        raise ValueError("HeightFieldFileTerrain: 'file' is required (path of an exported height-field .npz).")
    data = load_height_field(self.file)
    expected = (subterrain.width, subterrain.length)
    if data.height_field_m.shape != expected:
        raise ValueError(
            f"HeightFieldFileTerrain: '{self.file}' grid is {data.height_field_m.shape} but the "
            f"TerrainOptions imply {expected}; set subterrain_size=((H-1)*hs, (W-1)*hs) with the "
            "file's horizontal_scale."
        )
    if abs(subterrain.horizontal_scale - data.horizontal_scale) > 1e-9:
        raise ValueError(
            f"HeightFieldFileTerrain: horizontal_scale mismatch — file has {data.horizontal_scale}, "
            f"TerrainOptions has {subterrain.horizontal_scale}."
        )
    # The stored grid is meters; raw values are meters/vertical_scale.
    subterrain.height_field_raw[:] = data.height_field_m / subterrain.vertical_scale
    return subterrain

Height-field export and import

Height-field terrain export/import.

Eden terrains are generated in memory (:func:generate_height_field) and normally live only inside the Genesis morph. This module defines a small on-disk .npz format so a terrain's height field can be exported once and re-used by offline tools (e.g. the TCRS terrain-adaptive motion retargeter) without re-running the generators.

Format (version 1)

height_field float32 [H, W] grid of heights in meters (vertical_scale is already applied). Axis 0 maps to world +x, axis 1 to world +y — the same convention as :func:generate_height_field and :class:~eden.entities.terrain.Terrain. horizontal_scale float64 cell size in meters: x = origin[0] + i * horizontal_scale. origin float64 [3] world position of grid corner [0, 0]. _format_version Integer format tag; loaders must reject unknown versions.

Attributes

HEIGHT_FIELD_FORMAT_VERSION module-attribute

HEIGHT_FIELD_FORMAT_VERSION = 1

Classes

HeightFieldData dataclass

HeightFieldData(height_field_m: ndarray, horizontal_scale: float, origin: tuple[float, float, float] = (0.0, 0.0, 0.0))

A height-field grid in meters plus the metadata needed to query it.

Attributes

height_field_m instance-attribute
height_field_m: ndarray

float32 [H, W] heights in meters; axis 0 → world +x, axis 1 → world +y.

horizontal_scale instance-attribute
horizontal_scale: float

Cell size in meters along both axes.

origin class-attribute instance-attribute
origin: tuple[float, float, float] = field(default=(0.0, 0.0, 0.0))

World position of grid corner [0, 0]; origin[2] offsets all heights.

Methods:

Functions:

export_height_field

export_height_field(path: str, height_field_m: ndarray, horizontal_scale: float, origin: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> None

Save a height field (in meters) to path in the versioned .npz format.

Source code in eden/utils/terrains/io.py
def export_height_field(
    path: str,
    height_field_m: np.ndarray,
    horizontal_scale: float,
    origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> None:
    """Save a height field (in meters) to ``path`` in the versioned ``.npz`` format."""
    if not str(path).endswith(".npz"):
        # np.savez would silently append ".npz", breaking a later load with the
        # original path string.
        raise ValueError(f"Height-field export path must end with '.npz'; got '{path}'.")
    data = HeightFieldData(height_field_m=height_field_m, horizontal_scale=horizontal_scale, origin=origin)
    np.savez_compressed(
        path,
        height_field=data.height_field_m,
        horizontal_scale=np.float64(data.horizontal_scale),
        # float64: world coordinates can be large, and float32 loses ~1 m of
        # precision near 1e7 — the height grid itself stays float32.
        origin=np.asarray(data.origin, dtype=np.float64),
        _format_version=np.int64(HEIGHT_FIELD_FORMAT_VERSION),
    )

load_height_field

load_height_field(path: str) -> HeightFieldData

Load a height field previously written by :func:export_height_field.

Source code in eden/utils/terrains/io.py
def load_height_field(path: str) -> HeightFieldData:
    """Load a height field previously written by :func:`export_height_field`."""
    with np.load(path) as raw:
        missing = {"height_field", "horizontal_scale", "origin", "_format_version"} - set(raw.files)
        if missing:
            raise ValueError(
                f"'{path}' is not an Eden height-field npz: missing key(s) {sorted(missing)}. "
                f"Found keys: {sorted(raw.files)}."
            )
        version = int(raw["_format_version"])
        if version != HEIGHT_FIELD_FORMAT_VERSION:
            raise ValueError(
                f"'{path}' has height-field format version {version}; this Eden version reads "
                f"version {HEIGHT_FIELD_FORMAT_VERSION}. Re-export the terrain."
            )
        return HeightFieldData(
            height_field_m=raw["height_field"],
            horizontal_scale=float(raw["horizontal_scale"]),
            origin=tuple(raw["origin"].tolist()),
        )

terrain_options_from_height_field_file

terrain_options_from_height_field_file(path: str) -> 'TerrainOptions'

Build a TerrainOptions that reconstructs an exported height field verbatim.

The returned options place the terrain at the file's origin and reproduce the stored grid exactly (float raw field, vertical_scale=1.0), so a scene built from them matches what the TCRS HeightFieldTerrain queried during adaptation.

Source code in eden/utils/terrains/io.py
def terrain_options_from_height_field_file(path: str) -> "TerrainOptions":
    """Build a ``TerrainOptions`` that reconstructs an exported height field verbatim.

    The returned options place the terrain at the file's origin and reproduce
    the stored grid exactly (float raw field, ``vertical_scale=1.0``), so a
    scene built from them matches what the TCRS
    ``HeightFieldTerrain`` queried during adaptation.
    """
    from eden.options.entities import TerrainOptions
    from eden.utils.terrains.height_field_file_terrain import HeightFieldFileTerrain

    data = load_height_field(path)
    height, width = data.height_field_m.shape
    return TerrainOptions(
        horizontal_scale=data.horizontal_scale,
        vertical_scale=1.0,
        subterrain_size=(
            (height - 1) * data.horizontal_scale,
            (width - 1) * data.horizontal_scale,
        ),
        terrain_generators=[[HeightFieldFileTerrain.configure(file=path)]],
        default_root_pos=data.origin,
    )

height_field_from_terrain_options

height_field_from_terrain_options(options: 'TerrainOptions') -> HeightFieldData

Materialize the height field a :class:TerrainOptions would build, in meters.

Runs the same :func:generate_height_field call as :meth:eden.entities.terrain.Terrain._create_morph_from_options, applies vertical_scale, and stamps options.default_root_pos as the origin.

Source code in eden/utils/terrains/io.py
def height_field_from_terrain_options(options: "TerrainOptions") -> HeightFieldData:
    """Materialize the height field a :class:`TerrainOptions` would build, in meters.

    Runs the same :func:`generate_height_field` call as
    :meth:`eden.entities.terrain.Terrain._create_morph_from_options`, applies
    ``vertical_scale``, and stamps ``options.default_root_pos`` as the origin.
    """
    import genesis as gs

    from eden.utils.terrains.base import generate_height_field

    if gs.EPS is None:
        # generate_height_field reads gs.EPS, which Genesis only sets at init;
        # without this guard the failure is an opaque TypeError deep inside.
        raise RuntimeError("height_field_from_terrain_options requires an initialized Genesis — call en.init() first.")
    if not options.terrain_generators or not options.terrain_generators[0]:
        raise ValueError(
            "height_field_from_terrain_options: options.terrain_generators must be a non-empty "
            f"rectangular grid of generators; got {options.terrain_generators!r}."
        )
    n_subterrains = (len(options.terrain_generators), len(options.terrain_generators[0]))
    height_field = generate_height_field(
        terrain_generators=options.terrain_generators,
        subterrain_size=options.subterrain_size,
        n_subterrains=n_subterrains,
        horizontal_scale=options.horizontal_scale,
        vertical_scale=options.vertical_scale,
        randomize=options.randomize,
    )
    return HeightFieldData(
        height_field_m=height_field.astype(np.float32) * options.vertical_scale,
        horizontal_scale=options.horizontal_scale,
        origin=tuple(options.default_root_pos),
    )

Plane grids

A grid of square cells over a plane: the layout of a task's per-env marks on a flat surface.

A whiteboard's ink (eden.tasks.whiteboard) and a sheet's (eden.tasks.art.calligraphy) are kept on one, and a camera paints them through a :class:~eden.entities.camera_overlays.PaintedPlane, which carries the grid.

Classes

PlaneGrid dataclass

PlaneGrid(u0: float, v0: float, cell: float, rows: int, cols: int)

A rows x cols grid of cell-metre cells over a plane's (u, v) frame, row 0 at the top.

Cell (r, c) covers u in [u0 + c cell, u0 + (c + 1) cell] and v in [v0 - (r + 1) cell, v0 - r cell]: (u0, v0) is the grid's top-left corner.

Attributes

u0 instance-attribute
u0: float
v0 instance-attribute
v0: float
cell instance-attribute
cell: float
rows instance-attribute
rows: int
cols instance-attribute
cols: int
shape property
shape: tuple[int, int]

Methods:

cell_of
cell_of(uv: ndarray) -> ndarray

(row, col) of the cell holding each point uv (..., 2); may lie outside the grid.

Source code in eden/utils/plane_grid.py
def cell_of(self, uv: np.ndarray) -> np.ndarray:
    """``(row, col)`` of the cell holding each point ``uv (..., 2)``; may lie outside the grid."""
    uv = np.asarray(uv, dtype=np.float64)
    return np.stack(
        [np.floor((self.v0 - uv[..., 1]) / self.cell), np.floor((uv[..., 0] - self.u0) / self.cell)], -1
    ).astype(np.int64)
centers
centers() -> ndarray

(u, v) of every cell's centre, (rows, cols, 2).

Source code in eden/utils/plane_grid.py
def centers(self) -> np.ndarray:
    """``(u, v)`` of every cell's centre, ``(rows, cols, 2)``."""
    u = self.u0 + (np.arange(self.cols) + 0.5) * self.cell
    v = self.v0 - (np.arange(self.rows) + 0.5) * self.cell
    return np.stack(np.meshgrid(u, v, indexing="xy"), axis=-1)

Assets

Assets download on first use into eden.utils.misc.get_assets_dir(): $EDEN_ASSETS_DIR when it is set (it must be absolute), otherwise $XDG_CACHE_HOME/eden/assets, by default ~/.cache/eden/assets. Fetch an entity's or a policy's file through its owner (options.fetch_asset(file), MyPolicy.fetch_asset(file)) rather than calling get_asset_path with a local_dir yourself. See the assets page.

Asset download (local-first, lock-serialized) and dependency extraction from model files.

Attributes

SAMPLE_DATA_REGISTRY module-attribute

SAMPLE_DATA_REGISTRY = 'Kashu7100'

SAMPLE_DATA_REPO module-attribute

SAMPLE_DATA_REPO = 'eden_example'

Functions:

get_asset_path

get_asset_path(file: str, registry: str | None = 'Kashu7100', dataset: str | None = 'eden_assets', local_dir: str | None = None) -> str

Resolve an asset to a local file, fetching it and its dependencies from the Hub if needed.

file is one of:

  • a path inside local_dir, cached or not: the repo path below local_dir, resolved as that repo path is (so a missing dependency of a cached file is fetched too);
  • any other existing local path, returned as is (an absolute one that does not exist raises, without a fetch);
  • the file's full path in the registry/dataset Hub repo, e.g. models/eggplant/object.urdf.

local_dir defaults to :func:eden.utils.misc.get_assets_dir, the download root EDEN_ASSETS_DIR moves. A repo path is matched exactly, never by its tail. local_dir can hold other datasets' caches (the download root holds ycb/, simpler/, ...), so a bare name or a path suffix could name another dataset's file; the error for one lists the repo paths that end in it.

With registry or dataset set to None nothing is fetched: file is returned if it exists locally, else None.

Source code in eden/utils/assets.py
def get_asset_path(
    file: str,
    registry: str | None = "Kashu7100",
    dataset: str | None = "eden_assets",
    local_dir: str | None = None,
) -> str:
    """Resolve an asset to a local file, fetching it and its dependencies from the Hub if needed.

    ``file`` is one of:

    - a path inside ``local_dir``, cached or not: the repo path below ``local_dir``, resolved as that
      repo path is (so a missing dependency of a cached file is fetched too);
    - any other existing local path, returned as is (an absolute one that does not exist raises,
      without a fetch);
    - the file's full path in the ``registry/dataset`` Hub repo, e.g. ``models/eggplant/object.urdf``.

    ``local_dir`` defaults to :func:`eden.utils.misc.get_assets_dir`, the download root ``EDEN_ASSETS_DIR``
    moves. A repo path is matched exactly, never by its tail. ``local_dir`` can hold other datasets' caches
    (the download root holds ``ycb/``, ``simpler/``, ...), so a bare name or a path suffix could name
    another dataset's file; the error for one lists the repo paths that end in it.

    With ``registry`` or ``dataset`` set to ``None`` nothing is fetched: ``file`` is returned if it exists
    locally, else ``None``.
    """
    local_dir = resolve_local_dir(local_dir)
    if registry is None or dataset is None:
        return file if os.path.exists(file) else None

    asset_path = local_dir
    repo_id = f"{registry}/{dataset}"
    local_dir_abs = os.path.abspath(local_dir)
    file_abs = os.path.abspath(file)
    if file_abs.startswith(local_dir_abs + os.sep) and not os.path.isdir(file_abs):
        # A path inside local_dir is the cached copy of the repo path below it, so it resolves
        # (and has its dependencies fetched) exactly as that repo path does.
        repo_file = os.path.relpath(file_abs, local_dir_abs)
    elif os.path.exists(file):
        return file
    elif os.path.isabs(file):
        raise FileNotFoundError(
            f"No such local file: {file!r}. An absolute path is read from disk, never fetched; only a path "
            f"inside local_dir ({local_dir}) or a repo path names a file of the Hub repo {repo_id!r}."
        )
    else:
        repo_file = file

    matched_file_path = os.path.join(local_dir, repo_file)
    if not os.path.isfile(matched_file_path):
        # Escaped: ``allow_patterns`` are fnmatch patterns, and a name is matched literally.
        _snapshot_download_locked(
            repo_type="dataset",
            repo_id=repo_id,
            allow_patterns=[glob.escape(repo_file)],
            local_dir=local_dir,
        )
        if not os.path.isfile(matched_file_path):
            raise FileNotFoundError(_not_fetched_message(file, repo_file, repo_id, local_dir))

    # Download asset dependencies (meshes, textures, materials, etc.)
    # Walk the dep graph layer by layer, BFS: every file at the current
    # depth is parsed for refs, then ALL missing refs are downloaded in a
    # single batched ``snapshot_download`` call before advancing to the
    # next layer. URDFs in particular reference dozens of ``.obj`` files,
    # which each reference one ``.mtl`` — batching avoids one HF round-trip
    # per file (laptop URDF: 1 + 37 + 37 + textures ≈ 4 batched calls instead
    # of ~100 single-file ones).
    checked_files: set[str] = {matched_file_path}
    current_layer: list[str] = [matched_file_path]

    while current_layer:
        next_missing_patterns: list[str] = []
        next_local_paths: list[str] = []
        already_local: list[str] = []
        # (referring file, ref, candidates) for each ref this layer's fetch asks for.
        requested: list[tuple[str, str, list[tuple[str, str]]]] = []

        for current_file in current_layer:
            dep_paths = _get_asset_dependencies(current_file)
            if not dep_paths:
                continue

            file_dir = os.path.dirname(current_file)
            file_dir_relative = os.path.relpath(file_dir, asset_path)

            for dep_path in dep_paths:
                candidates = _resolve_dep(dep_path, file_dir, file_dir_relative, asset_path)
                found_local = False
                for _dep_pattern, dep_local_path in candidates:
                    if os.path.exists(dep_local_path):
                        if dep_local_path not in checked_files:
                            already_local.append(dep_local_path)
                        found_local = True
                        break
                if not found_local:
                    requested.append((current_file, dep_path, candidates))
                    for dep_pattern, dep_local_path in candidates:
                        # An absolute ref's primary candidate is a host path, not a repo path.
                        if not os.path.isabs(dep_pattern):
                            next_missing_patterns.append(dep_pattern.replace(os.sep, "/"))
                            next_local_paths.append(dep_local_path)

        # Dedup patterns + their matched local paths, preserving order.
        seen: set[str] = set()
        unique_patterns: list[str] = []
        unique_local_paths: list[str] = []
        for pattern, local_path in zip(next_missing_patterns, next_local_paths):
            if pattern not in seen:
                seen.add(pattern)
                unique_patterns.append(pattern)
                unique_local_paths.append(local_path)

        if unique_patterns:
            _snapshot_download_locked(
                repo_type="dataset",
                repo_id=repo_id,
                allow_patterns=[glob.escape(pattern) for pattern in unique_patterns],
                local_dir=local_dir,
            )
        for referrer, dep_path, candidates in requested:
            if not any(os.path.exists(dep_local_path) for _, dep_local_path in candidates):
                patterns = [p.replace(os.sep, "/") for p, _ in candidates if not os.path.isabs(p)]
                _warn_dependency_not_delivered(repo_id, local_dir, referrer, dep_path, patterns)

        next_layer: list[str] = []
        for p in already_local:
            if p not in checked_files:
                checked_files.add(p)
                next_layer.append(p)
        for p in unique_local_paths:
            if p not in checked_files and os.path.exists(p):
                checked_files.add(p)
                next_layer.append(p)
        current_layer = next_layer

    return matched_file_path

sample_data_path

sample_data_path(task: str, filename: str) -> str

Resolve one of task's sample files, fetching it from HuggingFace on first use.

The one way in to the shared example-data repo, so a checkout and an installed wheel behave identically. The task name is the directory holding that task's files, which stops a task widening its fetch to another's.

PARAMETER DESCRIPTION
task

Registered task name, and the directory holding that task's files in the repo.

TYPE: str

filename

File to resolve, relative to that directory. Only this one file is downloaded.

TYPE: str

RETURNS DESCRIPTION
str

Local path to the file.

Source code in eden/utils/assets.py
def sample_data_path(task: str, filename: str) -> str:
    """Resolve one of ``task``'s sample files, fetching it from HuggingFace on first use.

    The one way in to the shared example-data repo, so a checkout and an installed wheel
    behave identically. The task name *is* the directory holding that task's files, which
    stops a task widening its fetch to another's.

    Parameters
    ----------
    task : str
        Registered task name, and the directory holding that task's files in the repo.
    filename : str
        File to resolve, relative to that directory. Only this one file is downloaded.

    Returns
    -------
    str
        Local path to the file.
    """
    return get_asset_path(
        f"{task}/{filename}",
        registry=SAMPLE_DATA_REGISTRY,
        dataset=SAMPLE_DATA_REPO,
        # A directory of its own rather than the asset root (the default): tasks write beside their
        # clip — contrack caches the exported object mesh in a ``.cache/`` there. It must also be
        # stable, not a HuggingFace snapshot dir whose name tracks the revision, or those caches
        # cold-start every run.
        local_dir=get_cache_dir(SAMPLE_DATA_REPO),
    )

find_latest_checkpoint

find_latest_checkpoint(log_dir: str, return_path: bool = True) -> str
Source code in eden/utils/assets.py
def find_latest_checkpoint(log_dir: str, return_path: bool = True) -> str:
    if not os.path.exists(log_dir):
        raise FileNotFoundError(f"Log directory {log_dir} not found")
    if not os.path.isdir(log_dir):
        raise NotADirectoryError(f"Log directory {log_dir} is not a directory")
    if not os.listdir(log_dir):
        raise FileNotFoundError(f"Log directory {log_dir} is empty")
    if not any(filename.endswith(".pt") for filename in os.listdir(log_dir)):
        raise FileNotFoundError(f"No model checkpoints in {log_dir}")

    candidates = model_checkpoints(log_dir)
    if not candidates:
        raise FileNotFoundError(f"No model checkpoints matching 'model_{{itr}}.pt' in {log_dir}")
    if return_path:
        return os.path.join(log_dir, candidates[-1][1])
    else:
        return candidates[-1][1]

resolve_run_dir

resolve_run_dir(runs_root: str, ref: str) -> str

Resolve a --resume reference to a run directory under runs_root.

runs_root is a task's run root (logs/<task>); each child is a run directory named <timestamp>[-<run_name>]-<id>. ref selects one of them:

  • "latest" — the most recently modified run directory;
  • an exact run id — the trailing -<id> segment of a run's basename;
  • a substring — matched against the whole basename (e.g. part of a run_name).

Fails loud, listing the available runs, when nothing matches or a substring is ambiguous, so a typo never silently resumes the wrong run. This is the run-directory analogue of :func:find_latest_checkpoint (which selects a checkpoint within one run); callers chain the two: find_latest_checkpoint(resolve_run_dir(root, ref)).

PARAMETER DESCRIPTION
runs_root

The task's run root, e.g. logs/<task>.

TYPE: str

ref

"latest", an exact run id, or a basename substring.

TYPE: str

RETURNS DESCRIPTION
str

Path (joined onto runs_root) of the resolved run directory.

RAISES DESCRIPTION
FileNotFoundError

If runs_root has no run directories, or ref matches none.

ValueError

If ref is a substring matching more than one run.

Source code in eden/utils/assets.py
def resolve_run_dir(runs_root: str, ref: str) -> str:
    """Resolve a ``--resume`` reference to a run directory under ``runs_root``.

    ``runs_root`` is a task's run root (``logs/<task>``); each child is a run directory
    named ``<timestamp>[-<run_name>]-<id>``. ``ref`` selects one of them:

    - ``"latest"`` — the most recently modified run directory;
    - an exact run id — the trailing ``-<id>`` segment of a run's basename;
    - a substring — matched against the whole basename (e.g. part of a run_name).

    Fails loud, listing the available runs, when nothing matches or a substring is
    ambiguous, so a typo never silently resumes the wrong run. This is the run-directory
    analogue of :func:`find_latest_checkpoint` (which selects a checkpoint *within* one
    run); callers chain the two: ``find_latest_checkpoint(resolve_run_dir(root, ref))``.

    Parameters
    ----------
    runs_root : str
        The task's run root, e.g. ``logs/<task>``.
    ref : str
        ``"latest"``, an exact run id, or a basename substring.

    Returns
    -------
    str
        Path (joined onto ``runs_root``) of the resolved run directory.

    Raises
    ------
    FileNotFoundError
        If ``runs_root`` has no run directories, or ``ref`` matches none.
    ValueError
        If ``ref`` is a substring matching more than one run.
    """
    if not os.path.isdir(runs_root):
        raise FileNotFoundError(f"No runs directory {runs_root!r} to resume from — train the task first.")
    run_dirs = sorted(d for d in os.listdir(runs_root) if os.path.isdir(os.path.join(runs_root, d)))
    if not run_dirs:
        raise FileNotFoundError(f"No runs under {runs_root!r} to resume from — train the task first.")

    def _full(name: str) -> str:
        return os.path.join(runs_root, name)

    if ref == "latest":
        # Not max(run_dirs): the "%y%b%dT..." timestamp prefix (Aug/Sep/...) does not sort
        # chronologically as a string, so pick the most recently written run by mtime.
        return _full(max(run_dirs, key=lambda d: os.path.getmtime(_full(d))))

    # Exact run-id match (the trailing base-36 segment); ids are unique, so 0 or 1 hit.
    id_matches = [d for d in run_dirs if d.rsplit("-", 1)[-1] == ref]
    if id_matches:
        return _full(id_matches[0])

    sub_matches = [d for d in run_dirs if ref in d]
    if len(sub_matches) == 1:
        return _full(sub_matches[0])
    if len(sub_matches) > 1:
        raise ValueError(
            f"--resume {ref!r} is ambiguous under {runs_root!r}; it matches "
            + ", ".join(sub_matches)
            + ". Use 'latest', a full run id, or a longer substring."
        )
    raise FileNotFoundError(
        f"--resume {ref!r} matched no run under {runs_root!r}. Available runs: " + ", ".join(run_dirs)
    )

load_text

load_text(filepath: str) -> dict
Source code in eden/utils/assets.py
def load_text(filepath: str) -> dict:
    try:
        if filepath.endswith(".yaml") or filepath.endswith(".yml"):
            with open(filepath, "r") as f:
                return yaml.safe_load(f)
        elif filepath.endswith(".json"):
            with open(filepath, "r") as f:
                return json.load(f)
        else:
            raise ValueError(f"Unsupported file type: {filepath}")
    except OSError as e:
        # Provide a clearer error message while preserving the original exception context.
        raise FileNotFoundError(f"Unable to open file '{filepath}': {e}") from e

Kinematics-only robot envs

Genesis runtime helpers — transient kinematics envs for preprocessing.

Shared infrastructure for any Eden preprocessing code that needs to spin up a Genesis scene just to run forward kinematics or query a Jacobian, without the full training-loop machinery. Callers include:

  • :meth:eden.datasets.base.DatasetBase._forward_velocity_kinematics — fills in per-link velocity fields after dataset prepare.
  • :class:eden.datasets.modifiers.smooth_start_end.SmoothStartTransition — builds a single-env scene for the smooth-start IK.
  • :class:eden.datasets.modifiers.ground.GroundFirstFrame — FK of each clip's first frame to put the robot on the floor.
  • :class:eden.extensions.teleop.sonic.g1_fk.G1KeyFrameFk — a scene held open for the process's FK queries.

Classes

Functions:

transient_robot_env

transient_robot_env(robot_options: 'EntityOptions', *, num_envs: int = 1, sim_dt: float = 0.005) -> 'Iterator[tuple[EnvBase, RigidEntity]]'

Yield (env, robot) for a single-shot kinematics scene.

Destroys the env on exit. Use for preprocessing code that needs FK / Jacobian queries but doesn't run physics simulation.

PARAMETER DESCRIPTION
robot_options

Eden RobotOptions (or any EntityOptions) for the robot to spawn.

TYPE: 'EntityOptions'

num_envs

Scene env count. Most preprocessing wants 1; _forward_velocity_kinematics uses len(motion_seq.buffer_length) to vectorise over clips.

TYPE: int DEFAULT: 1

sim_dt

Doesn't affect kinematics queries, only matters for the constraint solver warmup. Set to the clip's dt when relevant, otherwise leave at the small default.

TYPE: float DEFAULT: 0.005

Wall

compile

compile

Source code in eden/utils/genesis_runtime.py
@contextmanager
def transient_robot_env(
    robot_options: "EntityOptions",
    *,
    num_envs: int = 1,
    sim_dt: float = 0.005,
) -> "Iterator[tuple[EnvBase, RigidEntity]]":
    """Yield ``(env, robot)`` for a single-shot kinematics scene.

    Destroys the env on exit. Use for preprocessing code that needs FK /
    Jacobian queries but doesn't run physics simulation.

    Parameters
    ----------
    robot_options:
        Eden ``RobotOptions`` (or any ``EntityOptions``) for the robot to
        spawn.
    num_envs:
        Scene env count. Most preprocessing wants 1; ``_forward_velocity_kinematics``
        uses ``len(motion_seq.buffer_length)`` to vectorise over clips.
    sim_dt:
        Doesn't affect kinematics queries, only matters for the constraint
        solver warmup. Set to the clip's dt when relevant, otherwise leave
        at the small default.

    Wall time: ~5–30 s on first call per Python process due to the Quadrants kernel
    compile; subsequent invocations within the same process reuse the
    compile cache.
    """
    _ensure_genesis_initialized()

    # Imports here (not at module scope) because ``EnvBase`` pulls in heavy
    # Genesis machinery — this helper should be cheap to ``from … import``
    # without paying the cost when the consumer doesn't actually need a
    # scene.
    from eden.envs.base import EnvBase
    from eden.options import EnvOptions, SceneOptions

    env = EnvBase(
        env_options=EnvOptions(num_envs=num_envs, sim_dt=sim_dt),
        scene_options=SceneOptions(robot=robot_options),
    )
    env.build()
    try:
        yield env, env.entities["robot"]
    finally:
        env.destroy()

clear_rigid_query_caches

clear_rigid_query_caches(rigid_solver) -> None

Drop the rigid solver's per-step query caches, so the next query of the step is answered afresh.

The part of Genesis' RigidSolver._restart that Eden needs, and only that part: _restart also re-arms every drone's once-per-step propeller guard, which a mid-step call must not do. Genesis exposes no public call for this, so the cache attributes are named here, in one place, and a Genesis that renamed one raises rather than leaving a cache that would be served stale.

Source code in eden/utils/genesis_runtime.py
def clear_rigid_query_caches(rigid_solver) -> None:
    """Drop the rigid solver's per-step query caches, so the next query of the step is answered afresh.

    The part of Genesis' ``RigidSolver._restart`` that Eden needs, and only that part: ``_restart`` also re-arms
    every drone's once-per-step propeller guard, which a mid-step call must not do. Genesis exposes no public
    call for this, so the cache attributes are named here, in one place, and a Genesis that renamed one raises
    rather than leaving a cache that would be served stale.
    """
    for owner_name, cache_name in _RIGID_QUERY_CACHES:
        owner = getattr(rigid_solver, owner_name, None)
        if owner is None:  # a solver with nothing of this kind built (no collider in an all-Mochi scene, say)
            continue
        cache = getattr(owner, cache_name, None)
        if not isinstance(cache, dict):
            raise RuntimeError(
                f"Genesis' {type(owner).__name__} has no per-step query cache '{cache_name}' (a dict) any more, "
                f"so eden.utils.genesis_runtime.clear_rigid_query_caches cannot drop it: this Genesis renamed or "
                "restructured it. Update _RIGID_QUERY_CACHES to the new attribute (see RigidSolver._restart in "
                "Genesis); skipping the clear would serve a cached query stale."
            )
        cache.clear()

See also