Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
Added
^^^^^

* Added :attr:`~isaaclab.cloner.CloneCfg.clone_template` for the replicated environment prim path,
with ``{}`` marking the environment index. It replaces ``CloneCfg.clone_regex``, whose value is
now ``clone_template.format("[^/]+")``.
* Added an ``env_template`` argument to :func:`~isaaclab.cloner.make_clone_plan` and
:class:`~isaaclab.cloner.ReplicateSession`.
* Added :func:`~isaaclab.sim.utils.path_expr_to_glob` and
:func:`~isaaclab.sim.utils.split_path_expr`, for converting a prim path expression to the glob
the physics engines accept and for splitting one without cutting a character class in half.
* Added :func:`~isaaclab.cloner.expand_env_regex_ns`, and applied it when an asset or a sensor is
constructed. ``{ENV_REGEX_NS}`` previously only resolved for assets a
:class:`~isaaclab.scene.InteractiveScene` collected, so a direct environment -- which builds its
own -- had to spell the namespace out. Either kind may now use the macro, and no configuration
has to name the wildcard that selects one environment.

Changed
^^^^^^^

* **Breaking:** Changed :func:`~isaaclab.sim.utils.find_matching_prims` to match the whole prim
path as a plain regular expression instead of one token per path segment. ``.`` now matches
``/``, so ``/World/Robot/.*`` selects descendants at any depth; use ``[^/]+`` for a single
segment.
* Changed :func:`~isaaclab.sim.utils.find_first_matching_prim` to delegate to
:func:`~isaaclab.sim.utils.find_matching_prims`, so both read an expression the same way.
* Changed the environment namespace to spell its slot ``[^/]+`` rather than ``.*``, so
``{ENV_REGEX_NS}/Robot`` no longer also selects a ``Robot`` nested deeper under an environment.
* Changed :func:`~isaaclab.cloner.path.match` to accept a character class in the clone slot, so a
segment-safe namespace resolves against a destination template.

* Changed prim path expressions throughout the repository to spell a single path segment
``[^/]`` rather than ``.``, so each pattern selects what it selected before now that ``.``
matches ``/``.

Removed
^^^^^^^

* Removed the legacy glob-wildcard rewrite from prim path expressions. A bare ``*`` is a regular
expression quantifier and is no longer rewritten to ``.*``; the rewrite could not tell a glob
star from a quantifier and corrupted ``[^/]*`` into ``[^/].*``. Patterns relying on ``*`` as a
standalone wildcard should spell it ``.*`` (any depth) or ``[^/]*`` (one path segment).

Fixed
^^^^^

* Fixed :func:`~isaaclab.cloner.make_clone_plan` raising ``IndexError`` for a prim path holding
more than one wildcard, and ignoring a non-default environment namespace.
* Fixed :class:`~isaaclab.sensors.MultiMeshRayCaster` expanding ``{ENV_REGEX_NS}`` with a
hardcoded namespace instead of the shared default.
* Fixed callers that split a prim path expression on ``/`` cutting a ``[^/]`` character class in
half, which raised ``re.error: unterminated character set`` or produced a truncated body name.
* Fixed :func:`~isaaclab.sim.spawn_multi_asset` rejecting an index slot spelled ``[^/]*``; the
slot is now any segment wildcard rather than a literal ``.*``.
* Fixed callers that substituted a concrete environment index into a path expression by matching
one spelling of the environment slot, so a namespace written with a different quantifier was
left unresolved: the visualizer camera view, and the deformable render bindings.
* Fixed :func:`~isaaclab.cloner.query.path_to_source` reporting its destination as a glob, which
matched nothing when a caller used it as the path expression its name promises.
5 changes: 5 additions & 0 deletions source/isaaclab/isaaclab/assets/asset_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import isaaclab.sim as sim_utils
from isaaclab.cloner import queue_replication
from isaaclab.cloner.cloner_cfg import expand_env_regex_ns
from isaaclab.physics import PhysicsEvent, PhysicsManager
from isaaclab.sim.simulation_context import SimulationContext
from isaaclab.sim.utils.stage import get_current_stage
Expand Down Expand Up @@ -97,6 +98,10 @@ def __init__(self, cfg: AssetBaseCfg):
"""
# check that the config is valid
cfg.validate()
# expand the namespace macro before the cfg is queued, so the clone plan keys its rows
# by a real path expression. The scene has already done this for the assets it collects;
# this covers the ones a direct environment builds itself.
cfg.prim_path = expand_env_regex_ns(cfg.prim_path)
# register the original cfg object for cloning: the clone plan keys rows by the
# cfg identity the scene collected; contexts and policy resolve at replication time
queue_replication(cfg)
Expand Down
2 changes: 1 addition & 1 deletion source/isaaclab/isaaclab/assets/asset_base_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class InitialStateCfg:
The expression can contain the environment namespace regex ``{ENV_REGEX_NS}`` which
will be replaced with the environment namespace.

Example: ``{ENV_REGEX_NS}/Robot`` will be replaced with ``/World/envs/env_.*/Robot``.
Example: ``{ENV_REGEX_NS}/Robot`` will be replaced with ``/World/envs/env_[^/]+/Robot``.
"""

spawn: SpawnerCfg | None = None
Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/isaaclab/cloner/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ __all__ = [
"add",
"clone_plan_from_env_0",
"disabled_fabric_change_notifies",
"expand_env_regex_ns",
"filter_collisions",
"grid_transforms",
"make_clone_plan",
Expand Down Expand Up @@ -37,7 +38,7 @@ from .clone_plan import (
make_valid_clone_combinations,
num_spawn_variants,
)
from .cloner_cfg import CloneCfg, InclusionSet, add
from .cloner_cfg import CloneCfg, InclusionSet, add, expand_env_regex_ns
from .cloner_strategies import random, sequential
from .collision_filter import filter_collisions
from .replicate_session import (
Expand Down
26 changes: 21 additions & 5 deletions source/isaaclab/isaaclab/cloner/clone_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import itertools
import math
import re
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field
from typing import Any
Expand All @@ -30,7 +31,7 @@

import isaaclab.sim as sim_utils

from .cloner_cfg import InclusionSet
from .cloner_cfg import DEFAULT_ENV_TEMPLATE, InclusionSet
from .cloner_strategies import sequential
from .path import split

Expand Down Expand Up @@ -224,6 +225,7 @@ def make_clone_plan(
*,
clone_strategy: Callable = sequential,
valid_set: torch.Tensor | None = None,
env_template: str = DEFAULT_ENV_TEMPLATE,
) -> ClonePlan:
"""Build a :class:`ClonePlan` from asset cfgs.

Expand Down Expand Up @@ -266,21 +268,35 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None:
raise ValueError("Single spawner expects exactly one planned source path.")
spawn_cfg.spawn_path = active[0]

env_root_marker = "/World/envs/"
env_template = "/World/envs/env_{}"
# the env root sits at a known depth, so the destination is the env template plus whatever the
# cfg authored below it -- no need to find the clone slot by substituting in the cfg's own path
env_prefix, _ = split(env_template)

def env_destination(prim_path: str) -> str | None:
"""Rebase an env-scoped cfg path onto the env template, or None when it is global.

The cfg may name one environment (``env_0``) or all of them (``env_.*``, ``env_[^/]+``);
only the text before the slot is fixed, so that is what identifies an env-scoped path.
"""
if not prim_path.startswith(env_prefix):
return None
# blank out character classes so a '/' inside one does not read as a separator; the
# replacement is the same length, so the index carries back to the original string
masked = re.sub(r"\[\^?[^]]*\]", lambda match: "\x00" * len(match.group()), prim_path)
cut = masked.find("/", len(env_prefix))
return env_template if cut == -1 else env_template + prim_path[cut:]

# 1) Build per-group records: (cfg, spawn_cfg, destination_template, num_variants).
groups: list[tuple[Any, Any, str, int]] = []
for cfg in cfgs:
if not hasattr(cfg, "prim_path") or not hasattr(cfg, "spawn") or cfg.spawn is None:
continue
prim_path = cfg.prim_path
if env_root_marker not in prim_path:
if (destination := env_destination(prim_path)) is None:
continue
count = num_spawn_variants(cfg.spawn)
if count <= 0:
raise ValueError(f"Spawner at '{prim_path}' must have at least one variant.")
destination = prim_path.replace(".*", "{}")
groups.append((cfg, cfg.spawn, destination, count))

env_ids = torch.arange(num_clones, dtype=torch.long, device=device)
Expand Down
30 changes: 28 additions & 2 deletions source/isaaclab/isaaclab/cloner/cloner_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@

from .cloner_strategies import sequential

DEFAULT_ENV_TEMPLATE = "/World/envs/env_{}"
"""Default path template for a replicated env prim; ``{}`` marks the environment index."""


def expand_env_regex_ns(path_expr: str, env_template: str = DEFAULT_ENV_TEMPLATE) -> str:
"""Replace the ``{ENV_REGEX_NS}`` macro with the environment namespace it stands for.

The macro spares a configuration from spelling the namespace, and with it the segment
wildcard that names one environment. :class:`~isaaclab.scene.InteractiveScene` expands it
against its own template for the assets it collects; assets built outside the scene (a
direct environment builds its own) go through here instead.

Args:
path_expr: Prim path expression, with or without the macro.
env_template: Environment path template whose ``{}`` marks the environment index.

Returns:
``path_expr`` with the macro replaced, unchanged when it holds no macro.
"""
# a plain replace, not str.format: the rest of the expression may hold braces of its own
return path_expr.replace("{ENV_REGEX_NS}", env_template.format("[^/]+"))


@configclass
class InclusionSet:
Expand Down Expand Up @@ -46,8 +68,12 @@ class CloneCfg:
device: str = "cpu"
"""Torch device on which mapping buffers are allocated."""

clone_regex: str = "/World/envs/env_.*"
"""Regex matching every replicated env prim. Used to expand ``{ENV_REGEX_NS}`` cfg macros."""
clone_template: str = DEFAULT_ENV_TEMPLATE
Comment thread
ooctipus marked this conversation as resolved.
"""Path template for every replicated env prim, where ``{}`` is the environment index.

The regex form used to expand ``{ENV_REGEX_NS}`` cfg macros is
``clone_template.format("[^/]+")``, which confines the slot to one path segment.
"""

replicate_physics: bool = True
"""Whether physics replication clones each environment. Default is True.
Expand Down
9 changes: 6 additions & 3 deletions source/isaaclab/isaaclab/cloner/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ def split(template: str) -> tuple[str, str]:
def match(path_expr: str, template: str) -> TemplateMatch | None:
"""Match ``path_expr`` against a destination template, capturing the instance slot.

The ``"{}"`` slot matches one path segment's worth of text, whether a concrete id (``3``)
or a wildcard (``.*``). Recovering that text is the only way to tell which instance a
The ``"{}"`` slot matches one path segment's worth of text: a concrete id (``3``) or a
wildcard standing for one segment (``.*``, ``[^/]+``). Recovering that text is the only way to tell which instance a
concrete clone path belongs to without slicing the string by hand.

Args:
Expand All @@ -73,7 +73,10 @@ def match(path_expr: str, template: str) -> TemplateMatch | None:
TemplateMatch(instance='3', suffix='/base')
"""
prefix, template_suffix = split(template)
pattern = re.compile(re.escape(prefix) + r"([^/]+)" + re.escape(template_suffix))
# the slot holds one segment's worth of text: a concrete id, or a wildcard standing for one.
# A segment-safe wildcard is written as a character class, whose text contains a '/' that is
# not a separator, so it is matched as a class rather than by the one-segment alternative.
pattern = re.compile(re.escape(prefix) + r"(\[\^?[^]]*\][*+?]?|[^/]+)" + re.escape(template_suffix))
matched = pattern.match(path_expr)
if matched is None:
return None
Expand Down
21 changes: 11 additions & 10 deletions source/isaaclab/isaaclab/cloner/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,25 +162,26 @@ def path_to_source(plan: ClonePlan, path_expr: str, env_id: int | None = None) -
A *concrete* clone path names its environment in the template's clone slot, and that
environment selects which variant to report — which is what lets this undo
:func:`path_to_clone` for a heterogeneous asset. A *wildcard* expression
(``.../env_.*/...``) names no environment and stands for all of them, so it resolves to
(``.../env_[^/]+/...``) names no environment and stands for all of them, so it resolves to
the first populated variant unless ``env_id`` says which one to take.

Args:
plan: Active clone plan.
path_expr: Clone-side path expression (e.g. a sensor's ``prim_path``, with ``.*`` env
wildcard) or a concrete clone path.
path_expr: Clone-side path expression (e.g. a sensor's ``prim_path``, with a segment
wildcard in the env slot) or a concrete clone path.
env_id: Environment whose variant to resolve. Defaults to the one ``path_expr`` names
when it is concrete, and to no particular environment otherwise.

Returns:
A ``(source_path, destination_glob, asset_suffix)`` tuple, where ``asset_suffix`` is
the part of ``path_expr`` below the owning template. ``None`` when ``path_expr``
matches no row, or no matching row populates the requested environment, letting
callers fall back to direct stage resolution.
A ``(source_path, destination_expr, asset_suffix)`` tuple, where ``destination_expr``
spells the clone slot ``[^/]+`` so it reads as a path expression like every other one,
and ``asset_suffix`` is the part of ``path_expr`` below the owning template. ``None``
when ``path_expr`` matches no row, or no matching row populates the requested
environment, letting callers fall back to direct stage resolution.

Partial-env coverage is supported: when the matching rows cover only a subset of envs
(an asset present in some envs but not others, as in heterogeneous scenes), the
returned glob resolves to just those envs.
returned expression resolves to just those envs.

Raises:
ValueError: When ``path_expr`` is owned by multiple distinct, equally near templates.
Expand All @@ -202,7 +203,7 @@ def path_to_source(plan: ClonePlan, path_expr: str, env_id: int | None = None) -
rows = [row for row in rows if bool(plan.clone_mask[row][column])]
if not rows:
return None
return plan.sources[rows[0]], template.replace("{}", "*"), matched.suffix
return plan.sources[rows[0]], template.format("[^/]+"), matched.suffix


def iter_sources(plan: ClonePlan, path_expr: str) -> Iterator[tuple[str, str, str, tuple[int, ...]]]:
Expand All @@ -214,7 +215,7 @@ def iter_sources(plan: ClonePlan, path_expr: str) -> Iterator[tuple[str, str, st
Example:
For a row with prototype root ``"/World/source/Robot"``, destination template
``"/World/scenes/{}/Robot"`` and env ids ``(0, 2)``, querying
``"/World/scenes/.*/Robot/base"`` yields ``("/World/source/Robot",
``"/World/scenes/[^/]*/Robot/base"`` yields ``("/World/source/Robot",
"/World/scenes/{}/Robot", "/World/source/Robot/base", (0, 2))``.

Args:
Expand Down
4 changes: 4 additions & 0 deletions source/isaaclab/isaaclab/cloner/replicate_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from isaaclab.utils.version import has_kit

from .clone_plan import make_clone_plan
from .cloner_cfg import DEFAULT_ENV_TEMPLATE
from .cloner_strategies import sequential
from .usd import UsdReplicateContext

Expand Down Expand Up @@ -142,6 +143,7 @@ def __init__(
clone_strategy: Callable = sequential,
valid_set: torch.Tensor | None = None,
replicate_physics: bool = True,
env_template: str = DEFAULT_ENV_TEMPLATE,
):
"""Capture arguments for :func:`make_clone_plan` and :func:`replicate`.

Expand All @@ -156,6 +158,7 @@ def __init__(
prototype combinations; ``None`` uses the full cartesian product.
replicate_physics: Whether physics replication clones each environment;
forwarded to :func:`replicate`.
env_template: Path template for a replicated env prim, ``{}`` marking the env index.
"""
self._cfgs = cfgs
self._stage = stage
Expand All @@ -166,6 +169,7 @@ def __init__(
device=device,
clone_strategy=clone_strategy,
valid_set=valid_set,
env_template=env_template,
)
self._plan: ClonePlan | None = None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import isaaclab.utils.math as math_utils
import isaaclab.utils.string as string_utils
from isaaclab.assets.articulation import Articulation
from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE
from isaaclab.controllers.differential_ik import DifferentialIKController
from isaaclab.controllers.operational_space import OperationalSpaceController
from isaaclab.managers.action_manager import ActionTerm
Expand Down Expand Up @@ -355,7 +356,9 @@ def has_rigid_body_api(prim) -> bool:
if not rigid_matches:
raise ValueError(f"No descendant rigid body found under the expression: '{self._asset.cfg.prim_path}'.")
_, root_rigidbody_path = rigid_matches[0]
task_frame_transformer_path = "/World/envs/env_.*/" + self.cfg.task_frame_rel_path
# this cfg is built and initialized here rather than by the scene, so the namespace
# macro would never be expanded -- name the namespace directly.
task_frame_transformer_path = DEFAULT_ENV_TEMPLATE.format("[^/]+") + "/" + self.cfg.task_frame_rel_path
task_frame_transformer_cfg = FrameTransformerCfg(
prim_path=root_rigidbody_path,
target_frames=[
Expand Down
12 changes: 8 additions & 4 deletions source/isaaclab/isaaclab/envs/utils/camera_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging
import math
import random
import re
from typing import Any

import numpy as np
Expand Down Expand Up @@ -72,6 +73,9 @@ def resolve_mono_env_index(num_envs: int) -> list[int]:
return [0] if num_envs > 0 else []


_ENV_SLOT_WILDCARD = re.compile(r"env_(?:\[\^/\][*+]|\.\*)")


def env_path_from_template(path_template: str, env_id: int) -> str:
"""Resolve common env wildcard/template spellings to a concrete env path."""
path = path_template
Expand All @@ -80,9 +84,9 @@ def env_path_from_template(path_template: str, env_id: int) -> str:
if "{}" in path:
return path.format(env_id)
path = path.replace("/World/envs/*", f"/World/envs/env_{env_id}")
path = path.replace("/World/envs/env_.*", f"/World/envs/env_{env_id}")
path = path.replace("/World/envs/env_.*/", f"/World/envs/env_{env_id}/")
return path
# the env slot is a segment wildcard; match every spelling rather than one, so a namespace
# written with a different quantifier still resolves to a concrete env.
return _ENV_SLOT_WILDCARD.sub(f"env_{env_id}", path)


def _camera_concrete_paths(camera: Camera) -> list[str]:
Expand Down Expand Up @@ -370,7 +374,7 @@ def create_visualizer_camera(
attr = cam_prim.CreateAttribute("omni:scenePartition", Sdf.ValueTypeNames.Token)
attr.Set(path.split("/")[-2])
cfg = CameraCfg(
prim_path=f"/World/envs/env_.*/{camera_name}",
prim_path=f"/World/envs/env_[^/]+/{camera_name}",
update_period=0.0,
height=int(height),
width=int(width),
Expand Down
Loading
Loading