Source code for isaaclab.cloner.query

# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

"""Queries over the prototype/clone relation a :class:`~isaaclab.cloner.ClonePlan` describes.

Each plan row pairs a prototype path that exists once on the stage with a destination
template and the environments it populates, so the relation is partial in both directions: a
prototype reaches only the environments its row covers, and an environment holds only the
assets whose rows cover it. :func:`path_to_clone`, :func:`path_env_ids` and
:func:`path_to_source` are the three ways to walk it; :func:`iter_sources` is
:func:`path_to_source` for callers that need every variant behind one template.

Environment ids are not mask columns. Column ``j`` stands for
:attr:`~isaaclab.cloner.ClonePlan.env_ids`\\ ``[j]``, which is the number
:func:`~isaaclab.cloner.replicate` formats into the template. These functions take and
return environment ids throughout.

A path belongs to the nearest row containing it: the deepest prototype root, or the template
leaving the shortest suffix. Rows tying there are one asset's variants, and the environment
picks between them. ``test/cloner/test_clone_plan_algebra.py`` pins that down.

The path primitives are aliased ``pth`` because ``path`` is a parameter name here.
"""

from __future__ import annotations

from collections.abc import Iterator
from typing import TYPE_CHECKING

from . import path as pth

if TYPE_CHECKING:
    from .clone_plan import ClonePlan


def _row_env_ids(plan: ClonePlan, row: int) -> tuple[int, ...]:
    """Env ids populated from a plan row: the plan's env ids at the row's ``True`` columns."""
    columns = plan.clone_mask[row].nonzero(as_tuple=False).flatten().tolist()
    if plan.env_ids is None:
        return tuple(int(column) for column in columns)
    return tuple(int(plan.env_ids[column]) for column in columns)


def _column_for_env_id(plan: ClonePlan, env_id: int) -> int | None:
    """Mask column standing for ``env_id``, or ``None`` when the plan does not target it.

    Guards against out-of-range and negative ids, which plain indexing would raise on or
    silently wrap around.
    """
    if plan.env_ids is None:
        return env_id if 0 <= env_id < plan.clone_mask.shape[1] else None
    columns = (plan.env_ids == env_id).nonzero(as_tuple=False).flatten().tolist()
    return int(columns[0]) if columns else None


def _source_rows(plan: ClonePlan, path: str) -> list[int]:
    """Rows whose prototype subtree owns ``path``, nearest owner only, in row order."""
    rows = [
        row for row, source in enumerate(plan.sources) if "{}" in plan.destinations[row] and pth.under(path, source)
    ]
    if not rows:
        return []
    nearest = max(len(plan.sources[row].rstrip("/")) for row in rows)
    return [row for row in rows if len(plan.sources[row].rstrip("/")) == nearest]


def _clone_rows(plan: ClonePlan, path_expr: str, *, populated_only: bool) -> list[tuple[str, pth.TemplateMatch, int]]:
    """Collect ``(template, match, row)`` for the nearest destination template owning ``path_expr``.

    A shorter suffix below the template means a longer matched prefix, i.e. a nearer owner.
    The suffix does not depend on how many digits a row's env ids have, so a variant is never
    ranked out by the width of its env id.

    ``populated_only`` is the active-row policy: :func:`iter_sources` ranks only rows that
    populate an env, so a nearer but empty template cannot hide a populated ancestor, while
    :func:`path_to_source` ranks every row and filters afterwards, so an empty nearest owner
    resolves to ``None`` and its caller falls back to direct stage resolution.
    """
    candidates: list[tuple[str, pth.TemplateMatch, int]] = []
    for row, template in enumerate(plan.destinations):
        if "{}" not in template:
            continue
        if populated_only and not _row_env_ids(plan, row):
            continue
        matched = pth.match(path_expr, template)
        if matched is None:
            continue
        candidates.append((template, matched, row))
    if not candidates:
        return []
    nearest = min(len(matched.suffix) for _, matched, _ in candidates)
    return [candidate for candidate in candidates if len(candidate[1].suffix) == nearest]


def _owning_template(plan: ClonePlan, path_expr: str) -> tuple[str, list[int], pth.TemplateMatch] | None:
    """Resolve the single destination template owning ``path_expr``.

    Returns:
        ``(template, rows, match)`` where ``rows`` are all rows sharing the winning template,
        in row order, or ``None`` when no template owns ``path_expr``.

    Raises:
        ValueError: When ``path_expr`` is owned by multiple distinct, equally near templates
            (a genuine ambiguity). Nested templates do not conflict: the nearest one wins.
    """
    candidates = _clone_rows(plan, path_expr, populated_only=False)
    if not candidates:
        return None
    owning_templates = {template for template, _, _ in candidates}
    if len(owning_templates) > 1:
        raise ValueError(f"path_expr {path_expr!r}: matches multiple destination templates {sorted(owning_templates)}.")
    template, matched, _ = candidates[0]
    return template, [row for _, _, row in candidates], matched


[docs] def path_env_ids(plan: ClonePlan, path: str) -> tuple[int, ...]: """Return the environments a prototype ``path`` is replicated to. Args: plan: Active clone plan. path: Prototype path. Returns: The ascending env ids populated from ``path``'s owning rows, empty when the plan does not own ``path``. """ env_ids: set[int] = set() for row in _source_rows(plan, path): env_ids.update(_row_env_ids(plan, row)) return tuple(sorted(env_ids))
[docs] def path_to_clone(plan: ClonePlan, path: str, env_id: int) -> str | None: """Return the clone of a prototype ``path`` in one environment. Only the prototype root is swapped; everything below it is carried through unchanged. Args: plan: Active clone plan. path: Prototype path. env_id: Target environment id. Returns: The clone path in ``env_id``, or ``None`` when ``path`` is unowned, ``env_id`` is not targeted by the plan, or no owning row populates it. Where several variants share the prototype subtree, the variant populating ``env_id`` is used. """ column = _column_for_env_id(plan, env_id) if column is None: return None for row in _source_rows(plan, path): if bool(plan.clone_mask[row][column]): return pth.rebase(path, plan.sources[row], plan.destinations[row].format(env_id)) return None
[docs] def path_to_source(plan: ClonePlan, path_expr: str, env_id: int | None = None) -> tuple[str, str, str] | None: """Resolve a clone-side expression to the prototype it was cloned from. 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 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. 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. 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. Raises: ValueError: When ``path_expr`` is owned by multiple distinct, equally near templates. """ owner = _owning_template(plan, path_expr) if owner is None: return None template, rows, matched = owner if env_id is None and matched.instance.isdigit(): env_id = int(matched.instance) # Resolution must walk a prototype that exists on stage, so rows populating no env at all # are skipped rather than reported. if env_id is None: rows = [row for row in rows if plan.clone_mask[row].any()] else: column = _column_for_env_id(plan, env_id) if column is None: return 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
[docs] def iter_sources(plan: ClonePlan, path_expr: str) -> Iterator[tuple[str, str, str, tuple[int, ...]]]: """Yield every populated plan row whose destination owns a path expression. Where :func:`path_to_source` names one variant, this yields them all, for callers that must visit each prototype behind a destination template (loading one mesh per variant). 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", "/World/source/Robot/base", (0, 2))``. Args: plan: Clone plan to query. path_expr: Clone-side prim path or path expression. Yields: ``(source_root, destination_template, source_path, env_ids)`` per row of the nearest owning template, in row order. Rows populating no env are skipped. """ for template, matched, row in _clone_rows(plan, path_expr, populated_only=True): template_norm = template.rstrip("/") or "/" source_root = plan.sources[row].rstrip("/") or "/" source_path = source_root + matched.suffix if source_root != "/" else matched.suffix or "/" yield source_root, template_norm, source_path, _row_env_ids(plan, row)