From 1c55dea9aed1c1466b7b680f2ea670505dbf7ddb Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 5 Jul 2026 22:17:25 +0800 Subject: [PATCH 01/18] Add light type expansion design spec Co-Authored-By: Claude --- .../specs/2026-07-05-light-types-design.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-light-types-design.md diff --git a/docs/superpowers/specs/2026-07-05-light-types-design.md b/docs/superpowers/specs/2026-07-05-light-types-design.md new file mode 100644 index 00000000..1ea20db1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-light-types-design.md @@ -0,0 +1,217 @@ +# Light Type Expansion — Design Spec + +**Date:** 2026-07-05 +**Status:** approved +**Scope:** Core pipeline — `LightCfg`, `Light` class, `SimulationManager.add_light` + +## Motivation + +EmbodiChain currently supports only `point` lights via `LightCfg(light_type="point")`. The dexsim rendering backend exposes 6 light types via Python bindings (`POINT`, `SUN`, `DIRECTION`, `SPOT`, `RECT`, `MESH`), each with distinct properties. The existing `LightCfg` has an explicit TODO: *"to be added more light type, such as spot, sun, etc."* + +This spec covers adding support for all 6 backend light types to the core configuration and runtime API. Gym-layer config (`EnvLightCfg`) and light randomization are out of scope for this round. + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Config structure | Single `LightCfg` with flat optional fields | Simpler, backward-compatible, `from_dict` works automatically | +| Runtime API | Single `Light` class with all setters | Warns on incompatible type; avoids class explosion | +| Parametric vs asset lights | Two-tier: 5 parametric types get tensor batching; `mesh` is string-set-only | Aligns with Isaac Lab's design philosophy — mesh lights are emissive geometry, not parametric primitives | +| Error handling | Warn on misconfiguration, error only on unknown type | Lets users iterate without crashes | + +## Light Types + +| Type | Category | Key Properties | +|---|---|---| +| `point` | Parametric | position, falloff (radius) | +| `sun` | Parametric | position, direction, angular_radius, halo_size, halo_falloff | +| `direction` | Parametric | direction only (infinite distance, no position) | +| `spot` | Parametric | position, direction, spot_angle_inner, spot_angle_outer | +| `rect` | Parametric | position, direction, rect_width, rect_height | +| `mesh` | Asset | position, direction, mesh_path (set-once, no tensor batching) | + +## `LightCfg` — Config Fields + +**File:** `embodichain/lab/sim/cfg.py` + +All fields are flat on the existing `LightCfg` class. Every new field has a default — existing point-light code works unchanged. + +```python +@configclass +class LightCfg(ObjectBaseCfg): + light_type: Literal["point", "sun", "direction", "spot", "rect", "mesh"] = "point" + + # Universal + color: tuple[float, float, float] = (1.0, 1.0, 1.0) + intensity: float = 30.0 + enable_shadow: bool = True + + # Point light + radius: float = 10.0 + + # Directional (sun, direction, spot, rect, mesh) + direction: tuple[float, float, float] = (0.0, 0.0, -1.0) + + # Sun + angular_radius: float = 0.5 + halo_size: float = 10.0 + halo_falloff: float = 3.0 + + # Spot + spot_angle_inner: float = 30.0 + spot_angle_outer: float = 45.0 + + # Rect + rect_width: float = 1.0 + rect_height: float = 1.0 + + # Mesh + mesh_path: str = "" +``` + +### Field Applicability Matrix + +| Field | point | sun | direction | spot | rect | mesh | +|---|---|---|---|---|---|---| +| `color` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `intensity` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `enable_shadow` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `radius` | ✓ | — | — | — | — | — | +| `direction` | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| `angular_radius` | — | ✓ | — | — | — | — | +| `halo_size` | — | ✓ | — | — | — | — | +| `halo_falloff` | — | ✓ | — | — | — | — | +| `spot_angle_inner` | — | — | — | ✓ | — | — | +| `spot_angle_outer` | — | — | — | ✓ | — | — | +| `rect_width` | — | — | — | — | ✓ | — | +| `rect_height` | — | — | — | — | ✓ | — | +| `mesh_path` | — | — | — | — | — | ✓ | + +## `Light` Class — Runtime API + +**File:** `embodichain/lab/sim/objects/light.py` + +### Existing Methods (unchanged signature) + +- `set_color(colors, env_ids=None)` — vector3 broadcast +- `set_intensity(intensities, env_ids=None)` — scalar broadcast +- `set_falloff(falloffs, env_ids=None)` — scalar broadcast (point only, warns otherwise) +- `set_local_pose(pose, env_ids=None, to_matrix=False)` — updated for directional types +- `get_local_pose(to_matrix=False)` — unchanged +- `reset(env_ids=None)` — updated to apply type-specific properties + +### New Methods + +| Method | Tensor Shape | Broadcast Pattern | Applies To | +|---|---|---|---| +| `set_direction(directions, env_ids=None)` | (3,) or (M, 3) | `_apply_vector3` | sun, direction, spot, rect, mesh | +| `set_spot_angle(inner, outer, env_ids=None)` | scalar each | `_apply_scalar` × 2 | spot | +| `set_angular_radius(radii, env_ids=None)` | scalar or (M,) | `_apply_scalar` | sun | +| `set_halo_size(sizes, env_ids=None)` | scalar or (M,) | `_apply_scalar` | sun | +| `set_halo_falloff(falloffs, env_ids=None)` | scalar or (M,) | `_apply_scalar` | sun | +| `set_rect_size(widths, heights, env_ids=None)` | scalar each | `_apply_scalar` × 2 | rect | +| `set_mesh_path(path, env_ids=None)` | `str` | single string, no tensor | mesh | +| `enable_shadow(flags, env_ids=None)` | scalar or (M,) | `_apply_scalar` | all | + +### Runtime Validation Pattern + +Each setter checks `self.cfg.light_type` and warns + no-ops if the property doesn't apply: + +```python +def set_spot_angle(self, inner, outer, env_ids=None): + if self.cfg.light_type != "spot": + logger.warning( + f"set_spot_angle not applicable to light type '{self.cfg.light_type}', ignoring." + ) + return + self._apply_scalar(inner, env_ids, "set_spot_angle_inner") + self._apply_scalar(outer, env_ids, "set_spot_angle_outer") +``` + +### `reset()` Behavior + +`reset()` applies only the properties relevant to `self.cfg.light_type`: + +- **point:** color, intensity, radius, init_pos, shadow +- **sun:** color, intensity, init_pos, direction, angular_radius, halo_size, halo_falloff, shadow +- **direction:** color, intensity, direction, shadow +- **spot:** color, intensity, init_pos, direction, spot_angle_inner, spot_angle_outer, shadow +- **rect:** color, intensity, init_pos, direction, rect_width, rect_height, shadow +- **mesh:** color, intensity, init_pos, direction, mesh_path, shadow + +## `SimulationManager.add_light` — Backend Mapping + +**File:** `embodichain/lab/sim/sim_manager.py` + +### Type Mapping + +```python +_LIGHT_TYPE_MAP: dict[str, LightType] = { + "point": LightType.POINT, + "sun": LightType.SUN, + "direction": LightType.DIRECTION, + "spot": LightType.SPOT, + "rect": LightType.RECT, + "mesh": LightType.MESH, +} +``` + +### Creation Flow + +1. Validate `cfg.light_type` against `_LIGHT_TYPE_MAP` — error on unknown type +2. Create one backend light per env via `env.create_light(f"{uid}_{i}", light_type)` +3. Apply initial properties from `cfg` based on light type (see `reset()` table above) +4. Wrap in `Light(cfg=cfg, entities=light_list)` and store in `self._lights[uid]` + +### Validation Warnings (at creation time) + +| Condition | Action | +|---|---| +| Unknown `light_type` | `logger.log_error`, return None | +| `mesh` with empty `mesh_path` | `logger.warning` | +| `rect` with zero `rect_width` or `rect_height` | `logger.warning` | + +## Backward Compatibility + +All existing call sites continue to work without modification: + +- `LightCfg(uid="light", intensity=10.0, init_pos=(0, 0, 2.0))` — `light_type` defaults to `"point"` +- `LightCfg.from_dict({"light_type": "point", ...})` — flat fields map 1:1 +- `sim.add_light(cfg=LightCfg(...))` — unchanged API +- `light.set_color(...)`, `light.set_intensity(...)`, `light.set_falloff(...)` — unchanged +- `light.set_local_pose(...)`, `light.get_local_pose(...)` — unchanged for point lights + +## Out of Scope + +- `EnvLightCfg` gym-layer changes +- Light randomization (`visual.py`) for new light types +- `indirect` lighting changes (IBL, emission light — already supported) +- USD import/export for new light types + +## Testing + +**File:** `tests/sim/objects/test_light.py` (extend) + +| Test | Coverage | +|---|---| +| `test_point_light_backward_compat` | Existing tests pass unchanged | +| `test_create_all_light_types` | All 6 types created via `add_light` | +| `test_light_type_specific_properties` | Type-specific props applied at creation | +| `test_incompatible_setter_warns` | Wrong-type setter logs warning, no-ops | +| `test_unknown_light_type_errors` | Invalid type logs error | +| `test_directional_light_no_position` | `set_local_pose` on direction warns | +| `test_mesh_light_empty_path_warns` | Empty `mesh_path` warns at creation | +| `test_reset_applies_type_specific_props` | `reset()` restores only relevant props | +| `test_set_direction_broadcasting` | (3,) tensor broadcasts to all instances | +| `test_from_dict_new_types` | `from_dict` works with new fields | +| `test_set_rect_size` | (M, 2) tensor per-instance | +| `test_set_spot_angle` | Inner/outer tensor per-instance | + +## Files Changed + +| File | Change | +|---|---| +| `embodichain/lab/sim/cfg.py` | Expand `LightCfg` fields | +| `embodichain/lab/sim/objects/light.py` | Add setters, update `reset()` | +| `embodichain/lab/sim/sim_manager.py` | Expand type mapping in `add_light` | +| `tests/sim/objects/test_light.py` | Add tests for new types | From b34bbcdb671a85bc08984387e7381159cd6a641b Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 5 Jul 2026 22:32:55 +0800 Subject: [PATCH 02/18] Add light type expansion implementation plan Co-Authored-By: Claude --- .../plans/2026-07-05-light-types-plan.md | 956 ++++++++++++++++++ 1 file changed, 956 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-light-types-plan.md diff --git a/docs/superpowers/plans/2026-07-05-light-types-plan.md b/docs/superpowers/plans/2026-07-05-light-types-plan.md new file mode 100644 index 00000000..68956870 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-light-types-plan.md @@ -0,0 +1,956 @@ +# Light Type Expansion — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Support all 6 dexsim light types (point, sun, direction, spot, rect, mesh) in EmbodiChain's core pipeline. + +**Architecture:** Expand the flat `LightCfg` with optional type-specific fields, add typed setters to the `Light` BatchEntity class with runtime validation, and map all 6 types in `SimulationManager.add_light`. Backward compatible — existing point-light code works unchanged. + +**Tech Stack:** Python 3.10+, PyTorch, dexsim Python bindings, `@configclass` decorator, `BatchEntity` pattern + +## Global Constraints + +- `intensity` default: `30.0` (was `50.0`) +- `radius` default: `10.0` (was `1e2`) +- Backend method names: `set_rect_wh` (not `set_rect_size`), `set_spot_angle(inner, outer)`, `set_direction(x, y, z)`, `set_mesh(MeshObject)`, `set_shadow(bool)` +- `set_angular_radius`, `set_halo_size`, `set_halo_falloff` are NOT exposed in Python bindings — config fields exist but setters are deferred +- `set_mesh` takes a `dexsim.models.MeshObject`, not a string — mesh lights are asset-based, not tensor-batched +- All new fields have defaults; backward compatibility is mandatory +- Runtime validation: warn on incompatible setter calls, error only on unknown light type + +--- + +### Task 1: Expand `LightCfg` fields + +**Files:** +- Modify: `embodichain/lab/sim/cfg.py:794-810` + +**Interfaces:** +- Produces: `LightCfg` with expanded `light_type` literal and new optional fields + +- [ ] **Step 1: Replace the `LightCfg` class definition** + +Replace lines 794-810 in `embodichain/lab/sim/cfg.py`: + +```python +@configclass +class LightCfg(ObjectBaseCfg): + """Configuration for a light asset in the simulation. + + Supports six light types matching the dexsim rendering backend: + + - ``"point"``: Omnidirectional point light with position and falloff radius. + - ``"sun"``: Directional sun light with position, direction, and angular radius (sun-specific setters like halo are not yet wired through Python bindings). + - ``"direction"``: Pure directional light at infinite distance (direction only, no position). + - ``"spot"``: Spotlight with position, direction, and inner/outer cone angles. + - ``"rect"``: Rectangular area light with position, direction, width, and height. + - ``"mesh"``: Mesh-based emissive light (requires a MeshObject via :meth:`set_mesh`; not tensor-batched). + + .. attention:: + The ``angular_radius``, ``halo_size``, and ``halo_falloff`` fields are + reserved for future use. The dexsim Python bindings do not yet expose + setters for these sun-specific properties. + """ + + light_type: Literal["point", "sun", "direction", "spot", "rect", "mesh"] = "point" + """Light type. Supported: ``"point"``, ``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, ``"mesh"``.""" + + # ------------------------------------------------------------------ + # Universal properties (apply to all light types) + # ------------------------------------------------------------------ + + color: tuple[float, float, float] = (1.0, 1.0, 1.0) + """RGB color of the light source. Defaults to white ``(1.0, 1.0, 1.0)``.""" + + intensity: float = 30.0 + """Intensity of the light source in watts/m^2. Defaults to ``30.0``.""" + + enable_shadow: bool = True + """Whether the light casts shadows. Defaults to ``True``.""" + + # ------------------------------------------------------------------ + # Point light + # ------------------------------------------------------------------ + + radius: float = 10.0 + """Falloff radius for point lights. Only used when ``light_type="point"``. Defaults to ``10.0``.""" + + # ------------------------------------------------------------------ + # Directional properties (sun, direction, spot, rect, mesh) + # ------------------------------------------------------------------ + + direction: tuple[float, float, float] = (0.0, 0.0, -1.0) + """Direction vector for directional, spot, rect, and mesh lights. + Defaults to ``(0.0, 0.0, -1.0)`` (pointing down along -Z).""" + + # ------------------------------------------------------------------ + # Sun light (reserved — Python bindings not yet available) + # ------------------------------------------------------------------ + + angular_radius: float = 0.5 + """Angular radius of the sun disc in degrees. Reserved for future use.""" + + halo_size: float = 10.0 + """Halo size for sun light. Reserved for future use.""" + + halo_falloff: float = 3.0 + """Halo falloff for sun light. Reserved for future use.""" + + # ------------------------------------------------------------------ + # Spot light + # ------------------------------------------------------------------ + + spot_angle_inner: float = 30.0 + """Inner cone angle of the spotlight in degrees. Only used when ``light_type="spot"``. + Defaults to ``30.0``.""" + + spot_angle_outer: float = 45.0 + """Outer cone angle of the spotlight in degrees. Only used when ``light_type="spot"``. + Defaults to ``45.0``.""" + + # ------------------------------------------------------------------ + # Rect light + # ------------------------------------------------------------------ + + rect_width: float = 1.0 + """Width of the rectangular area light. Only used when ``light_type="rect"``. + Defaults to ``1.0``.""" + + rect_height: float = 1.0 + """Height of the rectangular area light. Only used when ``light_type="rect"``. + Defaults to ``1.0``.""" + + # ------------------------------------------------------------------ + # Mesh light + # ------------------------------------------------------------------ + + mesh_path: str = "" + """Asset path for mesh-based emissive lights. Only used when ``light_type="mesh"``. + The actual mesh assignment is done via :meth:`Light.set_mesh` which accepts a + :class:`dexsim.models.MeshObject`. This field stores the path for reference.""" +``` + +- [ ] **Step 2: Verify the module imports correctly** + +```bash +cd /home/dex/workspace/sources/EmbodiChain && python -c "from embodichain.lab.sim.cfg import LightCfg; print(LightCfg()); print(LightCfg(light_type='spot'))" +``` + +Expected output: Two LightCfg instances printed without errors. + +- [ ] **Step 3: Commit** + +```bash +git add embodichain/lab/sim/cfg.py +git commit -m "feat: expand LightCfg to support 6 light types + +Add optional fields for sun, direction, spot, rect, and mesh light types. +Change default intensity to 30.0 and default radius to 10.0. +All new fields have defaults — existing point-light code works unchanged. + +Co-Authored-By: Claude " +``` + +--- + +### Task 2: Add new setters to `Light` class and update `reset()` + +**Files:** +- Modify: `embodichain/lab/sim/objects/light.py` + +**Interfaces:** +- Consumes: `LightCfg` with expanded fields (from Task 1) +- Produces: New methods on `Light` — `set_direction`, `set_spot_angle`, `set_rect_wh`, `set_mesh`, `enable_shadow`; updated `reset()` + +- [ ] **Step 1: Add new setter methods to the `Light` class** + +Insert these methods after the existing `set_falloff` method (after line 88 in the current file) and before `set_local_pose`: + +```python + def set_direction( + self, directions: torch.Tensor, env_ids: Sequence[int] | None = None + ) -> None: + """Set direction for directional-type lights. + + Only applies to ``sun``, ``direction``, ``spot``, ``rect``, and ``mesh`` + light types. Logs a warning and no-ops for other types. + + Args: + directions (torch.Tensor): Tensor of shape (3,) or (M, 3), representing + (x, y, z) direction vectors. + env_ids (Sequence[int] | None): Indices of instances to set. If None: + - For shape (3,), applies to all instances. + - For shape (M, 3), M must equal num_instances, applies per-instance. + """ + if self.cfg.light_type not in ("sun", "direction", "spot", "rect", "mesh"): + logger.warning( + f"set_direction not applicable to light type " + f"'{self.cfg.light_type}', ignoring." + ) + return + self._apply_vector3(directions, env_ids, "set_direction") + + def set_spot_angle( + self, + inner_angles: torch.Tensor, + outer_angles: torch.Tensor, + env_ids: Sequence[int] | None = None, + ) -> None: + """Set inner and outer cone angles for spot lights. + + Only applies to ``spot`` light type. Logs a warning and no-ops for other types. + + Args: + inner_angles (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) + representing inner cone angle in degrees. + outer_angles (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) + representing outer cone angle in degrees. + env_ids (Sequence[int] | None): Indices of instances to set. + """ + if self.cfg.light_type != "spot": + logger.warning( + f"set_spot_angle not applicable to light type " + f"'{self.cfg.light_type}', ignoring." + ) + return + + if not torch.is_tensor(inner_angles) or not torch.is_tensor(outer_angles): + logger.log_error("set_spot_angle requires torch.Tensor arguments") + return + + inner_cpu = inner_angles.detach().cpu() + outer_cpu = outer_angles.detach().cpu() + + if env_ids is None: + all_ids = list(range(self.num_instances)) + else: + all_ids = list(env_ids) + + # Both scalar (0-dim): broadcast to all_ids + if inner_cpu.ndim == 0 and outer_cpu.ndim == 0: + iv = float(inner_cpu.item()) + ov = float(outer_cpu.item()) + for i in all_ids: + self._entities[i].set_spot_angle(iv, ov) + return + + # Both 1D with matching length + if inner_cpu.ndim == 1 and outer_cpu.ndim == 1: + ilen, olen = inner_cpu.shape[0], outer_cpu.shape[0] + inner_arr = inner_cpu.numpy() + outer_arr = outer_cpu.numpy() + + if ilen == olen == self.num_instances and env_ids is None: + for i in range(self.num_instances): + self._entities[i].set_spot_angle( + float(inner_arr[i]), float(outer_arr[i]) + ) + return + + if env_ids is not None and ilen == olen == len(all_ids): + for idx, i in enumerate(all_ids): + self._entities[i].set_spot_angle( + float(inner_arr[idx]), float(outer_arr[idx]) + ) + return + + logger.log_error( + f"set_spot_angle: invalid tensor shapes " + f"inner={tuple(inner_cpu.shape)}, outer={tuple(outer_cpu.shape)}" + ) + + def set_rect_wh( + self, + widths: torch.Tensor, + heights: torch.Tensor, + env_ids: Sequence[int] | None = None, + ) -> None: + """Set width and height for rectangular area lights. + + Only applies to ``rect`` light type. Logs a warning and no-ops for other types. + + Args: + widths (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) + representing width of the rectangular light. + heights (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) + representing height of the rectangular light. + env_ids (Sequence[int] | None): Indices of instances to set. + """ + if self.cfg.light_type != "rect": + logger.warning( + f"set_rect_wh not applicable to light type " + f"'{self.cfg.light_type}', ignoring." + ) + return + + if not torch.is_tensor(widths) or not torch.is_tensor(heights): + logger.log_error("set_rect_wh requires torch.Tensor arguments") + return + + w_cpu = widths.detach().cpu() + h_cpu = heights.detach().cpu() + + if env_ids is None: + all_ids = list(range(self.num_instances)) + else: + all_ids = list(env_ids) + + # Both scalar (0-dim): broadcast to all_ids + if w_cpu.ndim == 0 and h_cpu.ndim == 0: + wv = float(w_cpu.item()) + hv = float(h_cpu.item()) + for i in all_ids: + self._entities[i].set_rect_wh(wv, hv) + return + + # Both 1D with matching length + if w_cpu.ndim == 1 and h_cpu.ndim == 1: + wlen, hlen = w_cpu.shape[0], h_cpu.shape[0] + w_arr = w_cpu.numpy() + h_arr = h_cpu.numpy() + + if wlen == hlen == self.num_instances and env_ids is None: + for i in range(self.num_instances): + self._entities[i].set_rect_wh( + float(w_arr[i]), float(h_arr[i]) + ) + return + + if env_ids is not None and wlen == hlen == len(all_ids): + for idx, i in enumerate(all_ids): + self._entities[i].set_rect_wh( + float(w_arr[idx]), float(h_arr[idx]) + ) + return + + logger.log_error( + f"set_rect_wh: invalid tensor shapes " + f"width={tuple(w_cpu.shape)}, height={tuple(h_cpu.shape)}" + ) + + def set_mesh( + self, + mesh: "MeshObject", + env_ids: Sequence[int] | None = None, + ) -> None: + """Set the mesh for mesh-type lights. + + Only applies to ``mesh`` light type. Logs a warning and no-ops for other types. + This is NOT tensor-batched — the same MeshObject is assigned to all targeted + instances. + + Args: + mesh (MeshObject): The mesh object to assign to the light. + env_ids (Sequence[int] | None): Indices of instances to set. If None, + applies to all instances. + """ + if self.cfg.light_type != "mesh": + logger.warning( + f"set_mesh not applicable to light type " + f"'{self.cfg.light_type}', ignoring." + ) + return + + if env_ids is None: + target_ids = list(range(self.num_instances)) + else: + target_ids = list(env_ids) + + for i in target_ids: + try: + self._entities[i].set_mesh(mesh) + except Exception as e: + logger.log_error(f"set_mesh: error for instance {i}: {e}") + + def enable_shadow( + self, + flags: torch.Tensor, + env_ids: Sequence[int] | None = None, + ) -> None: + """Enable or disable shadow casting. + + Applies to all light types. + + Args: + flags (torch.Tensor): Boolean tensor of shape () (0-dim), (1,), or (M,). + Non-zero values enable shadows; zero disables. + env_ids (Sequence[int] | None): Indices of instances to set. + """ + if not torch.is_tensor(flags): + logger.log_error(f"enable_shadow requires a torch.Tensor, got {type(flags)}") + return + + cpu = flags.detach().cpu() + if env_ids is None: + all_ids = list(range(self.num_instances)) + else: + all_ids = list(env_ids) + + # Scalar: broadcast + if cpu.ndim == 0: + val = bool(cpu.item() != 0) + for i in all_ids: + self._entities[i].set_shadow(val) + return + + # 1D tensor + if cpu.ndim == 1: + length = cpu.shape[0] + arr = cpu.numpy() + if length == self.num_instances and env_ids is None: + for i in range(self.num_instances): + self._entities[i].set_shadow(bool(arr[i] != 0)) + return + if env_ids is not None and length == len(all_ids): + for idx, i in enumerate(all_ids): + self._entities[i].set_shadow(bool(arr[idx] != 0)) + return + if length == 1: + val = bool(arr[0] != 0) + for i in all_ids: + self._entities[i].set_shadow(val) + return + + logger.log_error( + f"enable_shadow: tensor shape {tuple(cpu.shape)} is invalid for broadcasting" + ) +``` + +- [ ] **Step 2: Add the MeshObject import at the top of the file** + +Add after line 21 (`from embodichain.lab.sim.cfg import LightCfg`): + +```python +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dexsim.models import MeshObject +``` + +- [ ] **Step 3: Update the `reset` method** + +Replace the existing `reset` method (lines 294-299): + +```python + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Reset the light to its initial configuration state. + + Applies only the properties relevant to ``self.cfg.light_type``. + + Args: + env_ids (Sequence[int] | None): The environment IDs to reset. + If None, resets all environments. + """ + self.cfg: LightCfg + light_type = self.cfg.light_type + + # Universal properties + self.set_color(torch.as_tensor(self.cfg.color), env_ids=env_ids) + self.set_intensity(torch.as_tensor(self.cfg.intensity), env_ids=env_ids) + self.enable_shadow( + torch.as_tensor(float(self.cfg.enable_shadow)), env_ids=env_ids + ) + + # Position (all types except direction) + if light_type != "direction": + self.set_local_pose(torch.as_tensor(self.cfg.init_pos), env_ids=env_ids) + + # Point light: falloff + if light_type == "point": + self.set_falloff(torch.as_tensor(self.cfg.radius), env_ids=env_ids) + + # Directional types: direction vector + if light_type in ("sun", "direction", "spot", "rect", "mesh"): + self.set_direction(torch.as_tensor(self.cfg.direction), env_ids=env_ids) + + # Spot light: cone angles + if light_type == "spot": + self.set_spot_angle( + torch.as_tensor(self.cfg.spot_angle_inner), + torch.as_tensor(self.cfg.spot_angle_outer), + env_ids=env_ids, + ) + + # Rect light: dimensions + if light_type == "rect": + self.set_rect_wh( + torch.as_tensor(self.cfg.rect_width), + torch.as_tensor(self.cfg.rect_height), + env_ids=env_ids, + ) + + # Mesh light: mesh_path is stored in cfg but actual mesh assignment + # is done via set_mesh() which requires a MeshObject. + # Sun-specific angular_radius/halo are reserved for future backend support. +``` + +- [ ] **Step 4: Update `set_local_pose` to handle direction-type lights** + +Add a guard at the beginning of `set_local_pose` (after the docstring, before line 107): + +```python + if self.cfg.light_type == "direction": + logger.warning( + "set_local_pose not applicable to 'direction' light type " + "(infinite distance, direction only). Use set_direction() instead." + ) + return +``` + +- [ ] **Step 5: Verify the module imports correctly** + +```bash +cd /home/dex/workspace/sources/EmbodiChain && python -c "from embodichain.lab.sim.objects.light import Light; print('OK')" +``` + +- [ ] **Step 6: Commit** + +```bash +git add embodichain/lab/sim/objects/light.py +git commit -m "feat: add type-specific setters to Light class + +Add set_direction, set_spot_angle, set_rect_wh, set_mesh, and +enable_shadow methods with runtime type validation. Update reset() +to apply only properties relevant to the light type. Guard +set_local_pose for direction-type lights (no position). + +Co-Authored-By: Claude " +``` + +--- + +### Task 3: Update `SimulationManager.add_light` type mapping + +**Files:** +- Modify: `embodichain/lab/sim/sim_manager.py:800-837` + +**Interfaces:** +- Consumes: `LightCfg` with expanded `light_type` literal (from Task 1), `Light` class with new setters (from Task 2) +- Produces: `add_light` accepts all 6 light types + +- [ ] **Step 1: Replace the `add_light` method** + +Replace lines 800-837: + +```python + # Light type string → dexsim LightType enum mapping + _LIGHT_TYPE_MAP: dict[str, LightType] = { + "point": LightType.POINT, + "sun": LightType.SUN, + "direction": LightType.DIRECTION, + "spot": LightType.SPOT, + "rect": LightType.RECT, + "mesh": LightType.MESH, + } + + def add_light(self, cfg: LightCfg) -> Light: + """Create a light in the scene. + + Supports six light types: ``"point"``, ``"sun"``, ``"direction"``, + ``"spot"``, ``"rect"``, and ``"mesh"``. See :class:`LightCfg` for + type-specific configuration fields. + + Args: + cfg (LightCfg): Configuration for the light, including type, color, + intensity, and type-specific properties. + + Returns: + Light: The created light instance. + + Raises: + ValueError: If ``cfg.light_type`` is not one of the supported types. + """ + if cfg.uid is None: + uid = "light" + cfg.uid = uid + else: + uid = cfg.uid + + if uid in self._lights: + logger.log_error(f"Light {uid} already exists.") + return None + + light_type_str = cfg.light_type + light_type = self._LIGHT_TYPE_MAP.get(light_type_str) + if light_type is None: + supported = ", ".join(self._LIGHT_TYPE_MAP.keys()) + logger.log_error( + f"Unsupported light type: '{light_type_str}'. " + f"Supported types: {supported}." + ) + return None + + # Validation warnings for type-specific constraints + if light_type_str == "mesh" and not cfg.mesh_path: + logger.warning( + f"Mesh light '{uid}' has no mesh_path set. " + f"Use set_mesh() to assign a MeshObject." + ) + if light_type_str == "rect" and (cfg.rect_width <= 0 or cfg.rect_height <= 0): + logger.warning( + f"Rect light '{uid}' has zero or negative dimensions " + f"(width={cfg.rect_width}, height={cfg.rect_height})." + ) + + env_list = [self._env] if len(self._arenas) == 0 else self._arenas + light_list = [] + for i, env in enumerate(env_list): + light_name = f"{uid}_{i}" + light = env.create_light(light_name, light_type) + light_list.append(light) + + batch_lights = Light(cfg=cfg, entities=light_list) + + self._lights[uid] = batch_lights + + return batch_lights +``` + +Note: The `_LIGHT_TYPE_MAP` is a class-level dict on `SimulationManager`. Place it right before `add_light` (after `get_articulation` at line 798). + +- [ ] **Step 2: Verify the mapping works** + +```bash +cd /home/dex/workspace/sources/EmbodiChain && python -c " +from embodichain.lab.sim.sim_manager import SimulationManager +# Check that the map has all 6 types +mgr = SimulationManager +print(mgr._LIGHT_TYPE_MAP) +" +``` + +- [ ] **Step 3: Commit** + +```bash +git add embodichain/lab/sim/sim_manager.py +git commit -m "feat: support all 6 light types in SimulationManager.add_light + +Add _LIGHT_TYPE_MAP mapping string type names to dexsim LightType enum. +Add validation warnings for mesh (no path) and rect (zero dimensions). +Unknown light types now log the list of supported types. + +Co-Authored-By: Claude " +``` + +--- + +### Task 4: Write tests for new light types + +**Files:** +- Modify: `tests/sim/objects/test_light.py` + +**Interfaces:** +- Consumes: All types and setters from Tasks 1-3 +- Produces: Test coverage for all 6 light types + +- [ ] **Step 1: Add new test class `TestLightTypes` after the existing `TestLight` class** + +Insert after line 161 (after `gc.collect()`): + +```python +class TestLightTypes: + """Tests for all six supported light types.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Create a SimulationManager for each test.""" + config = SimulationManagerCfg(headless=True, sim_device="cpu", num_envs=4) + self.sim = SimulationManager(config) + yield + self.sim.destroy() + import embodichain.lab.sim as om + + om.SimulationManager.flush_cleanup_queue() + self.__dict__.clear() + import gc + + gc.collect() + + # ------------------------------------------------------------------ + # Creation tests + # ------------------------------------------------------------------ + + @pytest.mark.parametrize( + "light_type", + ["point", "sun", "direction", "spot", "rect", "mesh"], + ) + def test_create_each_light_type(self, light_type): + """Each of the 6 light types can be created without error.""" + cfg = LightCfg( + uid=f"test_{light_type}", + light_type=light_type, + init_pos=(0.0, 0.0, 3.0), + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None, f"Failed to create light of type '{light_type}'" + assert light.num_instances == 4 + + def test_unknown_light_type_errors(self): + """Passing an invalid light_type logs an error and returns None.""" + cfg = LightCfg(uid="bad", light_type="invalid") + light = self.sim.add_light(cfg=cfg) + assert light is None, "Unknown light type should return None" + + def test_mesh_light_empty_path_warns(self): + """Mesh light with empty mesh_path warns at creation but succeeds.""" + cfg = LightCfg( + uid="mesh_no_path", + light_type="mesh", + init_pos=(0.0, 0.0, 2.0), + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None, "Mesh light should be created even without path" + + # ------------------------------------------------------------------ + # Setter type-validation tests + # ------------------------------------------------------------------ + + def test_set_direction_on_point_warns(self): + """Calling set_direction on a point light logs a warning and no-ops.""" + cfg = LightCfg(uid="pt", light_type="point", init_pos=(0, 0, 2)) + light = self.sim.add_light(cfg=cfg) + direction = torch.tensor([1.0, 0.0, 0.0]) + # Should not raise; should log a warning + try: + light.set_direction(direction) + except Exception as e: + pytest.fail(f"set_direction on point light should warn, not crash: {e}") + + def test_set_spot_angle_on_point_warns(self): + """Calling set_spot_angle on a non-spot light warns and no-ops.""" + cfg = LightCfg(uid="pt2", light_type="point", init_pos=(0, 0, 2)) + light = self.sim.add_light(cfg=cfg) + inner = torch.tensor(15.0) + outer = torch.tensor(30.0) + try: + light.set_spot_angle(inner, outer) + except Exception as e: + pytest.fail(f"set_spot_angle on point light should warn, not crash: {e}") + + def test_set_rect_wh_on_point_warns(self): + """Calling set_rect_wh on a non-rect light warns and no-ops.""" + cfg = LightCfg(uid="pt3", light_type="point", init_pos=(0, 0, 2)) + light = self.sim.add_light(cfg=cfg) + w = torch.tensor(2.0) + h = torch.tensor(3.0) + try: + light.set_rect_wh(w, h) + except Exception as e: + pytest.fail(f"set_rect_wh on point light should warn, not crash: {e}") + + def test_set_local_pose_on_direction_warns(self): + """Calling set_local_pose on a direction light warns and no-ops.""" + cfg = LightCfg( + uid="dir_light", + light_type="direction", + direction=(0.0, -1.0, 0.0), + ) + light = self.sim.add_light(cfg=cfg) + pose = torch.tensor([1.0, 2.0, 3.0]) + try: + light.set_local_pose(pose) + except Exception as e: + pytest.fail( + f"set_local_pose on direction light should warn, not crash: {e}" + ) + + # ------------------------------------------------------------------ + # Type-specific property tests + # ------------------------------------------------------------------ + + def test_spot_light_has_cone_angles(self): + """Spot light creation applies inner/outer cone angles from cfg.""" + cfg = LightCfg( + uid="spot_test", + light_type="spot", + init_pos=(0.0, 0.0, 3.0), + direction=(0.0, 0.0, -1.0), + spot_angle_inner=20.0, + spot_angle_outer=40.0, + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None + # Should not crash on creation; spot_angle applied during reset() + + def test_rect_light_has_dimensions(self): + """Rect light creation applies width/height from cfg.""" + cfg = LightCfg( + uid="rect_test", + light_type="rect", + init_pos=(0.0, 0.0, 3.0), + direction=(0.0, 0.0, -1.0), + rect_width=2.0, + rect_height=1.5, + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None + + def test_direction_light_no_position_in_reset(self): + """Direction light reset does not set position.""" + cfg = LightCfg( + uid="dir_test", + light_type="direction", + direction=(0.0, -1.0, 0.0), + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None + + # ------------------------------------------------------------------ + # Broadcasting tests + # ------------------------------------------------------------------ + + def test_set_direction_broadcasting(self): + """set_direction with (3,) tensor broadcasts to all instances.""" + cfg = LightCfg( + uid="spot_broadcast", + light_type="spot", + init_pos=(0, 0, 3), + direction=(0.0, 0.0, -1.0), + ) + light = self.sim.add_light(cfg=cfg) + new_dir = torch.tensor([1.0, 0.0, 0.0]) + try: + light.set_direction(new_dir) + except Exception as e: + pytest.fail(f"set_direction broadcast failed: {e}") + + def test_set_direction_with_env_ids(self): + """set_direction with (M, 3) tensor applies per-instance.""" + cfg = LightCfg( + uid="spot_env", + light_type="spot", + init_pos=(0, 0, 3), + direction=(0.0, 0.0, -1.0), + ) + light = self.sim.add_light(cfg=cfg) + env_ids = [0, 2] + dirs = torch.tensor([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + try: + light.set_direction(dirs, env_ids=env_ids) + except Exception as e: + pytest.fail(f"set_direction per-instance failed: {e}") + + def test_enable_shadow(self): + """enable_shadow sets shadow flag on all instances.""" + cfg = LightCfg(uid="shadow_test", light_type="point", init_pos=(0, 0, 2)) + light = self.sim.add_light(cfg=cfg) + try: + light.enable_shadow(torch.tensor(0.0)) # disable + light.enable_shadow(torch.tensor(1.0)) # enable + except Exception as e: + pytest.fail(f"enable_shadow failed: {e}") + + # ------------------------------------------------------------------ + # from_dict compatibility + # ------------------------------------------------------------------ + + def test_from_dict_new_types(self): + """LightCfg.from_dict works with new type fields.""" + cfg = LightCfg.from_dict( + { + "light_type": "spot", + "color": [1.0, 0.9, 0.8], + "intensity": 100.0, + "init_pos": [0.0, 0.0, 3.0], + "direction": [0.0, 0.0, -1.0], + "spot_angle_inner": 25.0, + "spot_angle_outer": 50.0, + "uid": "from_dict_spot", + } + ) + assert cfg.light_type == "spot" + assert cfg.spot_angle_inner == 25.0 + assert cfg.spot_angle_outer == 50.0 + assert cfg.direction == (0.0, 0.0, -1.0) + + # ------------------------------------------------------------------ + # Backward compatibility + # ------------------------------------------------------------------ + + def test_point_light_backward_compat(self): + """Existing point-light config pattern still works.""" + cfg_dict = { + "light_type": "point", + "color": [0.1, 0.1, 0.1], + "radius": 10.0, + "position": [0.0, 0.0, 2.0], + "uid": "point_compat", + } + light = self.sim.add_light(cfg=LightCfg.from_dict(cfg_dict)) + assert light is not None + assert light.num_instances == 4 + + # set_color should still work + base_color = torch.tensor([0.1, 0.1, 0.1]) + try: + light.set_color(base_color) + except Exception as e: + pytest.fail(f"set_color failed: {e}") + + # set_falloff should still work + try: + light.set_falloff(torch.tensor(100.0)) + except Exception as e: + pytest.fail(f"set_falloff failed: {e}") +``` + +- [ ] **Step 2: Run the new test file** + +```bash +cd /home/dex/workspace/sources/EmbodiChain && python -m pytest tests/sim/objects/test_light.py -v +``` + +Expected: All tests in `TestLight` and `TestLightTypes` pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/sim/objects/test_light.py +git commit -m "test: add comprehensive tests for all 6 light types + +Cover creation, type validation, broadcasting, from_dict, and +backward compatibility for point lights. + +Co-Authored-By: Claude " +``` + +--- + +### Task 5: Run full pre-commit check and fix any issues + +**Files:** +- Modify: (any files that need formatting or lint fixes) + +- [ ] **Step 1: Format with black** + +```bash +cd /home/dex/workspace/sources/EmbodiChain && black embodichain/lab/sim/cfg.py embodichain/lab/sim/objects/light.py embodichain/lab/sim/sim_manager.py tests/sim/objects/test_light.py +``` + +- [ ] **Step 2: Run the full test suite for affected modules** + +```bash +cd /home/dex/workspace/sources/EmbodiChain && python -m pytest tests/sim/objects/test_light.py -v +``` + +- [ ] **Step 3: Verify no import regressions** + +```bash +cd /home/dex/workspace/sources/EmbodiChain && python -c " +from embodichain.lab.sim.cfg import LightCfg +from embodichain.lab.sim.objects import Light +from embodichain.lab.sim.sim_manager import SimulationManager +print('All imports successful') +print(f'Supported types: {list(SimulationManager._LIGHT_TYPE_MAP.keys())}') +" +``` + +Expected: `All imports successful` + list of all 6 types. + +- [ ] **Step 4: Final commit if any formatting changes were needed** + +```bash +git add -u && git commit -m "chore: format and finalize light type expansion + +Co-Authored-By: Claude " +``` From 38ff2551f8e8666d833437e2dafe7279b69a3a27 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 5 Jul 2026 22:39:39 +0800 Subject: [PATCH 03/18] feat: expand LightCfg to support 6 light types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional fields for sun, direction, spot, rect, and mesh light types. Change default intensity to 30.0 and default radius to 10.0. All new fields have defaults — existing point-light code works unchanged. Co-Authored-By: Claude --- embodichain/lab/sim/cfg.py | 92 +++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/embodichain/lab/sim/cfg.py b/embodichain/lab/sim/cfg.py index cf08b19f..d29ff11f 100644 --- a/embodichain/lab/sim/cfg.py +++ b/embodichain/lab/sim/cfg.py @@ -795,19 +795,97 @@ def from_dict(cls, init_dict: Dict[str, Union[str, float, tuple]]) -> ObjectBase class LightCfg(ObjectBaseCfg): """Configuration for a light asset in the simulation. - This class extends the base asset configuration to include specific properties for lights, + Supports six light types matching the dexsim rendering backend: + + - ``"point"``: Omnidirectional point light with position and falloff radius. + - ``"sun"``: Directional sun light with position, direction, and angular radius (sun-specific setters like halo are not yet wired through Python bindings). + - ``"direction"``: Pure directional light at infinite distance (direction only, no position). + - ``"spot"``: Spotlight with position, direction, and inner/outer cone angles. + - ``"rect"``: Rectangular area light with position, direction, width, and height. + - ``"mesh"``: Mesh-based emissive light (requires a MeshObject via :meth:`set_mesh`; not tensor-batched). + + .. attention:: + The ``angular_radius``, ``halo_size``, and ``halo_falloff`` fields are + reserved for future use. The dexsim Python bindings do not yet expose + setters for these sun-specific properties. """ - # TODO: to be added more light type, such as spot, sun, etc. - light_type: Literal["point"] = "point" + light_type: Literal["point", "sun", "direction", "spot", "rect", "mesh"] = "point" + """Light type. Supported: ``"point"``, ``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, ``"mesh"``.""" + + # ------------------------------------------------------------------ + # Universal properties (apply to all light types) + # ------------------------------------------------------------------ color: tuple[float, float, float] = (1.0, 1.0, 1.0) + """RGB color of the light source. Defaults to white ``(1.0, 1.0, 1.0)``.""" + + intensity: float = 30.0 + """Intensity of the light source in watts/m^2. Defaults to ``30.0``.""" + + enable_shadow: bool = True + """Whether the light casts shadows. Defaults to ``True``.""" + + # ------------------------------------------------------------------ + # Point light + # ------------------------------------------------------------------ + + radius: float = 10.0 + """Falloff radius for point lights. Only used when ``light_type="point"``. Defaults to ``10.0``.""" + + # ------------------------------------------------------------------ + # Directional properties (sun, direction, spot, rect, mesh) + # ------------------------------------------------------------------ + + direction: tuple[float, float, float] = (0.0, 0.0, -1.0) + """Direction vector for directional, spot, rect, and mesh lights. + Defaults to ``(0.0, 0.0, -1.0)`` (pointing down along -Z).""" + + # ------------------------------------------------------------------ + # Sun light (reserved — Python bindings not yet available) + # ------------------------------------------------------------------ + + angular_radius: float = 0.5 + """Angular radius of the sun disc in degrees. Reserved for future use.""" + + halo_size: float = 10.0 + """Halo size for sun light. Reserved for future use.""" + + halo_falloff: float = 3.0 + """Halo falloff for sun light. Reserved for future use.""" + + # ------------------------------------------------------------------ + # Spot light + # ------------------------------------------------------------------ + + spot_angle_inner: float = 30.0 + """Inner cone angle of the spotlight in degrees. Only used when ``light_type="spot"``. + Defaults to ``30.0``.""" + + spot_angle_outer: float = 45.0 + """Outer cone angle of the spotlight in degrees. Only used when ``light_type="spot"``. + Defaults to ``45.0``.""" + + # ------------------------------------------------------------------ + # Rect light + # ------------------------------------------------------------------ + + rect_width: float = 1.0 + """Width of the rectangular area light. Only used when ``light_type="rect"``. + Defaults to ``1.0``.""" + + rect_height: float = 1.0 + """Height of the rectangular area light. Only used when ``light_type="rect"``. + Defaults to ``1.0``.""" - intensity: float = 50.0 - """Intensity of the light source with unit of watts/m^2.""" + # ------------------------------------------------------------------ + # Mesh light + # ------------------------------------------------------------------ - radius: float = 1e2 - """Falloff of the light, only used for point light.""" + mesh_path: str = "" + """Asset path for mesh-based emissive lights. Only used when ``light_type="mesh"``. + The actual mesh assignment is done via :meth:`Light.set_mesh` which accepts a + :class:`dexsim.models.MeshObject`. This field stores the path for reference.""" @configclass From ea0611e904c06665d8732f7ff799460f5d6e51b8 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 5 Jul 2026 22:47:05 +0800 Subject: [PATCH 04/18] feat: add type-specific setters to Light class Add set_direction, set_spot_angle, set_rect_wh, set_mesh, and enable_shadow methods with runtime type validation. Update reset() to apply only properties relevant to the light type. Guard set_local_pose for direction-type lights (no position). Co-Authored-By: Claude --- embodichain/lab/sim/objects/light.py | 310 ++++++++++++++++++++++++++- 1 file changed, 307 insertions(+), 3 deletions(-) diff --git a/embodichain/lab/sim/objects/light.py b/embodichain/lab/sim/objects/light.py index 8cea8d8a..8820ce0a 100644 --- a/embodichain/lab/sim/objects/light.py +++ b/embodichain/lab/sim/objects/light.py @@ -14,14 +14,19 @@ # limitations under the License. # ---------------------------------------------------------------------------- +from __future__ import annotations + import torch import numpy as np -from typing import List, Sequence +from typing import TYPE_CHECKING, List, Sequence from dexsim.render import Light as _Light from embodichain.lab.sim.cfg import LightCfg from embodichain.lab.sim.common import BatchEntity from embodichain.utils import logger +if TYPE_CHECKING: + from dexsim.models import MeshObject + class Light(BatchEntity): """Light represents a batch of lights in the simulation. @@ -87,6 +92,254 @@ def set_falloff( """ self._apply_scalar(falloffs, env_ids, "set_falloff") + def set_direction( + self, directions: torch.Tensor, env_ids: Sequence[int] | None = None + ) -> None: + """Set direction for directional-type lights. + + Only applies to ``sun``, ``direction``, ``spot``, ``rect``, and ``mesh`` + light types. Logs a warning and no-ops for other types. + + Args: + directions (torch.Tensor): Tensor of shape (3,) or (M, 3), representing + (x, y, z) direction vectors. + env_ids (Sequence[int] | None): Indices of instances to set. If None: + - For shape (3,), applies to all instances. + - For shape (M, 3), M must equal num_instances, applies per-instance. + """ + if self.cfg.light_type not in ("sun", "direction", "spot", "rect", "mesh"): + logger.warning( + f"set_direction not applicable to light type " + f"'{self.cfg.light_type}', ignoring." + ) + return + self._apply_vector3(directions, env_ids, "set_direction") + + def set_spot_angle( + self, + inner_angles: torch.Tensor, + outer_angles: torch.Tensor, + env_ids: Sequence[int] | None = None, + ) -> None: + """Set inner and outer cone angles for spot lights. + + Only applies to ``spot`` light type. Logs a warning and no-ops for other types. + + Args: + inner_angles (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) + representing inner cone angle in degrees. + outer_angles (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) + representing outer cone angle in degrees. + env_ids (Sequence[int] | None): Indices of instances to set. + """ + if self.cfg.light_type != "spot": + logger.warning( + f"set_spot_angle not applicable to light type " + f"'{self.cfg.light_type}', ignoring." + ) + return + + if not torch.is_tensor(inner_angles) or not torch.is_tensor(outer_angles): + logger.log_error("set_spot_angle requires torch.Tensor arguments") + return + + inner_cpu = inner_angles.detach().cpu() + outer_cpu = outer_angles.detach().cpu() + + if env_ids is None: + all_ids = list(range(self.num_instances)) + else: + all_ids = list(env_ids) + + # Both scalar (0-dim): broadcast to all_ids + if inner_cpu.ndim == 0 and outer_cpu.ndim == 0: + iv = float(inner_cpu.item()) + ov = float(outer_cpu.item()) + for i in all_ids: + self._entities[i].set_spot_angle(iv, ov) + return + + # Both 1D with matching length + if inner_cpu.ndim == 1 and outer_cpu.ndim == 1: + ilen, olen = inner_cpu.shape[0], outer_cpu.shape[0] + inner_arr = inner_cpu.numpy() + outer_arr = outer_cpu.numpy() + + if ilen == olen == self.num_instances and env_ids is None: + for i in range(self.num_instances): + self._entities[i].set_spot_angle( + float(inner_arr[i]), float(outer_arr[i]) + ) + return + + if env_ids is not None and ilen == olen == len(all_ids): + for idx, i in enumerate(all_ids): + self._entities[i].set_spot_angle( + float(inner_arr[idx]), float(outer_arr[idx]) + ) + return + + logger.log_error( + f"set_spot_angle: invalid tensor shapes " + f"inner={tuple(inner_cpu.shape)}, outer={tuple(outer_cpu.shape)}" + ) + + def set_rect_wh( + self, + widths: torch.Tensor, + heights: torch.Tensor, + env_ids: Sequence[int] | None = None, + ) -> None: + """Set width and height for rectangular area lights. + + Only applies to ``rect`` light type. Logs a warning and no-ops for other types. + + Args: + widths (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) + representing width of the rectangular light. + heights (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) + representing height of the rectangular light. + env_ids (Sequence[int] | None): Indices of instances to set. + """ + if self.cfg.light_type != "rect": + logger.warning( + f"set_rect_wh not applicable to light type " + f"'{self.cfg.light_type}', ignoring." + ) + return + + if not torch.is_tensor(widths) or not torch.is_tensor(heights): + logger.log_error("set_rect_wh requires torch.Tensor arguments") + return + + w_cpu = widths.detach().cpu() + h_cpu = heights.detach().cpu() + + if env_ids is None: + all_ids = list(range(self.num_instances)) + else: + all_ids = list(env_ids) + + # Both scalar (0-dim): broadcast to all_ids + if w_cpu.ndim == 0 and h_cpu.ndim == 0: + wv = float(w_cpu.item()) + hv = float(h_cpu.item()) + for i in all_ids: + self._entities[i].set_rect_wh(wv, hv) + return + + # Both 1D with matching length + if w_cpu.ndim == 1 and h_cpu.ndim == 1: + wlen, hlen = w_cpu.shape[0], h_cpu.shape[0] + w_arr = w_cpu.numpy() + h_arr = h_cpu.numpy() + + if wlen == hlen == self.num_instances and env_ids is None: + for i in range(self.num_instances): + self._entities[i].set_rect_wh( + float(w_arr[i]), float(h_arr[i]) + ) + return + + if env_ids is not None and wlen == hlen == len(all_ids): + for idx, i in enumerate(all_ids): + self._entities[i].set_rect_wh( + float(w_arr[idx]), float(h_arr[idx]) + ) + return + + logger.log_error( + f"set_rect_wh: invalid tensor shapes " + f"width={tuple(w_cpu.shape)}, height={tuple(h_cpu.shape)}" + ) + + def set_mesh( + self, + mesh: "MeshObject", + env_ids: Sequence[int] | None = None, + ) -> None: + """Set the mesh for mesh-type lights. + + Only applies to ``mesh`` light type. Logs a warning and no-ops for other types. + This is NOT tensor-batched — the same MeshObject is assigned to all targeted + instances. + + Args: + mesh (MeshObject): The mesh object to assign to the light. + env_ids (Sequence[int] | None): Indices of instances to set. If None, + applies to all instances. + """ + if self.cfg.light_type != "mesh": + logger.warning( + f"set_mesh not applicable to light type " + f"'{self.cfg.light_type}', ignoring." + ) + return + + if env_ids is None: + target_ids = list(range(self.num_instances)) + else: + target_ids = list(env_ids) + + for i in target_ids: + try: + self._entities[i].set_mesh(mesh) + except Exception as e: + logger.log_error(f"set_mesh: error for instance {i}: {e}") + + def enable_shadow( + self, + flags: torch.Tensor, + env_ids: Sequence[int] | None = None, + ) -> None: + """Enable or disable shadow casting. + + Applies to all light types. + + Args: + flags (torch.Tensor): Boolean tensor of shape () (0-dim), (1,), or (M,). + Non-zero values enable shadows; zero disables. + env_ids (Sequence[int] | None): Indices of instances to set. + """ + if not torch.is_tensor(flags): + logger.log_error(f"enable_shadow requires a torch.Tensor, got {type(flags)}") + return + + cpu = flags.detach().cpu() + if env_ids is None: + all_ids = list(range(self.num_instances)) + else: + all_ids = list(env_ids) + + # Scalar: broadcast + if cpu.ndim == 0: + val = bool(cpu.item() != 0) + for i in all_ids: + self._entities[i].set_shadow(val) + return + + # 1D tensor + if cpu.ndim == 1: + length = cpu.shape[0] + arr = cpu.numpy() + if length == self.num_instances and env_ids is None: + for i in range(self.num_instances): + self._entities[i].set_shadow(bool(arr[i] != 0)) + return + if env_ids is not None and length == len(all_ids): + for idx, i in enumerate(all_ids): + self._entities[i].set_shadow(bool(arr[idx] != 0)) + return + if length == 1: + val = bool(arr[0] != 0) + for i in all_ids: + self._entities[i].set_shadow(val) + return + + logger.log_error( + f"enable_shadow: tensor shape {tuple(cpu.shape)} is invalid for broadcasting" + ) + def set_local_pose( self, pose: torch.Tensor, @@ -104,6 +357,13 @@ def set_local_pose( - For matrix input (4,4) broadcast to all, or (M,4,4) with M == num_instances. to_matrix (bool): Interpret `pose` as full 4x4 matrix if True, else as vector(s). """ + if self.cfg.light_type == "direction": + logger.warning( + "set_local_pose not applicable to 'direction' light type " + "(infinite distance, direction only). Use set_direction() instead." + ) + return + if not torch.is_tensor(pose): logger.log_error( f"set_local_pose requires a torch.Tensor, got {type(pose)}" @@ -292,8 +552,52 @@ def _apply_scalar( ) def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Reset the light to its initial configuration state. + + Applies only the properties relevant to ``self.cfg.light_type``. + + Args: + env_ids (Sequence[int] | None): The environment IDs to reset. + If None, resets all environments. + """ self.cfg: LightCfg + light_type = self.cfg.light_type + + # Universal properties self.set_color(torch.as_tensor(self.cfg.color), env_ids=env_ids) self.set_intensity(torch.as_tensor(self.cfg.intensity), env_ids=env_ids) - self.set_falloff(torch.as_tensor(self.cfg.radius), env_ids=env_ids) - self.set_local_pose(torch.as_tensor(self.cfg.init_pos), env_ids=env_ids) + self.enable_shadow( + torch.as_tensor(float(self.cfg.enable_shadow)), env_ids=env_ids + ) + + # Position (all types except direction) + if light_type != "direction": + self.set_local_pose(torch.as_tensor(self.cfg.init_pos), env_ids=env_ids) + + # Point light: falloff + if light_type == "point": + self.set_falloff(torch.as_tensor(self.cfg.radius), env_ids=env_ids) + + # Directional types: direction vector + if light_type in ("sun", "direction", "spot", "rect", "mesh"): + self.set_direction(torch.as_tensor(self.cfg.direction), env_ids=env_ids) + + # Spot light: cone angles + if light_type == "spot": + self.set_spot_angle( + torch.as_tensor(self.cfg.spot_angle_inner), + torch.as_tensor(self.cfg.spot_angle_outer), + env_ids=env_ids, + ) + + # Rect light: dimensions + if light_type == "rect": + self.set_rect_wh( + torch.as_tensor(self.cfg.rect_width), + torch.as_tensor(self.cfg.rect_height), + env_ids=env_ids, + ) + + # Mesh light: mesh_path is stored in cfg but actual mesh assignment + # is done via set_mesh() which requires a MeshObject. + # Sun-specific angular_radius/halo are reserved for future backend support. From db605795d91e0d4d0108582129c7d43f5a350221 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 5 Jul 2026 22:51:52 +0800 Subject: [PATCH 05/18] Fix length-1 broadcast in Light set_spot_angle and set_rect_wh Adds a length-1 broadcast branch so that (1,) tensors for inner/outer spot angles and width/height are applied to all targeted env_ids. Co-Authored-By: Claude --- embodichain/lab/sim/objects/light.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/embodichain/lab/sim/objects/light.py b/embodichain/lab/sim/objects/light.py index 8820ce0a..acd85e05 100644 --- a/embodichain/lab/sim/objects/light.py +++ b/embodichain/lab/sim/objects/light.py @@ -179,6 +179,14 @@ def set_spot_angle( ) return + # length-1 broadcast + if ilen == olen == 1: + iv = float(inner_arr[0]) + ov = float(outer_arr[0]) + for i in all_ids: + self._entities[i].set_spot_angle(iv, ov) + return + logger.log_error( f"set_spot_angle: invalid tensor shapes " f"inner={tuple(inner_cpu.shape)}, outer={tuple(outer_cpu.shape)}" @@ -236,16 +244,20 @@ def set_rect_wh( if wlen == hlen == self.num_instances and env_ids is None: for i in range(self.num_instances): - self._entities[i].set_rect_wh( - float(w_arr[i]), float(h_arr[i]) - ) + self._entities[i].set_rect_wh(float(w_arr[i]), float(h_arr[i])) return if env_ids is not None and wlen == hlen == len(all_ids): for idx, i in enumerate(all_ids): - self._entities[i].set_rect_wh( - float(w_arr[idx]), float(h_arr[idx]) - ) + self._entities[i].set_rect_wh(float(w_arr[idx]), float(h_arr[idx])) + return + + # length-1 broadcast + if wlen == hlen == 1: + wv = float(w_arr[0]) + hv = float(h_arr[0]) + for i in all_ids: + self._entities[i].set_rect_wh(wv, hv) return logger.log_error( @@ -302,7 +314,9 @@ def enable_shadow( env_ids (Sequence[int] | None): Indices of instances to set. """ if not torch.is_tensor(flags): - logger.log_error(f"enable_shadow requires a torch.Tensor, got {type(flags)}") + logger.log_error( + f"enable_shadow requires a torch.Tensor, got {type(flags)}" + ) return cpu = flags.detach().cpu() From 0b5f7e5cdd3de114b410e47c8b9e03bf1a0b1fcb Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 5 Jul 2026 22:55:07 +0800 Subject: [PATCH 06/18] feat: support all 6 light types in SimulationManager.add_light Add _LIGHT_TYPE_MAP mapping string type names to dexsim LightType enum. Add validation warnings for mesh (no path) and rect (zero dimensions). Unknown light types now log the list of supported types. Co-Authored-By: Claude --- embodichain/lab/sim/sim_manager.py | 45 ++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index c14d98c9..862dd514 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -797,14 +797,32 @@ def get_asset( logger.log_warning(f"Asset {uid} not found.") return None + # Light type string → dexsim LightType enum mapping + _LIGHT_TYPE_MAP: dict[str, LightType] = { + "point": LightType.POINT, + "sun": LightType.SUN, + "direction": LightType.DIRECTION, + "spot": LightType.SPOT, + "rect": LightType.RECT, + "mesh": LightType.MESH, + } + def add_light(self, cfg: LightCfg) -> Light: """Create a light in the scene. + Supports six light types: ``"point"``, ``"sun"``, ``"direction"``, + ``"spot"``, ``"rect"``, and ``"mesh"``. See :class:`LightCfg` for + type-specific configuration fields. + Args: - cfg (LightCfg): Configuration for the light, including type, color, intensity, and radius. + cfg (LightCfg): Configuration for the light, including type, color, + intensity, and type-specific properties. Returns: Light: The created light instance. + + Raises: + ValueError: If ``cfg.light_type`` is not one of the supported types. """ if cfg.uid is None: uid = "light" @@ -814,13 +832,28 @@ def add_light(self, cfg: LightCfg) -> Light: if uid in self._lights: logger.log_error(f"Light {uid} already exists.") + return None - light_type = cfg.light_type - if light_type == "point": - light_type = LightType.POINT - else: + light_type_str = cfg.light_type + light_type = self._LIGHT_TYPE_MAP.get(light_type_str) + if light_type is None: + supported = ", ".join(self._LIGHT_TYPE_MAP.keys()) logger.log_error( - f"Unsupported light type: {light_type}. Supported types: point." + f"Unsupported light type: '{light_type_str}'. " + f"Supported types: {supported}." + ) + return None + + # Validation warnings for type-specific constraints + if light_type_str == "mesh" and not cfg.mesh_path: + logger.warning( + f"Mesh light '{uid}' has no mesh_path set. " + f"Use set_mesh() to assign a MeshObject." + ) + if light_type_str == "rect" and (cfg.rect_width <= 0 or cfg.rect_height <= 0): + logger.warning( + f"Rect light '{uid}' has zero or negative dimensions " + f"(width={cfg.rect_width}, height={cfg.rect_height})." ) env_list = [self._env] if len(self._arenas) == 0 else self._arenas From fab40769ced51a0cebd139bc94792c27a7c6065c Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 5 Jul 2026 23:12:01 +0800 Subject: [PATCH 07/18] test: add comprehensive tests for all 6 light types Cover creation, type validation, broadcasting, from_dict, and backward compatibility for point lights. Also fix logger.warning() -> logger.log_warning() in sim_manager.py and light.py (logger.warning() did not exist). Co-Authored-By: Claude --- embodichain/lab/sim/objects/light.py | 10 +- embodichain/lab/sim/sim_manager.py | 4 +- tests/sim/objects/test_light.py | 246 +++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 7 deletions(-) diff --git a/embodichain/lab/sim/objects/light.py b/embodichain/lab/sim/objects/light.py index acd85e05..bb9d7427 100644 --- a/embodichain/lab/sim/objects/light.py +++ b/embodichain/lab/sim/objects/light.py @@ -108,7 +108,7 @@ def set_direction( - For shape (M, 3), M must equal num_instances, applies per-instance. """ if self.cfg.light_type not in ("sun", "direction", "spot", "rect", "mesh"): - logger.warning( + logger.log_warning( f"set_direction not applicable to light type " f"'{self.cfg.light_type}', ignoring." ) @@ -133,7 +133,7 @@ def set_spot_angle( env_ids (Sequence[int] | None): Indices of instances to set. """ if self.cfg.light_type != "spot": - logger.warning( + logger.log_warning( f"set_spot_angle not applicable to light type " f"'{self.cfg.light_type}', ignoring." ) @@ -210,7 +210,7 @@ def set_rect_wh( env_ids (Sequence[int] | None): Indices of instances to set. """ if self.cfg.light_type != "rect": - logger.warning( + logger.log_warning( f"set_rect_wh not applicable to light type " f"'{self.cfg.light_type}', ignoring." ) @@ -282,7 +282,7 @@ def set_mesh( applies to all instances. """ if self.cfg.light_type != "mesh": - logger.warning( + logger.log_warning( f"set_mesh not applicable to light type " f"'{self.cfg.light_type}', ignoring." ) @@ -372,7 +372,7 @@ def set_local_pose( to_matrix (bool): Interpret `pose` as full 4x4 matrix if True, else as vector(s). """ if self.cfg.light_type == "direction": - logger.warning( + logger.log_warning( "set_local_pose not applicable to 'direction' light type " "(infinite distance, direction only). Use set_direction() instead." ) diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 862dd514..08759ddd 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -846,12 +846,12 @@ def add_light(self, cfg: LightCfg) -> Light: # Validation warnings for type-specific constraints if light_type_str == "mesh" and not cfg.mesh_path: - logger.warning( + logger.log_warning( f"Mesh light '{uid}' has no mesh_path set. " f"Use set_mesh() to assign a MeshObject." ) if light_type_str == "rect" and (cfg.rect_width <= 0 or cfg.rect_height <= 0): - logger.warning( + logger.log_warning( f"Rect light '{uid}' has zero or negative dimensions " f"(width={cfg.rect_width}, height={cfg.rect_height})." ) diff --git a/tests/sim/objects/test_light.py b/tests/sim/objects/test_light.py index 7e9d58c4..2e4d3f00 100644 --- a/tests/sim/objects/test_light.py +++ b/tests/sim/objects/test_light.py @@ -159,3 +159,249 @@ def teardown_method(self): import gc gc.collect() + + +class TestLightTypes: + """Tests for all six supported light types.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Create a SimulationManager for each test.""" + config = SimulationManagerCfg(headless=True, sim_device="cpu", num_envs=4) + self.sim = SimulationManager(config) + yield + self.sim.destroy() + import embodichain.lab.sim as om + + om.SimulationManager.flush_cleanup_queue() + self.__dict__.clear() + import gc + + gc.collect() + + # ------------------------------------------------------------------ + # Creation tests + # ------------------------------------------------------------------ + + @pytest.mark.parametrize( + "light_type", + ["point", "sun", "direction", "spot", "rect", "mesh"], + ) + def test_create_each_light_type(self, light_type): + """Each of the 6 light types can be created without error.""" + cfg = LightCfg( + uid=f"test_{light_type}", + light_type=light_type, + init_pos=(0.0, 0.0, 3.0), + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None, f"Failed to create light of type '{light_type}'" + assert light.num_instances == 4 + + def test_unknown_light_type_errors(self): + """Passing an invalid light_type raises RuntimeError.""" + cfg = LightCfg(uid="bad", light_type="invalid") + with pytest.raises(RuntimeError, match="Unsupported light type"): + self.sim.add_light(cfg=cfg) + + def test_mesh_light_empty_path_warns(self): + """Mesh light with empty mesh_path warns at creation but succeeds.""" + cfg = LightCfg( + uid="mesh_no_path", + light_type="mesh", + init_pos=(0.0, 0.0, 2.0), + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None, "Mesh light should be created even without path" + + # ------------------------------------------------------------------ + # Setter type-validation tests + # ------------------------------------------------------------------ + + def test_set_direction_on_point_warns(self): + """Calling set_direction on a point light logs a warning and no-ops.""" + cfg = LightCfg(uid="pt", light_type="point", init_pos=(0, 0, 2)) + light = self.sim.add_light(cfg=cfg) + direction = torch.tensor([1.0, 0.0, 0.0]) + # Should not raise; should log a warning + try: + light.set_direction(direction) + except Exception as e: + pytest.fail(f"set_direction on point light should warn, not crash: {e}") + + def test_set_spot_angle_on_point_warns(self): + """Calling set_spot_angle on a non-spot light warns and no-ops.""" + cfg = LightCfg(uid="pt2", light_type="point", init_pos=(0, 0, 2)) + light = self.sim.add_light(cfg=cfg) + inner = torch.tensor(15.0) + outer = torch.tensor(30.0) + try: + light.set_spot_angle(inner, outer) + except Exception as e: + pytest.fail(f"set_spot_angle on point light should warn, not crash: {e}") + + def test_set_rect_wh_on_point_warns(self): + """Calling set_rect_wh on a non-rect light warns and no-ops.""" + cfg = LightCfg(uid="pt3", light_type="point", init_pos=(0, 0, 2)) + light = self.sim.add_light(cfg=cfg) + w = torch.tensor(2.0) + h = torch.tensor(3.0) + try: + light.set_rect_wh(w, h) + except Exception as e: + pytest.fail(f"set_rect_wh on point light should warn, not crash: {e}") + + def test_set_local_pose_on_direction_warns(self): + """Calling set_local_pose on a direction light warns and no-ops.""" + cfg = LightCfg( + uid="dir_light", + light_type="direction", + direction=(0.0, -1.0, 0.0), + ) + light = self.sim.add_light(cfg=cfg) + pose = torch.tensor([1.0, 2.0, 3.0]) + try: + light.set_local_pose(pose) + except Exception as e: + pytest.fail( + f"set_local_pose on direction light should warn, not crash: {e}" + ) + + # ------------------------------------------------------------------ + # Type-specific property tests + # ------------------------------------------------------------------ + + def test_spot_light_has_cone_angles(self): + """Spot light creation applies inner/outer cone angles from cfg.""" + cfg = LightCfg( + uid="spot_test", + light_type="spot", + init_pos=(0.0, 0.0, 3.0), + direction=(0.0, 0.0, -1.0), + spot_angle_inner=20.0, + spot_angle_outer=40.0, + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None + # Should not crash on creation; spot_angle applied during reset() + + def test_rect_light_has_dimensions(self): + """Rect light creation applies width/height from cfg.""" + cfg = LightCfg( + uid="rect_test", + light_type="rect", + init_pos=(0.0, 0.0, 3.0), + direction=(0.0, 0.0, -1.0), + rect_width=2.0, + rect_height=1.5, + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None + + def test_direction_light_no_position_in_reset(self): + """Direction light reset does not set position.""" + cfg = LightCfg( + uid="dir_test", + light_type="direction", + direction=(0.0, -1.0, 0.0), + ) + light = self.sim.add_light(cfg=cfg) + assert light is not None + + # ------------------------------------------------------------------ + # Broadcasting tests + # ------------------------------------------------------------------ + + def test_set_direction_broadcasting(self): + """set_direction with (3,) tensor broadcasts to all instances.""" + cfg = LightCfg( + uid="spot_broadcast", + light_type="spot", + init_pos=(0, 0, 3), + direction=(0.0, 0.0, -1.0), + ) + light = self.sim.add_light(cfg=cfg) + new_dir = torch.tensor([1.0, 0.0, 0.0]) + try: + light.set_direction(new_dir) + except Exception as e: + pytest.fail(f"set_direction broadcast failed: {e}") + + def test_set_direction_with_env_ids(self): + """set_direction with (M, 3) tensor applies per-instance.""" + cfg = LightCfg( + uid="spot_env", + light_type="spot", + init_pos=(0, 0, 3), + direction=(0.0, 0.0, -1.0), + ) + light = self.sim.add_light(cfg=cfg) + env_ids = [0, 2] + dirs = torch.tensor([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + try: + light.set_direction(dirs, env_ids=env_ids) + except Exception as e: + pytest.fail(f"set_direction per-instance failed: {e}") + + def test_enable_shadow(self): + """enable_shadow sets shadow flag on all instances.""" + cfg = LightCfg(uid="shadow_test", light_type="point", init_pos=(0, 0, 2)) + light = self.sim.add_light(cfg=cfg) + try: + light.enable_shadow(torch.tensor(0.0)) # disable + light.enable_shadow(torch.tensor(1.0)) # enable + except Exception as e: + pytest.fail(f"enable_shadow failed: {e}") + + # ------------------------------------------------------------------ + # from_dict compatibility + # ------------------------------------------------------------------ + + def test_from_dict_new_types(self): + """LightCfg.from_dict works with new type fields.""" + cfg = LightCfg.from_dict( + { + "light_type": "spot", + "color": [1.0, 0.9, 0.8], + "intensity": 100.0, + "init_pos": [0.0, 0.0, 3.0], + "direction": [0.0, 0.0, -1.0], + "spot_angle_inner": 25.0, + "spot_angle_outer": 50.0, + "uid": "from_dict_spot", + } + ) + assert cfg.light_type == "spot" + assert cfg.spot_angle_inner == 25.0 + assert cfg.spot_angle_outer == 50.0 + assert cfg.direction == (0.0, 0.0, -1.0) or cfg.direction == [0.0, 0.0, -1.0] + + # ------------------------------------------------------------------ + # Backward compatibility + # ------------------------------------------------------------------ + + def test_point_light_backward_compat(self): + """Existing point-light config pattern still works.""" + cfg_dict = { + "light_type": "point", + "color": [0.1, 0.1, 0.1], + "radius": 10.0, + "position": [0.0, 0.0, 2.0], + "uid": "point_compat", + } + light = self.sim.add_light(cfg=LightCfg.from_dict(cfg_dict)) + assert light is not None + assert light.num_instances == 4 + + # set_color should still work + base_color = torch.tensor([0.1, 0.1, 0.1]) + try: + light.set_color(base_color) + except Exception as e: + pytest.fail(f"set_color failed: {e}") + + # set_falloff should still work + try: + light.set_falloff(torch.tensor(100.0)) + except Exception as e: + pytest.fail(f"set_falloff failed: {e}") From fa57384e782743868b3f13c0e22fc0d5b7563e0c Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 5 Jul 2026 23:26:43 +0800 Subject: [PATCH 08/18] docs: fix add_light Raises docstring to RuntimeError logger.log_error raises RuntimeError, not ValueError. Co-Authored-By: Claude --- embodichain/lab/sim/sim_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 08759ddd..3ec58de2 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -822,7 +822,7 @@ def add_light(self, cfg: LightCfg) -> Light: Light: The created light instance. Raises: - ValueError: If ``cfg.light_type`` is not one of the supported types. + RuntimeError: If ``cfg.light_type`` is not one of the supported types. """ if cfg.uid is None: uid = "light" From 3f3342c8be9682cb7c85833c27b2c8162c14f480 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 7 Jul 2026 01:20:17 +0800 Subject: [PATCH 09/18] wip --- embodichain/lab/sim/cfg.py | 21 ++++++--- embodichain/lab/sim/objects/light.py | 32 +++++++++++--- embodichain/lab/sim/sim_manager.py | 33 ++++++++++---- tests/sim/objects/test_light.py | 64 ++++++++++++++++++++++++---- 4 files changed, 122 insertions(+), 28 deletions(-) diff --git a/embodichain/lab/sim/cfg.py b/embodichain/lab/sim/cfg.py index d29ff11f..4e73aee5 100644 --- a/embodichain/lab/sim/cfg.py +++ b/embodichain/lab/sim/cfg.py @@ -797,12 +797,21 @@ class LightCfg(ObjectBaseCfg): Supports six light types matching the dexsim rendering backend: - - ``"point"``: Omnidirectional point light with position and falloff radius. - - ``"sun"``: Directional sun light with position, direction, and angular radius (sun-specific setters like halo are not yet wired through Python bindings). - - ``"direction"``: Pure directional light at infinite distance (direction only, no position). - - ``"spot"``: Spotlight with position, direction, and inner/outer cone angles. - - ``"rect"``: Rectangular area light with position, direction, width, and height. - - ``"mesh"``: Mesh-based emissive light (requires a MeshObject via :meth:`set_mesh`; not tensor-batched). + - ``"point"``: Per-environment omnidirectional point light with position + and falloff radius. Created as a batched light (one per environment). + - ``"sun"``: Global directional sun light (infinite distance). Created as + a single scene-level instance. Uses direction only; position is ignored. + Sun-specific fields (``angular_radius``, ``halo_size``, ``halo_falloff``) + are reserved for future backend support. + - ``"direction"``: Global pure directional light at infinite distance. + Created as a single scene-level instance. Direction only; no position. + - ``"spot"``: Per-environment spotlight with position, direction, and + inner/outer cone angles. Created as a batched light. + - ``"rect"``: Per-environment rectangular area light with position, + direction, width, and height. Created as a batched light. + - ``"mesh"``: Per-environment mesh-based emissive light. Requires a + :class:`~dexsim.models.MeshObject` via :meth:`~Light.set_mesh` + (not tensor-batched). Created as a batched light. .. attention:: The ``angular_radius``, ``halo_size``, and ``halo_falloff`` fields are diff --git a/embodichain/lab/sim/objects/light.py b/embodichain/lab/sim/objects/light.py index bb9d7427..0ab75dce 100644 --- a/embodichain/lab/sim/objects/light.py +++ b/embodichain/lab/sim/objects/light.py @@ -354,6 +354,19 @@ def enable_shadow( f"enable_shadow: tensor shape {tuple(cpu.shape)} is invalid for broadcasting" ) + @property + def is_global(self) -> bool: + """Whether this light is a global scene light (single instance). + + Global lights (``"sun"``, ``"direction"``) are infinite-distance + light sources that illuminate the entire scene. They are never + batched per environment. + + Returns: + bool: True if the light is a global scene light. + """ + return self.cfg.light_type in ("sun", "direction") + def set_local_pose( self, pose: torch.Tensor, @@ -371,10 +384,10 @@ def set_local_pose( - For matrix input (4,4) broadcast to all, or (M,4,4) with M == num_instances. to_matrix (bool): Interpret `pose` as full 4x4 matrix if True, else as vector(s). """ - if self.cfg.light_type == "direction": + if self.is_global: logger.log_warning( - "set_local_pose not applicable to 'direction' light type " - "(infinite distance, direction only). Use set_direction() instead." + f"set_local_pose not applicable to '{self.cfg.light_type}' light type " + f"(infinite distance, direction only). Use set_direction() instead." ) return @@ -570,6 +583,11 @@ def reset(self, env_ids: Sequence[int] | None = None) -> None: Applies only the properties relevant to ``self.cfg.light_type``. + .. attention:: + For global lights (:attr:`is_global` is True), ``env_ids`` is + normalized to ``None`` because there is only one instance. + Passing per-environment indices is silently ignored. + Args: env_ids (Sequence[int] | None): The environment IDs to reset. If None, resets all environments. @@ -577,6 +595,10 @@ def reset(self, env_ids: Sequence[int] | None = None) -> None: self.cfg: LightCfg light_type = self.cfg.light_type + # Global lights have a single instance — ignore per-env indices. + if self.num_instances == 1: + env_ids = None + # Universal properties self.set_color(torch.as_tensor(self.cfg.color), env_ids=env_ids) self.set_intensity(torch.as_tensor(self.cfg.intensity), env_ids=env_ids) @@ -584,8 +606,8 @@ def reset(self, env_ids: Sequence[int] | None = None) -> None: torch.as_tensor(float(self.cfg.enable_shadow)), env_ids=env_ids ) - # Position (all types except direction) - if light_type != "direction": + # Position (all types except global infinite-distance lights) + if not self.is_global: self.set_local_pose(torch.as_tensor(self.cfg.init_pos), env_ids=env_ids) # Point light: falloff diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 3ec58de2..99e9b1e3 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -807,6 +807,9 @@ def get_asset( "mesh": LightType.MESH, } + # Light types that are created as a single global scene light (not per-environment). + _GLOBAL_LIGHT_TYPES: tuple[str, ...] = ("sun", "direction") + def add_light(self, cfg: LightCfg) -> Light: """Create a light in the scene. @@ -814,6 +817,13 @@ def add_light(self, cfg: LightCfg) -> Light: ``"spot"``, ``"rect"``, and ``"mesh"``. See :class:`LightCfg` for type-specific configuration fields. + .. attention:: + ``"sun"`` and ``"direction"`` lights are global scene lights + (infinite-distance directional light sources). They are created + as a single instance on the root environment, not batched per + environment. All other types are created as per-environment + batched lights. + Args: cfg (LightCfg): Configuration for the light, including type, color, intensity, and type-specific properties. @@ -856,14 +866,21 @@ def add_light(self, cfg: LightCfg) -> Light: f"(width={cfg.rect_width}, height={cfg.rect_height})." ) - env_list = [self._env] if len(self._arenas) == 0 else self._arenas - light_list = [] - for i, env in enumerate(env_list): - light_name = f"{uid}_{i}" - light = env.create_light(light_name, light_type) - light_list.append(light) - - batch_lights = Light(cfg=cfg, entities=light_list) + if cfg.light_type in self._GLOBAL_LIGHT_TYPES: + # Global scene light: create a single instance on the root + # environment. Infinite-distance lights (sun, direction) are + # physically scene-global and should not be duplicated per arena. + light = self._env.create_light(uid, light_type) + batch_lights = Light(cfg=cfg, entities=[light]) + else: + # Per-environment batched light: one instance per arena. + env_list = [self._env] if len(self._arenas) == 0 else self._arenas + light_list = [] + for i, env in enumerate(env_list): + light_name = f"{uid}_{i}" + light = env.create_light(light_name, light_type) + light_list.append(light) + batch_lights = Light(cfg=cfg, entities=light_list) self._lights[uid] = batch_lights diff --git a/tests/sim/objects/test_light.py b/tests/sim/objects/test_light.py index 2e4d3f00..072157cb 100644 --- a/tests/sim/objects/test_light.py +++ b/tests/sim/objects/test_light.py @@ -184,11 +184,22 @@ def setup(self): # ------------------------------------------------------------------ @pytest.mark.parametrize( - "light_type", - ["point", "sun", "direction", "spot", "rect", "mesh"], + "light_type, expected_num_instances", + [ + ("point", 4), + ("sun", 1), + ("direction", 1), + ("spot", 4), + ("rect", 4), + ("mesh", 4), + ], ) - def test_create_each_light_type(self, light_type): - """Each of the 6 light types can be created without error.""" + def test_create_each_light_type(self, light_type, expected_num_instances): + """Each of the 6 light types can be created without error. + + ``sun`` and ``direction`` are global scene lights with a single instance. + All other types are per-environment batched lights. + """ cfg = LightCfg( uid=f"test_{light_type}", light_type=light_type, @@ -196,7 +207,9 @@ def test_create_each_light_type(self, light_type): ) light = self.sim.add_light(cfg=cfg) assert light is not None, f"Failed to create light of type '{light_type}'" - assert light.num_instances == 4 + assert light.num_instances == expected_num_instances + if light_type in ("sun", "direction"): + assert light.is_global, f"{light_type} should be a global light" def test_unknown_light_type_errors(self): """Passing an invalid light_type raises RuntimeError.""" @@ -267,6 +280,20 @@ def test_set_local_pose_on_direction_warns(self): f"set_local_pose on direction light should warn, not crash: {e}" ) + def test_set_local_pose_on_sun_warns(self): + """Calling set_local_pose on a sun light warns and no-ops.""" + cfg = LightCfg( + uid="sun_light", + light_type="sun", + direction=(0.0, -1.0, 0.0), + ) + light = self.sim.add_light(cfg=cfg) + pose = torch.tensor([1.0, 2.0, 3.0]) + try: + light.set_local_pose(pose) + except Exception as e: + pytest.fail(f"set_local_pose on sun light should warn, not crash: {e}") + # ------------------------------------------------------------------ # Type-specific property tests # ------------------------------------------------------------------ @@ -298,15 +325,34 @@ def test_rect_light_has_dimensions(self): light = self.sim.add_light(cfg=cfg) assert light is not None - def test_direction_light_no_position_in_reset(self): - """Direction light reset does not set position.""" + @pytest.mark.parametrize("light_type", ["sun", "direction"]) + def test_global_light_no_position_in_reset(self, light_type): + """Global light (sun/direction) reset does not set position.""" cfg = LightCfg( - uid="dir_test", - light_type="direction", + uid=f"{light_type}_test", + light_type=light_type, direction=(0.0, -1.0, 0.0), ) light = self.sim.add_light(cfg=cfg) assert light is not None + assert light.is_global + assert light.num_instances == 1 + + @pytest.mark.parametrize("light_type", ["sun", "direction"]) + def test_reset_global_light_with_env_ids(self, light_type): + """reset_objects_state with env_ids does not crash for global lights.""" + cfg = LightCfg( + uid=f"global_{light_type}", + light_type=light_type, + direction=(0.0, -1.0, 0.0), + ) + self.sim.add_light(cfg=cfg) + try: + self.sim.reset_objects_state(env_ids=[1, 2]) + except Exception as e: + pytest.fail( + f"reset_objects_state with global light ({light_type}) failed: {e}" + ) # ------------------------------------------------------------------ # Broadcasting tests From f32ff9d5a75bb089382b036876d441094ee1ccbd Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 7 Jul 2026 11:52:09 +0800 Subject: [PATCH 10/18] wip --- embodichain/lab/sim/sim_manager.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 99e9b1e3..27932d54 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -306,6 +306,7 @@ def __init__( self._create_default_plane() self.set_default_background() + self.set_default_global_lighting() # Set physics to manual update mode by default. self.set_manual_update(True) @@ -701,6 +702,27 @@ def _create_default_plane(self): # TODO: add default physics attributes for the plane. + def set_default_global_lighting(self) -> None: + """Set default global lighting for the scene. + + Configures both the environment emission (ambient) light and a + directional light to provide default scene illumination. The + directional light is a global scene light (infinite distance) + pointing downward along the -Z axis. + """ + # Environment emission light + self.set_emission_light([1.0, 1.0, 1.0], 120.0) + + # Directional light as global scene light + dir_light_cfg = LightCfg( + uid="global_light", + light_type="direction", + intensity=8.0, + direction=(0.0, 0, -1.0), + color=(1.0, 0.95, 0.85), + ) + self.add_light(dir_light_cfg) + def set_default_background(self) -> None: """Set default background.""" @@ -714,12 +736,10 @@ def set_default_background(self) -> None: uid=mat_name, base_color_texture=color_texture, roughness_texture=roughness_texture, - roughness=0.7, + roughness=0.9, ) ) - self.set_emission_light([1.0, 1.0, 1.0], 120.0) - self._default_plane.set_material(mat.get_instance("plane_mat").mat) self._visual_materials[mat_name] = mat @@ -842,7 +862,6 @@ def add_light(self, cfg: LightCfg) -> Light: if uid in self._lights: logger.log_error(f"Light {uid} already exists.") - return None light_type_str = cfg.light_type light_type = self._LIGHT_TYPE_MAP.get(light_type_str) From 27585abe42b0f917a64e01cf4347be63a7cdfe79 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 7 Jul 2026 12:38:42 +0800 Subject: [PATCH 11/18] feat(rendering): add DLSS 3.5 support to RenderCfg and SimulationManager Add DlssCfg @configclass with full DLSS 3.5 parameter set (Ray Reconstruction, Super Resolution, quality presets, resolution scaling, exposure compensation). Wire DLSS config into SimulationManager._convert_sim_config with validation warnings for incompatible configurations and automatic window-to-target resolution alignment. References: dexsim developer_docs/dlss/README_DLSS.md Co-Authored-By: Claude --- embodichain/lab/sim/cfg.py | 65 ++++++++++++++++++++++++++++++ embodichain/lab/sim/sim_manager.py | 41 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/embodichain/lab/sim/cfg.py b/embodichain/lab/sim/cfg.py index 81128bd0..9744387b 100644 --- a/embodichain/lab/sim/cfg.py +++ b/embodichain/lab/sim/cfg.py @@ -53,6 +53,66 @@ DEFAULT_RENDERER: Literal["auto", "hybrid", "fast-rt", "rt"] = "auto" +@configclass +class DLSSCfg: + """DLSS 3.5 configuration for the simulation window. + + NVIDIA DLSS 3.5 provides two AI-powered features for the OfflineRT renderer: + + - **Ray Reconstruction (RR)**: Replaces the OptiX denoiser. It takes the noisy + 1-spp path-traced HDR image plus G-buffer guides (albedo, normals, roughness, + depth, motion vectors) and reconstructs a clean image at render resolution. + - **Super Resolution (SR)**: Upscales the RR output from render resolution to + target/display resolution. + + .. attention:: + DLSS is only available with the ``"rt"`` (OfflineRT) renderer in windowed + mode. Offscreen cameras always use the OptiX denoiser regardless of DLSS + settings. The window resolution (``SimulationManagerCfg.width/height``) + should match ``target_width/target_height`` when upscaling is enabled. + + Reference: ``developer_docs/dlss/README_DLSS.md`` in the dexsim repository. + """ + + dlss_enabled: bool = False + """Master DLSS enable toggle. Off → standard OptiX denoiser / TAA path.""" + + rayreconstruction_enabled: bool = True + """Enable DLSS Ray Reconstruction (AI denoiser). Replaces the OptiX denoiser + for the window camera only.""" + + upscale_enabled: bool = False + """Enable DLSS Super Resolution (AI upscaler). When enabled, the path tracer + renders at ``render_width/height`` and DLSS-SR upscales to + ``target_width/height``.""" + + dlss_quality: int = 2 + """DLSS quality preset. ``-1`` = auto (ratio-based), ``0`` = UltraPerformance + (~3.0×), ``1`` = Performance (~2.25×), ``2`` = Balanced (~1.75×, default), + ``3`` = Quality (~1.5×), ``4`` = UltraQuality (~1.3×), ``5`` = DLAA (1.0×, + no upscale — render must equal target).""" + + render_width: int = 0 + """Internal render resolution width. ``0`` = use window width. + The path tracer and DLSS-RR operate at this resolution.""" + + render_height: int = 0 + """Internal render resolution height. ``0`` = use window height.""" + + target_width: int = 0 + """Target/display resolution width. ``0`` = use window width. + DLSS-SR upscales to this resolution. Should match the window size.""" + + target_height: int = 0 + """Target/display resolution height. ``0`` = use window height.""" + + exposure_compensation: float = 1.0 + """Multiplier on the DLSS-RR pre-exposure scalar. ``>1`` (e.g. ``2.0``) + brightens the HDR fed to RR, speeding ghost rejection in dark areas; + ``<1`` darkens. ``1.0`` = neutral. Typical range 0.5–8.0; tune per scene. + No rebuild needed.""" + + @configclass class RenderCfg: renderer: Literal["auto", "hybrid", "fast-rt", "rt"] = "auto" @@ -71,6 +131,11 @@ class RenderCfg: spp: int = 1 """Samples per pixel for ray tracing rendering. This parameter is only valid when renderer is 'hybrid', 'fast-rt' or 'rt'.""" + dlss: DLSSCfg | None = None + """DLSS 3.5 configuration for AI-powered denoising and upscaling. + Only effective when ``renderer="rt"`` (OfflineRT) and windowed mode. + Set to ``None`` to disable DLSS entirely.""" + def to_dexsim_flags(self): if self.renderer == "hybrid": return Renderer.HYBRID diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 27932d54..aede589a 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -75,6 +75,7 @@ ContactSensor, ) from embodichain.lab.sim.cfg import ( + DLSSCfg, RenderCfg, PhysicsCfg, MarkerCfg, @@ -96,6 +97,7 @@ __all__ = [ "SimulationManager", "SimulationManagerCfg", + "DLSSCfg", "SIM_CACHE_DIR", "MATERIAL_CACHE_DIR", "CONVEX_DECOMP_DIR", @@ -474,6 +476,45 @@ def _convert_sim_config( sim_config.render_cfg.spp ) + # Configure DLSS 3.5 (Ray Reconstruction + Super Resolution). + # Only effective with OfflineRT renderer in windowed mode. + if sim_config.render_cfg.dlss is not None: + dlss_cfg = sim_config.render_cfg.dlss + if dlss_cfg.dlss_enabled: + if sim_config.render_cfg.renderer != "rt": + logger.log_warning( + f"DLSS is enabled but renderer is '{sim_config.render_cfg.renderer}', " + f"not 'rt' (OfflineRT). DLSS only works with the OfflineRT renderer. " + f"DLSS settings will be ignored." + ) + elif sim_config.headless: + logger.log_warning( + "DLSS is enabled but the simulation is running in headless mode. " + "DLSS is window-only. DLSS settings will be ignored." + ) + else: + dlss = world_config.dlss_config + dlss.dlss_enabled = True + dlss.rayreconstruction_enabled = dlss_cfg.rayreconstruction_enabled + dlss.upscale_enabled = dlss_cfg.upscale_enabled + dlss.dlss_quality = dlss_cfg.dlss_quality + dlss.render_width = dlss_cfg.render_width + dlss.render_height = dlss_cfg.render_height + dlss.target_width = dlss_cfg.target_width + dlss.target_height = dlss_cfg.target_height + dlss.exposure_compensation = dlss_cfg.exposure_compensation + + # When DLSS upscaling is enabled and target dimensions are + # specified, align the window size with the target resolution. + # Rule: win_config.width/height == target_width/height. + if ( + dlss_cfg.upscale_enabled + and dlss_cfg.target_width > 0 + and dlss_cfg.target_height > 0 + ): + win_config.width = dlss_cfg.target_width + win_config.height = dlss_cfg.target_height + if type(sim_config.sim_device) is str: self.device = torch.device(sim_config.sim_device) else: From 7f6b748690c497353b9eda858570127af065977a Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 7 Jul 2026 14:10:56 +0800 Subject: [PATCH 12/18] wip --- .../topics/randomization/randomization.md | 5 +- docs/source/overview/gym/event_functors.md | 13 +- docs/source/overview/sim/sim_assets.md | 23 +- docs/source/tutorial/modular_env.rst | 9 +- .../gym/envs/managers/randomization/visual.py | 84 ++++- .../gym/envs/managers/test_event_functors.py | 325 ++++++++++++++++++ 6 files changed, 431 insertions(+), 28 deletions(-) diff --git a/agent_context/topics/randomization/randomization.md b/agent_context/topics/randomization/randomization.md index b7f894a6..05720600 100644 --- a/agent_context/topics/randomization/randomization.md +++ b/agent_context/topics/randomization/randomization.md @@ -42,13 +42,16 @@ The `__init__.py` of the randomization package re-exports everything via `from . | `randomize_visual_material` | Random material properties | (varies) | | `randomize_camera_extrinsics` | Camera pose | `pos_range`, `euler_range` (attach mode) or `eye_range`, `target_range`, `up_range` (look-at mode) | | `randomize_camera_intrinsics` | Camera intrinsic params | (varies) | -| `randomize_light` | Light pos/color/intensity | `position_range`, `color_range`, `intensity_range` | +| `randomize_light` | Light pos/color/intensity/direction | `position_range`, `color_range`, `intensity_range`, `direction_range` | | `randomize_emission_light` | Emission light props | (varies) | | `randomize_indirect_lighting` | Indirect lighting | (varies) | - Camera extrinsics auto-detect mode: if `extrinsics.parent` is set → attach mode (pos/euler perturbation via `set_local_pose`); if `extrinsics.eye` is set → look-at mode (eye/target/up perturbation via `look_at`). - `set_rigid_object_visual_material` is deterministic (not random) but uses the same functor mechanism for fixed material assignment at reset. - Light randomization applies the **same values across all envs** (documented limitation). +- ``position_range`` is ignored for global scene lights (``"sun"``, ``"direction"``). Use ``direction_range`` instead. +- ``direction_range`` is only applicable for directional light types (``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, ``"mesh"``). +- Global lights (``"sun"``, ``"direction"``) have ``num_instances == 1``; all other types are batched per environment. ### Spatial (`spatial.py`) diff --git a/docs/source/overview/gym/event_functors.md b/docs/source/overview/gym/event_functors.md index eab6ad95..2ab03ddf 100644 --- a/docs/source/overview/gym/event_functors.md +++ b/docs/source/overview/gym/event_functors.md @@ -64,7 +64,15 @@ This page lists all available event functors that can be used with the Event Man "base_color_range": [[0.2, 0.2, 0.2], [1.0, 1.0, 1.0]]}} ``` * - {func}`~randomization.visual.randomize_light` - - Vary light position, color, and intensity within specified ranges. + - Vary light position, color, intensity, and direction within specified ranges. + + .. note:: + ``position_range`` is ignored for global scene lights (``"sun"``, ``"direction"``). + Use ``direction_range`` instead for these types. + + .. note:: + ``direction_range`` is only applicable for directional light types + (``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, ``"mesh"``). ```json {"func": "randomize_light", @@ -72,7 +80,8 @@ This page lists all available event functors that can be used with the Event Man "params": {"entity_cfg": {"uid": "light_1"}, "position_range": [[-0.5, -0.5, 2], [0.5, 0.5, 2]], "color_range": [[0.6, 0.6, 0.6], [1, 1, 1]], - "intensity_range": [50.0, 100.0]}} + "intensity_range": [50.0, 100.0], + "direction_range": [[-0.1, -0.1, -0.1], [0.1, 0.1, 0.1]]}} ``` * - {func}`~randomization.visual.randomize_emission_light` - Randomize global emission light color and intensity. Applies the same emission light properties across all environments. diff --git a/docs/source/overview/sim/sim_assets.md b/docs/source/overview/sim/sim_assets.md index ab49dfbb..fca7351f 100644 --- a/docs/source/overview/sim/sim_assets.md +++ b/docs/source/overview/sim/sim_assets.md @@ -112,14 +112,27 @@ For Rigid Object tutorial, please refer to the [Create Scene](https://dexforce.g ### Lights -Configured via `LightCfg`. +Configured via `LightCfg`. Supports six light types matching the dexsim rendering backend. | Parameter | Type | Default | Description | | :--- | :--- | :--- | :--- | -| `light_type` | `Literal` | `"point"` | Type of light (currently only "point"). | -| `color` | `tuple` | `(1.0, 1.0, 1.0)` | RGB color. | -| `intensity` | `float` | `50.0` | Intensity in watts/m^2. | -| `radius` | `float` | `1e2` | Falloff radius. | +| `light_type` | `Literal` | `"point"` | Light type: ``"point"``, ``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, or ``"mesh"``. | +| `color` | `tuple` | `(1.0, 1.0, 1.0)` | RGB color of the light source. | +| `intensity` | `float` | `30.0` | Intensity of the light source in watts/m^2. | +| `enable_shadow` | `bool` | `True` | Whether the light casts shadows. | +| `radius` | `float` | `10.0` | Falloff radius (only for ``"point"``). | +| `direction` | `tuple` | `(0.0, 0.0, -1.0)` | Direction vector for directional types (``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, ``"mesh"``). | +| `spot_angle_inner` | `float` | `30.0` | Inner cone angle in degrees (only for ``"spot"``). | +| `spot_angle_outer` | `float` | `45.0` | Outer cone angle in degrees (only for ``"spot"``). | +| `rect_width` | `float` | `1.0` | Width of rectangular area light (only for ``"rect"``). | +| `rect_height` | `float` | `1.0` | Height of rectangular area light (only for ``"rect"``). | +| `mesh_path` | `str` | `""` | Asset path for mesh-based emissive lights (only for ``"mesh"``). | + +.. attention:: + ``"sun"`` and ``"direction"`` are **global scene lights** (infinite-distance directional light + sources). They are created as a single instance on the root environment, not batched per + environment. Use :meth:`Light.set_direction` instead of :meth:`Light.set_local_pose` for + these types. ```{toctree} diff --git a/docs/source/tutorial/modular_env.rst b/docs/source/tutorial/modular_env.rst index eef801c3..af4d1499 100644 --- a/docs/source/tutorial/modular_env.rst +++ b/docs/source/tutorial/modular_env.rst @@ -129,11 +129,12 @@ Configures a stereo camera system using :class:`StereoCameraCfg`: :language: python :lines: 120-130 -Defines scene illumination with controllable point lights: +Defines scene illumination with configurable lights: -- **Type**: Point light for realistic shadows -- **Properties**: Configurable color, intensity, and position -- **UID**: Named reference for event system manipulation +- **Types**: Supports ``"point"``, ``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, and ``"mesh"``. +- **Global lights**: ``"sun"`` and ``"direction"`` are global scene lights (single instance, infinite distance). +- **Properties**: Configurable color, intensity, position, and direction. +- **UID**: Named reference for event system manipulation. **Rigid Objects** diff --git a/embodichain/lab/gym/envs/managers/randomization/visual.py b/embodichain/lab/gym/envs/managers/randomization/visual.py index c49707bd..bb4a08ac 100644 --- a/embodichain/lab/gym/envs/managers/randomization/visual.py +++ b/embodichain/lab/gym/envs/managers/randomization/visual.py @@ -272,6 +272,7 @@ def randomize_light( position_range: tuple[list[float], list[float]] | None = None, color_range: tuple[list[float], list[float]] | None = None, intensity_range: tuple[float, float] | None = None, + direction_range: tuple[list[float], list[float]] | None = None, ) -> None: """Randomize light properties by adding, scaling, or setting random values. @@ -289,10 +290,17 @@ def randomize_light( position_range is the x, y, z value added into light's cfg.init_pos. color_range is the absolute r, g, b value set to the light object. intensity_range is the value added into light's cfg.intensity. + direction_range is the x, y, z value added into light's cfg.direction. + (Only applicable for ``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, and ``"mesh"`` light types.) .. tip:: This function uses CPU tensors to assign light properties. + .. warning:: + ``position_range`` is ignored for global scene lights (``"sun"``, ``"direction"``) + because they are infinite-distance lights with no meaningful position. + Use ``direction_range`` instead for these light types. + Args: env (EmbodiedEnv): The environment instance. env_ids (Union[torch.Tensor, None]): The environment IDs to apply the randomization. @@ -300,25 +308,45 @@ def randomize_light( position_range (tuple[list[float], list[float]] | None): The range for the position randomization. color_range (tuple[list[float], list[float]] | None): The range for the color randomization. intensity_range (tuple[float, float] | None): The range for the intensity randomization. + direction_range (tuple[list[float], list[float]] | None): The range for the direction randomization. + Only applicable for directional light types (``"sun"``, ``"direction"``, ``"spot"``, + ``"rect"``, ``"mesh"``). """ light: Light = env.sim.get_light(entity_cfg.uid) - num_instance = len(env_ids) + if light is None: + return + + is_global = light.is_global if hasattr(light, "is_global") else False + + # For global lights, normalize env_ids to avoid index-out-of-range + if is_global or light.num_instances == 1: + num_instance = 1 + effective_env_ids = None + else: + num_instance = len(env_ids) + effective_env_ids = env_ids if position_range: - init_pos = light.cfg.init_pos - new_pos = ( - torch.tensor(init_pos, dtype=torch.float32) - .unsqueeze_(0) - .repeat(num_instance, 1) - ) - random_value = sample_uniform( - lower=torch.tensor(position_range[0]), - upper=torch.tensor(position_range[1]), - size=new_pos.shape, - ) - new_pos += random_value - light.set_local_pose(new_pos, env_ids=env_ids) + if is_global: + logger.log_warning( + f"position_range ignored for global light '{entity_cfg.uid}' " + f"(type='{light.cfg.light_type}'). Use direction_range instead." + ) + else: + init_pos = light.cfg.init_pos + new_pos = ( + torch.tensor(init_pos, dtype=torch.float32) + .unsqueeze_(0) + .repeat(num_instance, 1) + ) + random_value = sample_uniform( + lower=torch.tensor(position_range[0]), + upper=torch.tensor(position_range[1]), + size=new_pos.shape, + ) + new_pos += random_value + light.set_local_pose(new_pos, env_ids=effective_env_ids) if color_range: color = torch.zeros((num_instance, 3), dtype=torch.float32) @@ -328,7 +356,7 @@ def randomize_light( size=color.shape, ) color += random_value - light.set_color(color, env_ids=env_ids) + light.set_color(color, env_ids=effective_env_ids) if intensity_range: init_intensity = light.cfg.intensity @@ -344,7 +372,31 @@ def randomize_light( ) new_intensity += random_value new_intensity.squeeze_(1) - light.set_intensity(new_intensity, env_ids=env_ids) + light.set_intensity(new_intensity, env_ids=effective_env_ids) + + if direction_range: + light_type = light.cfg.light_type + if light_type not in ("sun", "direction", "spot", "rect", "mesh"): + logger.log_warning( + f"direction_range ignored for light '{entity_cfg.uid}' " + f"(type='{light_type}'). Direction only applicable to " + f"'sun', 'direction', 'spot', 'rect', and 'mesh' types." + ) + return + + init_dir = light.cfg.direction + new_dir = ( + torch.tensor(init_dir, dtype=torch.float32) + .unsqueeze_(0) + .repeat(num_instance, 1) + ) + random_value = sample_uniform( + lower=torch.tensor(direction_range[0]), + upper=torch.tensor(direction_range[1]), + size=new_dir.shape, + ) + new_dir += random_value + light.set_direction(new_dir, env_ids=effective_env_ids) def randomize_emission_light( diff --git a/tests/gym/envs/managers/test_event_functors.py b/tests/gym/envs/managers/test_event_functors.py index 981e44ab..4c6cb8f0 100644 --- a/tests/gym/envs/managers/test_event_functors.py +++ b/tests/gym/envs/managers/test_event_functors.py @@ -117,6 +117,64 @@ def set_mass(self, link_name: str, mass: float) -> int: return 0 +class MockLight: + """Mock light for event functor tests. + + Supports both global lights (num_instances=1 for sun/direction) + and per-environment batched lights (num_instances=num_envs). + """ + + def __init__( + self, + uid: str = "test_light", + num_envs: int = 4, + light_type: str = "point", + init_pos: tuple = (0.0, 0.0, 2.0), + direction: tuple = (0.0, 0.0, -1.0), + intensity: float = 30.0, + color: tuple = (1.0, 1.0, 1.0), + ): + self.uid = uid + + # Config-like attributes + class CfgProxy: + pass + + self.cfg = CfgProxy() + self.cfg.light_type = light_type + self.cfg.init_pos = init_pos + self.cfg.direction = direction + self.cfg.intensity = intensity + self.cfg.color = color + + self.device = torch.device("cpu") + self.is_global = light_type in ("sun", "direction") + self.num_instances = 1 if self.is_global else num_envs + + # Track calls for assertions + self._last_set_color = None + self._last_set_intensity = None + self._last_set_local_pose = None + self._last_set_direction = None + self._last_env_ids = None + + def set_color(self, color, env_ids=None): + self._last_set_color = color + self._last_env_ids = env_ids + + def set_intensity(self, intensity, env_ids=None): + self._last_set_intensity = intensity + self._last_env_ids = env_ids + + def set_local_pose(self, pose, env_ids=None, to_matrix=False): + self._last_set_local_pose = pose + self._last_env_ids = env_ids + + def set_direction(self, direction, env_ids=None): + self._last_set_direction = direction + self._last_env_ids = env_ids + + class MockArticulation: """Mock articulation for event functor tests.""" @@ -238,6 +296,7 @@ def __init__(self, num_envs: int = 4): self._articulations = {} self._robots = {} self._rigid_object_groups = {} + self._lights = {} def get_rigid_object(self, uid: str): return self._rigid_objects.get(uid) @@ -290,6 +349,13 @@ def set_emission_light(self, color=None, intensity=None) -> None: self._last_emission_color = color self._last_emission_intensity = intensity + def get_light(self, uid: str): + return self._lights.get(uid) + + def add_light(self, light: MockLight): + self._lights[light.uid] = light + return light + class MockEnv: """Mock environment for event functor tests.""" @@ -333,7 +399,9 @@ def __init__(self, num_envs: int = 4, num_joints: int = 6): ) from embodichain.lab.gym.envs.managers.randomization.visual import ( randomize_indirect_lighting, + randomize_light, ) +from embodichain.lab.gym.envs.managers.cfg import SceneEntityCfg from embodichain.lab.gym.envs.managers import FunctorCfg @@ -1010,3 +1078,260 @@ def test_emissive_values_vary_across_calls(self): # Over 20 draws from [0, 1000] all values being identical is astronomically unlikely assert len(intensities) > 1 + + +# -------------------------------------------------------------------------- +# randomize_light tests +# -------------------------------------------------------------------------- + + +class TestRandomizeLight: + """Tests for the randomize_light function.""" + + # ------------------------------------------------------------------ + # Per-environment batched light tests (point light) + # ------------------------------------------------------------------ + + def test_randomize_color(self): + """color_range randomizes the light color for all envs.""" + env = MockEnv(num_envs=4) + light = MockLight("test_pt", num_envs=4, light_type="point") + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_pt") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + color_range=[[0.2, 0.2, 0.2], [0.8, 0.8, 0.8]], + ) + + assert light._last_set_color is not None + assert light._last_set_color.shape == (4, 3) + # All values should be within [0.2, 0.8] + assert (light._last_set_color >= 0.2).all() + assert (light._last_set_color <= 0.8).all() + + def test_randomize_intensity(self): + """intensity_range randomizes the light intensity for all envs. + + The intensity_range is additive: random value is added to cfg.intensity. + """ + env = MockEnv(num_envs=4) + light = MockLight("test_pt", num_envs=4, light_type="point") + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_pt") + env_ids = torch.tensor([0, 1, 2, 3]) + + # Use a small additive range so we can bound the result + randomize_light( + env, + env_ids, + entity_cfg, + intensity_range=(10.0, 20.0), + ) + + assert light._last_set_intensity is not None + assert light._last_set_intensity.shape == (4,) + # init_intensity=30 + rand(10,20) => [40, 50] + assert (light._last_set_intensity >= 40.0).all() + assert (light._last_set_intensity <= 50.0).all() + + def test_randomize_position(self): + """position_range randomizes the light position for batched lights.""" + env = MockEnv(num_envs=4) + light = MockLight("test_pt", num_envs=4, light_type="point") + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_pt") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + position_range=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]], + ) + + assert light._last_set_local_pose is not None + assert light._last_set_local_pose.shape == (4, 3) + + def test_randomize_direction(self): + """direction_range randomizes the light direction for spot lights.""" + env = MockEnv(num_envs=4) + light = MockLight("test_spot", num_envs=4, light_type="spot") + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_spot") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + direction_range=[[-0.2, -0.2, -0.2], [0.2, 0.2, 0.2]], + ) + + assert light._last_set_direction is not None + assert light._last_set_direction.shape == (4, 3) + + def test_direction_range_ignored_for_point_light(self): + """direction_range is ignored for point lights (no crash).""" + env = MockEnv(num_envs=4) + light = MockLight("test_pt", num_envs=4, light_type="point") + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_pt") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + direction_range=[[-0.1, -0.1, -0.1], [0.1, 0.1, 0.1]], + ) + + # direction should NOT have been set for point light + assert light._last_set_direction is None + + def test_combined_randomization(self): + """All four ranges can be combined in one call.""" + env = MockEnv(num_envs=4) + light = MockLight("test_spot", num_envs=4, light_type="spot") + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_spot") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + position_range=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]], + color_range=[[0.2, 0.2, 0.2], [0.8, 0.8, 0.8]], + intensity_range=(50.0, 100.0), + direction_range=[[-0.1, -0.1, -0.1], [0.1, 0.1, 0.1]], + ) + + assert light._last_set_local_pose is not None + assert light._last_set_color is not None + assert light._last_set_intensity is not None + assert light._last_set_direction is not None + + def test_light_not_found_returns_early(self): + """No crash when light UID is not registered.""" + env = MockEnv(num_envs=4) + entity_cfg = SceneEntityCfg(uid="nonexistent") + env_ids = torch.tensor([0, 1, 2, 3]) + + # Should not raise + try: + randomize_light( + env, + env_ids, + entity_cfg, + color_range=[[0.2, 0.2, 0.2], [0.8, 0.8, 0.8]], + ) + except Exception as e: + pytest.fail(f"randomize_light should not crash on missing light: {e}") + + # ------------------------------------------------------------------ + # Global scene light tests (sun, direction) + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("light_type", ["sun", "direction"]) + def test_global_light_position_range_ignored(self, light_type): + """position_range is ignored for global lights (sun/direction).""" + env = MockEnv(num_envs=4) + light = MockLight("test_global", num_envs=1, light_type=light_type) + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_global") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + position_range=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]], + ) + + # set_local_pose should NOT be called for global lights + assert light._last_set_local_pose is None + + @pytest.mark.parametrize("light_type", ["sun", "direction"]) + def test_global_light_color_applied_single_instance(self, light_type): + """color_range works for global lights — single instance broadcast.""" + env = MockEnv(num_envs=4) + light = MockLight("test_global", num_envs=1, light_type=light_type) + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_global") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + color_range=[[0.3, 0.3, 0.3], [0.7, 0.7, 0.7]], + ) + + assert light._last_set_color is not None + # Global light: single instance, color shape should be (1, 3) + assert light._last_set_color.shape == (1, 3) + # env_ids=None for single-instance lights + assert light._last_env_ids is None + + @pytest.mark.parametrize("light_type", ["sun", "direction"]) + def test_global_light_intensity_applied_single_instance(self, light_type): + """intensity_range works for global lights — single instance.""" + env = MockEnv(num_envs=4) + light = MockLight("test_global", num_envs=1, light_type=light_type) + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_global") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + intensity_range=(50.0, 100.0), + ) + + assert light._last_set_intensity is not None + assert light._last_set_intensity.shape == (1,) + + @pytest.mark.parametrize("light_type", ["sun", "direction"]) + def test_global_light_direction_range_applied(self, light_type): + """direction_range works for global lights (sun/direction).""" + env = MockEnv(num_envs=4) + light = MockLight("test_global", num_envs=1, light_type=light_type) + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_global") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light( + env, + env_ids, + entity_cfg, + direction_range=[[-0.2, -0.2, -0.2], [0.2, 0.2, 0.2]], + ) + + assert light._last_set_direction is not None + assert light._last_set_direction.shape == (1, 3) + assert light._last_env_ids is None + + # ------------------------------------------------------------------ + # Edge cases + # ------------------------------------------------------------------ + + def test_no_ranges_does_nothing(self): + """Calling with no ranges set does not crash.""" + env = MockEnv(num_envs=4) + light = MockLight("test_pt", num_envs=4, light_type="point") + env.sim.add_light(light) + entity_cfg = SceneEntityCfg(uid="test_pt") + env_ids = torch.tensor([0, 1, 2, 3]) + + randomize_light(env, env_ids, entity_cfg) + + # Nothing should have been called + assert light._last_set_color is None + assert light._last_set_intensity is None + assert light._last_set_local_pose is None + assert light._last_set_direction is None From 4f0cd1c80307eb5ad8f41131b6ca3f6ca6eeb7ee Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 7 Jul 2026 14:20:45 +0800 Subject: [PATCH 13/18] fix(dlss): make DLSSCfg default disabled and always present on RenderCfg - Change DLSSCfg.dlss_enabled default from True to False - Change RenderCfg.dlss default from None to field(default_factory=DLSSCfg) - Simplify SimulationManager._convert_sim_config to read dlss directly Co-Authored-By: Claude --- embodichain/lab/sim/cfg.py | 8 ++-- embodichain/lab/sim/sim_manager.py | 71 +++++++++++++++--------------- 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/embodichain/lab/sim/cfg.py b/embodichain/lab/sim/cfg.py index 9744387b..bf200369 100644 --- a/embodichain/lab/sim/cfg.py +++ b/embodichain/lab/sim/cfg.py @@ -86,7 +86,7 @@ class DLSSCfg: renders at ``render_width/height`` and DLSS-SR upscales to ``target_width/height``.""" - dlss_quality: int = 2 + dlss_quality: int = 5 """DLSS quality preset. ``-1`` = auto (ratio-based), ``0`` = UltraPerformance (~3.0×), ``1`` = Performance (~2.25×), ``2`` = Balanced (~1.75×, default), ``3`` = Quality (~1.5×), ``4`` = UltraQuality (~1.3×), ``5`` = DLAA (1.0×, @@ -131,10 +131,10 @@ class RenderCfg: spp: int = 1 """Samples per pixel for ray tracing rendering. This parameter is only valid when renderer is 'hybrid', 'fast-rt' or 'rt'.""" - dlss: DLSSCfg | None = None + dlss: DLSSCfg = field(default_factory=DLSSCfg) """DLSS 3.5 configuration for AI-powered denoising and upscaling. - Only effective when ``renderer="rt"`` (OfflineRT) and windowed mode. - Set to ``None`` to disable DLSS entirely.""" + Only effective when ``renderer="rt"`` (OfflineRT), windowed mode, and + ``dlss_enabled=True``. Defaults to disabled.""" def to_dexsim_flags(self): if self.renderer == "hybrid": diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index aede589a..e5b4a0b6 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -478,42 +478,41 @@ def _convert_sim_config( # Configure DLSS 3.5 (Ray Reconstruction + Super Resolution). # Only effective with OfflineRT renderer in windowed mode. - if sim_config.render_cfg.dlss is not None: - dlss_cfg = sim_config.render_cfg.dlss - if dlss_cfg.dlss_enabled: - if sim_config.render_cfg.renderer != "rt": - logger.log_warning( - f"DLSS is enabled but renderer is '{sim_config.render_cfg.renderer}', " - f"not 'rt' (OfflineRT). DLSS only works with the OfflineRT renderer. " - f"DLSS settings will be ignored." - ) - elif sim_config.headless: - logger.log_warning( - "DLSS is enabled but the simulation is running in headless mode. " - "DLSS is window-only. DLSS settings will be ignored." - ) - else: - dlss = world_config.dlss_config - dlss.dlss_enabled = True - dlss.rayreconstruction_enabled = dlss_cfg.rayreconstruction_enabled - dlss.upscale_enabled = dlss_cfg.upscale_enabled - dlss.dlss_quality = dlss_cfg.dlss_quality - dlss.render_width = dlss_cfg.render_width - dlss.render_height = dlss_cfg.render_height - dlss.target_width = dlss_cfg.target_width - dlss.target_height = dlss_cfg.target_height - dlss.exposure_compensation = dlss_cfg.exposure_compensation - - # When DLSS upscaling is enabled and target dimensions are - # specified, align the window size with the target resolution. - # Rule: win_config.width/height == target_width/height. - if ( - dlss_cfg.upscale_enabled - and dlss_cfg.target_width > 0 - and dlss_cfg.target_height > 0 - ): - win_config.width = dlss_cfg.target_width - win_config.height = dlss_cfg.target_height + dlss_cfg = sim_config.render_cfg.dlss + if dlss_cfg.dlss_enabled: + if sim_config.render_cfg.renderer != "rt": + logger.log_warning( + f"DLSS is enabled but renderer is '{sim_config.render_cfg.renderer}', " + f"not 'rt' (OfflineRT). DLSS only works with the OfflineRT renderer. " + f"DLSS settings will be ignored." + ) + elif sim_config.headless: + logger.log_warning( + "DLSS is enabled but the simulation is running in headless mode. " + "DLSS is window-only. DLSS settings will be ignored." + ) + else: + dlss = world_config.dlss_config + dlss.dlss_enabled = True + dlss.rayreconstruction_enabled = dlss_cfg.rayreconstruction_enabled + dlss.upscale_enabled = dlss_cfg.upscale_enabled + dlss.dlss_quality = dlss_cfg.dlss_quality + dlss.render_width = dlss_cfg.render_width + dlss.render_height = dlss_cfg.render_height + dlss.target_width = dlss_cfg.target_width + dlss.target_height = dlss_cfg.target_height + dlss.exposure_compensation = dlss_cfg.exposure_compensation + + # When DLSS upscaling is enabled and target dimensions are + # specified, align the window size with the target resolution. + # Rule: win_config.width/height == target_width/height. + if ( + dlss_cfg.upscale_enabled + and dlss_cfg.target_width > 0 + and dlss_cfg.target_height > 0 + ): + win_config.width = dlss_cfg.target_width + win_config.height = dlss_cfg.target_height if type(sim_config.sim_device) is str: self.device = torch.device(sim_config.sim_device) From 248b91636859cc641d814dc98365f6e5057290bc Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 7 Jul 2026 15:56:17 +0800 Subject: [PATCH 14/18] wip --- embodichain/lab/sim/cfg.py | 25 ++++++++++-------------- embodichain/lab/sim/sim_manager.py | 24 ++++++++++------------- embodichain/lab/sim/utility/sim_utils.py | 2 +- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/embodichain/lab/sim/cfg.py b/embodichain/lab/sim/cfg.py index bf200369..ec779c49 100644 --- a/embodichain/lab/sim/cfg.py +++ b/embodichain/lab/sim/cfg.py @@ -65,27 +65,23 @@ class DLSSCfg: - **Super Resolution (SR)**: Upscales the RR output from render resolution to target/display resolution. + When :attr:`dlss_enabled` is ``True``, both Ray Reconstruction and Super + Resolution are enabled together. The upscaling ratio is controlled through + :attr:`dlss_quality` (for example, ``5`` = DLAA / 1.0×, ``3`` = Quality). + .. attention:: DLSS is only available with the ``"rt"`` (OfflineRT) renderer in windowed mode. Offscreen cameras always use the OptiX denoiser regardless of DLSS settings. The window resolution (``SimulationManagerCfg.width/height``) - should match ``target_width/target_height`` when upscaling is enabled. + should match ``target_width/target_height`` when the target resolution is + explicitly set. Reference: ``developer_docs/dlss/README_DLSS.md`` in the dexsim repository. """ - dlss_enabled: bool = False + dlss_enabled: bool = True """Master DLSS enable toggle. Off → standard OptiX denoiser / TAA path.""" - rayreconstruction_enabled: bool = True - """Enable DLSS Ray Reconstruction (AI denoiser). Replaces the OptiX denoiser - for the window camera only.""" - - upscale_enabled: bool = False - """Enable DLSS Super Resolution (AI upscaler). When enabled, the path tracer - renders at ``render_width/height`` and DLSS-SR upscales to - ``target_width/height``.""" - dlss_quality: int = 5 """DLSS quality preset. ``-1`` = auto (ratio-based), ``0`` = UltraPerformance (~3.0×), ``1`` = Performance (~2.25×), ``2`` = Balanced (~1.75×, default), @@ -99,11 +95,11 @@ class DLSSCfg: render_height: int = 0 """Internal render resolution height. ``0`` = use window height.""" - target_width: int = 0 + target_width: int = 1920 """Target/display resolution width. ``0`` = use window width. DLSS-SR upscales to this resolution. Should match the window size.""" - target_height: int = 0 + target_height: int = 1080 """Target/display resolution height. ``0`` = use window height.""" exposure_compensation: float = 1.0 @@ -133,8 +129,7 @@ class RenderCfg: dlss: DLSSCfg = field(default_factory=DLSSCfg) """DLSS 3.5 configuration for AI-powered denoising and upscaling. - Only effective when ``renderer="rt"`` (OfflineRT), windowed mode, and - ``dlss_enabled=True``. Defaults to disabled.""" + Only effective when ``renderer="rt"`` (OfflineRT) and in windowed mode.""" def to_dexsim_flags(self): if self.renderer == "hybrid": diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index e5b4a0b6..20ee1fdf 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -486,16 +486,16 @@ def _convert_sim_config( f"not 'rt' (OfflineRT). DLSS only works with the OfflineRT renderer. " f"DLSS settings will be ignored." ) - elif sim_config.headless: - logger.log_warning( - "DLSS is enabled but the simulation is running in headless mode. " - "DLSS is window-only. DLSS settings will be ignored." - ) + # elif sim_config.headless: + # logger.log_warning( + # "DLSS is enabled but the simulation is running in headless mode. " + # "DLSS is window-only. DLSS settings will be ignored." + # ) else: dlss = world_config.dlss_config dlss.dlss_enabled = True - dlss.rayreconstruction_enabled = dlss_cfg.rayreconstruction_enabled - dlss.upscale_enabled = dlss_cfg.upscale_enabled + dlss.rayreconstruction_enabled = True + dlss.upscale_enabled = True dlss.dlss_quality = dlss_cfg.dlss_quality dlss.render_width = dlss_cfg.render_width dlss.render_height = dlss_cfg.render_height @@ -503,14 +503,10 @@ def _convert_sim_config( dlss.target_height = dlss_cfg.target_height dlss.exposure_compensation = dlss_cfg.exposure_compensation - # When DLSS upscaling is enabled and target dimensions are - # specified, align the window size with the target resolution. + # When target dimensions are specified, align the window size with + # the target resolution. # Rule: win_config.width/height == target_width/height. - if ( - dlss_cfg.upscale_enabled - and dlss_cfg.target_width > 0 - and dlss_cfg.target_height > 0 - ): + if dlss_cfg.target_width > 0 and dlss_cfg.target_height > 0: win_config.width = dlss_cfg.target_width win_config.height = dlss_cfg.target_height diff --git a/embodichain/lab/sim/utility/sim_utils.py b/embodichain/lab/sim/utility/sim_utils.py index 993e4df9..7cefbab4 100644 --- a/embodichain/lab/sim/utility/sim_utils.py +++ b/embodichain/lab/sim/utility/sim_utils.py @@ -308,7 +308,7 @@ def load_mesh_objects_from_cfg( for i, env in enumerate(env_list): if max_convex_hull_num > 1: - obj = env.load_actor_with_coacd( + obj = env.load_actor_with_acd( fpath, duplicate=True, attach_scene=True, From 0cf49ff1da2e89af4641f1ff7fde63134ed1ac78 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 7 Jul 2026 19:34:06 +0800 Subject: [PATCH 15/18] wip --- docs/source/overview/sim/sim_manager.md | 73 ++++++++++++++++++++++++ embodichain/lab/sim/cfg.py | 74 +++++++++++++++++++++++-- embodichain/lab/sim/sim_manager.py | 33 +++++------ 3 files changed, 158 insertions(+), 22 deletions(-) diff --git a/docs/source/overview/sim/sim_manager.md b/docs/source/overview/sim/sim_manager.md index 675f8a06..9a4a50c3 100644 --- a/docs/source/overview/sim/sim_manager.md +++ b/docs/source/overview/sim/sim_manager.md @@ -103,6 +103,79 @@ sim_config = SimulationManagerCfg( ) ``` +### DLSS 3.5 (OfflineRT Only) + +When `render_cfg.renderer="rt"` (OfflineRT) and the simulation is running with a visible window, you can enable NVIDIA DLSS 3.5 for AI-powered denoising and upscaling through `render_cfg.dlss`. + +DLSS 3.5 combines two features: + +- **Ray Reconstruction (RR)** — replaces the OptiX denoiser for the window camera. +- **Super Resolution (SR)** — upscales the RR output from render resolution to the target/display resolution. + +Both features are enabled together when `dlss_enabled=True`. The exact upscale ratio can be controlled either with the `dlss_quality` preset or with the `upsample_ratio` convenience parameter. + +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `dlss_enabled` | `bool` | `True` | Master DLSS enable toggle. | +| `dlss_quality` | `int` | `-1` | DLSS quality preset. `-1`=auto, `0`=UltraPerformance (~3.0×), `1`=Performance (~2.25×), `2`=Balanced (~1.75×), `3`=Quality (~1.5×), `4`=UltraQuality (~1.3×), `5`=DLAA (1.0×). | +| `upsample_ratio` | `float` | `1.0` | Convenience ratio `target_width / render_width` (and height). When `render_width`/`render_height` are `0`, the render resolution is computed as `target / upsample_ratio`. Defaults to `1.0` (1:1 / DLAA mode). Must be `>= 1.0`. | +| `render_width` | `int` | `0` | Internal render resolution width. `0` means use the effective target width. | +| `render_height` | `int` | `0` | Internal render resolution height. `0` means use the effective target height. | +| `target_width` | `int` | `0` | Target/display resolution width. `0` means use the window width. | +| `target_height` | `int` | `0` | Target/display resolution height. `0` means use the window height. | +| `exposure_compensation` | `float` | `1.0` | Multiplier on the DLSS-RR pre-exposure scalar. `>1` brightens the HDR input to RR, which helps reject ghosting in dark areas; `<1` darkens. Typical range `0.5–8.0`. | + +#### Resolution selection rules + +- The **target** resolution defaults to the window size unless `target_width`/`target_height` are set explicitly. +- The **render** resolution defaults to the target resolution (1:1 / DLAA mode) unless overridden. +- If `upsample_ratio > 1.0` and `render_width`/`render_height` are `0`, the render resolution is computed as `target / upsample_ratio`. At `1.0`, render defaults to the target resolution (1:1 / DLAA mode). +- Explicit `render_width`/`render_height` or `target_width`/`target_height` take precedence over computed values. +- The window is always resized to match the effective target resolution when DLSS is active. + +#### Examples + +Use the `upsample_ratio` shortcut to render at half resolution and upscale to the window size: + +```python +from embodichain.lab.sim import SimulationManagerCfg +from embodichain.lab.sim.cfg import DLSSCfg, RenderCfg + +sim_config = SimulationManagerCfg( + width=1920, + height=1080, + render_cfg=RenderCfg( + renderer="rt", + dlss=DLSSCfg( + dlss_enabled=True, + upsample_ratio=2.0, # render at 960x540, upscale to 1920x1080 + ), + ), +) +``` + +Set explicit render and target resolutions: + +```python +from embodichain.lab.sim import SimulationManagerCfg +from embodichain.lab.sim.cfg import DLSSCfg, RenderCfg + +sim_config = SimulationManagerCfg( + render_cfg=RenderCfg( + renderer="rt", + dlss=DLSSCfg( + dlss_enabled=True, + render_width=1280, + render_height=720, + target_width=1920, + target_height=1080, + ), + ), +) +``` + +> **Note:** Offscreen cameras always use the OptiX denoiser regardless of DLSS settings. DLSS only affects the interactive viewer window when the OfflineRT renderer is used. + ## Initialization diff --git a/embodichain/lab/sim/cfg.py b/embodichain/lab/sim/cfg.py index ec779c49..cb6a10e6 100644 --- a/embodichain/lab/sim/cfg.py +++ b/embodichain/lab/sim/cfg.py @@ -18,6 +18,8 @@ import enum import json import os + +import dexsim import numpy as np import torch @@ -67,7 +69,7 @@ class DLSSCfg: When :attr:`dlss_enabled` is ``True``, both Ray Reconstruction and Super Resolution are enabled together. The upscaling ratio is controlled through - :attr:`dlss_quality` (for example, ``5`` = DLAA / 1.0×, ``3`` = Quality). + :attr:`dlss_quality` (for example, ``5`` = DLAA / 1.0x, ``3`` = Quality). .. attention:: DLSS is only available with the ``"rt"`` (OfflineRT) renderer in windowed @@ -82,12 +84,20 @@ class DLSSCfg: dlss_enabled: bool = True """Master DLSS enable toggle. Off → standard OptiX denoiser / TAA path.""" - dlss_quality: int = 5 + dlss_quality: int = -1 """DLSS quality preset. ``-1`` = auto (ratio-based), ``0`` = UltraPerformance - (~3.0×), ``1`` = Performance (~2.25×), ``2`` = Balanced (~1.75×, default), - ``3`` = Quality (~1.5×), ``4`` = UltraQuality (~1.3×), ``5`` = DLAA (1.0×, + (~3.0x), ``1`` = Performance (~2.25x), ``2`` = Balanced (~1.75x, default), + ``3`` = Quality (~1.5x), ``4`` = UltraQuality (~1.3x), ``5`` = DLAA (1.0x, no upscale — render must equal target).""" + upsample_ratio: float = 1.0 + """Convenience ratio ``target_width / render_width`` (and height). When + :attr:`render_width`/:attr:`render_height` are ``0``, the render resolution + is computed from the effective target resolution as + ``target / upsample_ratio``. Defaults to ``1.0`` (1:1 / DLAA mode). Must be + ``>= 1.0``. Explicit render or target dimensions take precedence over this + value.""" + render_width: int = 0 """Internal render resolution width. ``0`` = use window width. The path tracer and DLSS-RR operate at this resolution.""" @@ -95,11 +105,11 @@ class DLSSCfg: render_height: int = 0 """Internal render resolution height. ``0`` = use window height.""" - target_width: int = 1920 + target_width: int = 0 """Target/display resolution width. ``0`` = use window width. DLSS-SR upscales to this resolution. Should match the window size.""" - target_height: int = 1080 + target_height: int = 0 """Target/display resolution height. ``0`` = use window height.""" exposure_compensation: float = 1.0 @@ -108,6 +118,58 @@ class DLSSCfg: ``<1`` darkens. ``1.0`` = neutral. Typical range 0.5–8.0; tune per scene. No rebuild needed.""" + def to_dexsim_cfg(self, window_width: int, window_height: int) -> dexsim.DLSSConfig: + """Resolve effective DLSS settings and return a dexsim config object. + + The effective target resolution defaults to the window size unless + :attr:`target_width`/:attr:`target_height` are set explicitly. The render + resolution defaults to the target unless :attr:`render_width`/ + :attr:`render_height` are explicit or :attr:`upsample_ratio` is provided. + + Args: + window_width: Window width in pixels. + window_height: Window height in pixels. + + Returns: + Populated :class:`dexsim.DLSSConfig` instance ready to assign to + ``world_config.dlss_config``. + """ + effective_target_width = ( + self.target_width if self.target_width > 0 else window_width + ) + effective_target_height = ( + self.target_height if self.target_height > 0 else window_height + ) + + if ( + self.upsample_ratio >= 1.0 + and self.render_width == 0 + and self.render_height == 0 + ): + effective_render_width = int(effective_target_width / self.upsample_ratio) + effective_render_height = int(effective_target_height / self.upsample_ratio) + else: + effective_render_width = ( + self.render_width if self.render_width > 0 else effective_target_width + ) + effective_render_height = ( + self.render_height + if self.render_height > 0 + else effective_target_height + ) + + dlss = dexsim.DLSSConfig() + dlss.dlss_enabled = self.dlss_enabled + dlss.rayreconstruction_enabled = True + dlss.upscale_enabled = True + dlss.dlss_quality = self.dlss_quality + dlss.render_width = effective_render_width + dlss.render_height = effective_render_height + dlss.target_width = effective_target_width + dlss.target_height = effective_target_height + dlss.exposure_compensation = self.exposure_compensation + return dlss + @configclass class RenderCfg: diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 20ee1fdf..9c0b7312 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -492,23 +492,24 @@ def _convert_sim_config( # "DLSS is window-only. DLSS settings will be ignored." # ) else: + world_config.dlss_config = dlss_cfg.to_dexsim_cfg( + window_width=sim_config.width, + window_height=sim_config.height, + ) dlss = world_config.dlss_config - dlss.dlss_enabled = True - dlss.rayreconstruction_enabled = True - dlss.upscale_enabled = True - dlss.dlss_quality = dlss_cfg.dlss_quality - dlss.render_width = dlss_cfg.render_width - dlss.render_height = dlss_cfg.render_height - dlss.target_width = dlss_cfg.target_width - dlss.target_height = dlss_cfg.target_height - dlss.exposure_compensation = dlss_cfg.exposure_compensation - - # When target dimensions are specified, align the window size with - # the target resolution. - # Rule: win_config.width/height == target_width/height. - if dlss_cfg.target_width > 0 and dlss_cfg.target_height > 0: - win_config.width = dlss_cfg.target_width - win_config.height = dlss_cfg.target_height + + # Align the window size with the effective target resolution. + # Rule: win_config.width/height == target_width/target_height. + win_config.width = dlss.target_width + win_config.height = dlss.target_height + + logger.log_info( + f"DLSS enabled with renderer='{sim_config.render_cfg.renderer}': " + f"render={dlss.render_width}x{dlss.render_height}, " + f"target={dlss.target_width}x{dlss.target_height}, " + f"upsample_ratio={dlss_cfg.upsample_ratio}, " + f"quality={dlss.dlss_quality}." + ) if type(sim_config.sim_device) is str: self.device = torch.device(sim_config.sim_device) From 4f4ace813e812f2dc7a47e2e265dfbc074842e34 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 7 Jul 2026 21:29:02 +0800 Subject: [PATCH 16/18] wip --- embodichain/lab/sim/sim_manager.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 9c0b7312..78e84be3 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -486,17 +486,13 @@ def _convert_sim_config( f"not 'rt' (OfflineRT). DLSS only works with the OfflineRT renderer. " f"DLSS settings will be ignored." ) - # elif sim_config.headless: - # logger.log_warning( - # "DLSS is enabled but the simulation is running in headless mode. " - # "DLSS is window-only. DLSS settings will be ignored." - # ) else: world_config.dlss_config = dlss_cfg.to_dexsim_cfg( window_width=sim_config.width, window_height=sim_config.height, ) dlss = world_config.dlss_config + world_config.raytrace_config.window_taa_enabled = False # Align the window size with the effective target resolution. # Rule: win_config.width/height == target_width/target_height. From 3a4a5965c0b8b625e7ad075839411ec2616c9ed4 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Wed, 8 Jul 2026 14:58:20 +0800 Subject: [PATCH 17/18] wip --- .../plans/2026-07-05-light-types-plan.md | 956 ------------------ .../specs/2026-07-05-light-types-design.md | 217 ---- embodichain/lab/sim/cfg.py | 6 +- embodichain/lab/sim/objects/light.py | 8 +- embodichain/lab/sim/sim_manager.py | 9 +- embodichain/lab/sim/utility/sim_utils.py | 2 +- tests/sim/objects/test_light.py | 10 +- 7 files changed, 21 insertions(+), 1187 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-05-light-types-plan.md delete mode 100644 docs/superpowers/specs/2026-07-05-light-types-design.md diff --git a/docs/superpowers/plans/2026-07-05-light-types-plan.md b/docs/superpowers/plans/2026-07-05-light-types-plan.md deleted file mode 100644 index 68956870..00000000 --- a/docs/superpowers/plans/2026-07-05-light-types-plan.md +++ /dev/null @@ -1,956 +0,0 @@ -# Light Type Expansion — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Support all 6 dexsim light types (point, sun, direction, spot, rect, mesh) in EmbodiChain's core pipeline. - -**Architecture:** Expand the flat `LightCfg` with optional type-specific fields, add typed setters to the `Light` BatchEntity class with runtime validation, and map all 6 types in `SimulationManager.add_light`. Backward compatible — existing point-light code works unchanged. - -**Tech Stack:** Python 3.10+, PyTorch, dexsim Python bindings, `@configclass` decorator, `BatchEntity` pattern - -## Global Constraints - -- `intensity` default: `30.0` (was `50.0`) -- `radius` default: `10.0` (was `1e2`) -- Backend method names: `set_rect_wh` (not `set_rect_size`), `set_spot_angle(inner, outer)`, `set_direction(x, y, z)`, `set_mesh(MeshObject)`, `set_shadow(bool)` -- `set_angular_radius`, `set_halo_size`, `set_halo_falloff` are NOT exposed in Python bindings — config fields exist but setters are deferred -- `set_mesh` takes a `dexsim.models.MeshObject`, not a string — mesh lights are asset-based, not tensor-batched -- All new fields have defaults; backward compatibility is mandatory -- Runtime validation: warn on incompatible setter calls, error only on unknown light type - ---- - -### Task 1: Expand `LightCfg` fields - -**Files:** -- Modify: `embodichain/lab/sim/cfg.py:794-810` - -**Interfaces:** -- Produces: `LightCfg` with expanded `light_type` literal and new optional fields - -- [ ] **Step 1: Replace the `LightCfg` class definition** - -Replace lines 794-810 in `embodichain/lab/sim/cfg.py`: - -```python -@configclass -class LightCfg(ObjectBaseCfg): - """Configuration for a light asset in the simulation. - - Supports six light types matching the dexsim rendering backend: - - - ``"point"``: Omnidirectional point light with position and falloff radius. - - ``"sun"``: Directional sun light with position, direction, and angular radius (sun-specific setters like halo are not yet wired through Python bindings). - - ``"direction"``: Pure directional light at infinite distance (direction only, no position). - - ``"spot"``: Spotlight with position, direction, and inner/outer cone angles. - - ``"rect"``: Rectangular area light with position, direction, width, and height. - - ``"mesh"``: Mesh-based emissive light (requires a MeshObject via :meth:`set_mesh`; not tensor-batched). - - .. attention:: - The ``angular_radius``, ``halo_size``, and ``halo_falloff`` fields are - reserved for future use. The dexsim Python bindings do not yet expose - setters for these sun-specific properties. - """ - - light_type: Literal["point", "sun", "direction", "spot", "rect", "mesh"] = "point" - """Light type. Supported: ``"point"``, ``"sun"``, ``"direction"``, ``"spot"``, ``"rect"``, ``"mesh"``.""" - - # ------------------------------------------------------------------ - # Universal properties (apply to all light types) - # ------------------------------------------------------------------ - - color: tuple[float, float, float] = (1.0, 1.0, 1.0) - """RGB color of the light source. Defaults to white ``(1.0, 1.0, 1.0)``.""" - - intensity: float = 30.0 - """Intensity of the light source in watts/m^2. Defaults to ``30.0``.""" - - enable_shadow: bool = True - """Whether the light casts shadows. Defaults to ``True``.""" - - # ------------------------------------------------------------------ - # Point light - # ------------------------------------------------------------------ - - radius: float = 10.0 - """Falloff radius for point lights. Only used when ``light_type="point"``. Defaults to ``10.0``.""" - - # ------------------------------------------------------------------ - # Directional properties (sun, direction, spot, rect, mesh) - # ------------------------------------------------------------------ - - direction: tuple[float, float, float] = (0.0, 0.0, -1.0) - """Direction vector for directional, spot, rect, and mesh lights. - Defaults to ``(0.0, 0.0, -1.0)`` (pointing down along -Z).""" - - # ------------------------------------------------------------------ - # Sun light (reserved — Python bindings not yet available) - # ------------------------------------------------------------------ - - angular_radius: float = 0.5 - """Angular radius of the sun disc in degrees. Reserved for future use.""" - - halo_size: float = 10.0 - """Halo size for sun light. Reserved for future use.""" - - halo_falloff: float = 3.0 - """Halo falloff for sun light. Reserved for future use.""" - - # ------------------------------------------------------------------ - # Spot light - # ------------------------------------------------------------------ - - spot_angle_inner: float = 30.0 - """Inner cone angle of the spotlight in degrees. Only used when ``light_type="spot"``. - Defaults to ``30.0``.""" - - spot_angle_outer: float = 45.0 - """Outer cone angle of the spotlight in degrees. Only used when ``light_type="spot"``. - Defaults to ``45.0``.""" - - # ------------------------------------------------------------------ - # Rect light - # ------------------------------------------------------------------ - - rect_width: float = 1.0 - """Width of the rectangular area light. Only used when ``light_type="rect"``. - Defaults to ``1.0``.""" - - rect_height: float = 1.0 - """Height of the rectangular area light. Only used when ``light_type="rect"``. - Defaults to ``1.0``.""" - - # ------------------------------------------------------------------ - # Mesh light - # ------------------------------------------------------------------ - - mesh_path: str = "" - """Asset path for mesh-based emissive lights. Only used when ``light_type="mesh"``. - The actual mesh assignment is done via :meth:`Light.set_mesh` which accepts a - :class:`dexsim.models.MeshObject`. This field stores the path for reference.""" -``` - -- [ ] **Step 2: Verify the module imports correctly** - -```bash -cd /home/dex/workspace/sources/EmbodiChain && python -c "from embodichain.lab.sim.cfg import LightCfg; print(LightCfg()); print(LightCfg(light_type='spot'))" -``` - -Expected output: Two LightCfg instances printed without errors. - -- [ ] **Step 3: Commit** - -```bash -git add embodichain/lab/sim/cfg.py -git commit -m "feat: expand LightCfg to support 6 light types - -Add optional fields for sun, direction, spot, rect, and mesh light types. -Change default intensity to 30.0 and default radius to 10.0. -All new fields have defaults — existing point-light code works unchanged. - -Co-Authored-By: Claude " -``` - ---- - -### Task 2: Add new setters to `Light` class and update `reset()` - -**Files:** -- Modify: `embodichain/lab/sim/objects/light.py` - -**Interfaces:** -- Consumes: `LightCfg` with expanded fields (from Task 1) -- Produces: New methods on `Light` — `set_direction`, `set_spot_angle`, `set_rect_wh`, `set_mesh`, `enable_shadow`; updated `reset()` - -- [ ] **Step 1: Add new setter methods to the `Light` class** - -Insert these methods after the existing `set_falloff` method (after line 88 in the current file) and before `set_local_pose`: - -```python - def set_direction( - self, directions: torch.Tensor, env_ids: Sequence[int] | None = None - ) -> None: - """Set direction for directional-type lights. - - Only applies to ``sun``, ``direction``, ``spot``, ``rect``, and ``mesh`` - light types. Logs a warning and no-ops for other types. - - Args: - directions (torch.Tensor): Tensor of shape (3,) or (M, 3), representing - (x, y, z) direction vectors. - env_ids (Sequence[int] | None): Indices of instances to set. If None: - - For shape (3,), applies to all instances. - - For shape (M, 3), M must equal num_instances, applies per-instance. - """ - if self.cfg.light_type not in ("sun", "direction", "spot", "rect", "mesh"): - logger.warning( - f"set_direction not applicable to light type " - f"'{self.cfg.light_type}', ignoring." - ) - return - self._apply_vector3(directions, env_ids, "set_direction") - - def set_spot_angle( - self, - inner_angles: torch.Tensor, - outer_angles: torch.Tensor, - env_ids: Sequence[int] | None = None, - ) -> None: - """Set inner and outer cone angles for spot lights. - - Only applies to ``spot`` light type. Logs a warning and no-ops for other types. - - Args: - inner_angles (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) - representing inner cone angle in degrees. - outer_angles (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) - representing outer cone angle in degrees. - env_ids (Sequence[int] | None): Indices of instances to set. - """ - if self.cfg.light_type != "spot": - logger.warning( - f"set_spot_angle not applicable to light type " - f"'{self.cfg.light_type}', ignoring." - ) - return - - if not torch.is_tensor(inner_angles) or not torch.is_tensor(outer_angles): - logger.log_error("set_spot_angle requires torch.Tensor arguments") - return - - inner_cpu = inner_angles.detach().cpu() - outer_cpu = outer_angles.detach().cpu() - - if env_ids is None: - all_ids = list(range(self.num_instances)) - else: - all_ids = list(env_ids) - - # Both scalar (0-dim): broadcast to all_ids - if inner_cpu.ndim == 0 and outer_cpu.ndim == 0: - iv = float(inner_cpu.item()) - ov = float(outer_cpu.item()) - for i in all_ids: - self._entities[i].set_spot_angle(iv, ov) - return - - # Both 1D with matching length - if inner_cpu.ndim == 1 and outer_cpu.ndim == 1: - ilen, olen = inner_cpu.shape[0], outer_cpu.shape[0] - inner_arr = inner_cpu.numpy() - outer_arr = outer_cpu.numpy() - - if ilen == olen == self.num_instances and env_ids is None: - for i in range(self.num_instances): - self._entities[i].set_spot_angle( - float(inner_arr[i]), float(outer_arr[i]) - ) - return - - if env_ids is not None and ilen == olen == len(all_ids): - for idx, i in enumerate(all_ids): - self._entities[i].set_spot_angle( - float(inner_arr[idx]), float(outer_arr[idx]) - ) - return - - logger.log_error( - f"set_spot_angle: invalid tensor shapes " - f"inner={tuple(inner_cpu.shape)}, outer={tuple(outer_cpu.shape)}" - ) - - def set_rect_wh( - self, - widths: torch.Tensor, - heights: torch.Tensor, - env_ids: Sequence[int] | None = None, - ) -> None: - """Set width and height for rectangular area lights. - - Only applies to ``rect`` light type. Logs a warning and no-ops for other types. - - Args: - widths (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) - representing width of the rectangular light. - heights (torch.Tensor): Tensor of shape () (0-dim), (1,), or (M,) - representing height of the rectangular light. - env_ids (Sequence[int] | None): Indices of instances to set. - """ - if self.cfg.light_type != "rect": - logger.warning( - f"set_rect_wh not applicable to light type " - f"'{self.cfg.light_type}', ignoring." - ) - return - - if not torch.is_tensor(widths) or not torch.is_tensor(heights): - logger.log_error("set_rect_wh requires torch.Tensor arguments") - return - - w_cpu = widths.detach().cpu() - h_cpu = heights.detach().cpu() - - if env_ids is None: - all_ids = list(range(self.num_instances)) - else: - all_ids = list(env_ids) - - # Both scalar (0-dim): broadcast to all_ids - if w_cpu.ndim == 0 and h_cpu.ndim == 0: - wv = float(w_cpu.item()) - hv = float(h_cpu.item()) - for i in all_ids: - self._entities[i].set_rect_wh(wv, hv) - return - - # Both 1D with matching length - if w_cpu.ndim == 1 and h_cpu.ndim == 1: - wlen, hlen = w_cpu.shape[0], h_cpu.shape[0] - w_arr = w_cpu.numpy() - h_arr = h_cpu.numpy() - - if wlen == hlen == self.num_instances and env_ids is None: - for i in range(self.num_instances): - self._entities[i].set_rect_wh( - float(w_arr[i]), float(h_arr[i]) - ) - return - - if env_ids is not None and wlen == hlen == len(all_ids): - for idx, i in enumerate(all_ids): - self._entities[i].set_rect_wh( - float(w_arr[idx]), float(h_arr[idx]) - ) - return - - logger.log_error( - f"set_rect_wh: invalid tensor shapes " - f"width={tuple(w_cpu.shape)}, height={tuple(h_cpu.shape)}" - ) - - def set_mesh( - self, - mesh: "MeshObject", - env_ids: Sequence[int] | None = None, - ) -> None: - """Set the mesh for mesh-type lights. - - Only applies to ``mesh`` light type. Logs a warning and no-ops for other types. - This is NOT tensor-batched — the same MeshObject is assigned to all targeted - instances. - - Args: - mesh (MeshObject): The mesh object to assign to the light. - env_ids (Sequence[int] | None): Indices of instances to set. If None, - applies to all instances. - """ - if self.cfg.light_type != "mesh": - logger.warning( - f"set_mesh not applicable to light type " - f"'{self.cfg.light_type}', ignoring." - ) - return - - if env_ids is None: - target_ids = list(range(self.num_instances)) - else: - target_ids = list(env_ids) - - for i in target_ids: - try: - self._entities[i].set_mesh(mesh) - except Exception as e: - logger.log_error(f"set_mesh: error for instance {i}: {e}") - - def enable_shadow( - self, - flags: torch.Tensor, - env_ids: Sequence[int] | None = None, - ) -> None: - """Enable or disable shadow casting. - - Applies to all light types. - - Args: - flags (torch.Tensor): Boolean tensor of shape () (0-dim), (1,), or (M,). - Non-zero values enable shadows; zero disables. - env_ids (Sequence[int] | None): Indices of instances to set. - """ - if not torch.is_tensor(flags): - logger.log_error(f"enable_shadow requires a torch.Tensor, got {type(flags)}") - return - - cpu = flags.detach().cpu() - if env_ids is None: - all_ids = list(range(self.num_instances)) - else: - all_ids = list(env_ids) - - # Scalar: broadcast - if cpu.ndim == 0: - val = bool(cpu.item() != 0) - for i in all_ids: - self._entities[i].set_shadow(val) - return - - # 1D tensor - if cpu.ndim == 1: - length = cpu.shape[0] - arr = cpu.numpy() - if length == self.num_instances and env_ids is None: - for i in range(self.num_instances): - self._entities[i].set_shadow(bool(arr[i] != 0)) - return - if env_ids is not None and length == len(all_ids): - for idx, i in enumerate(all_ids): - self._entities[i].set_shadow(bool(arr[idx] != 0)) - return - if length == 1: - val = bool(arr[0] != 0) - for i in all_ids: - self._entities[i].set_shadow(val) - return - - logger.log_error( - f"enable_shadow: tensor shape {tuple(cpu.shape)} is invalid for broadcasting" - ) -``` - -- [ ] **Step 2: Add the MeshObject import at the top of the file** - -Add after line 21 (`from embodichain.lab.sim.cfg import LightCfg`): - -```python -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from dexsim.models import MeshObject -``` - -- [ ] **Step 3: Update the `reset` method** - -Replace the existing `reset` method (lines 294-299): - -```python - def reset(self, env_ids: Sequence[int] | None = None) -> None: - """Reset the light to its initial configuration state. - - Applies only the properties relevant to ``self.cfg.light_type``. - - Args: - env_ids (Sequence[int] | None): The environment IDs to reset. - If None, resets all environments. - """ - self.cfg: LightCfg - light_type = self.cfg.light_type - - # Universal properties - self.set_color(torch.as_tensor(self.cfg.color), env_ids=env_ids) - self.set_intensity(torch.as_tensor(self.cfg.intensity), env_ids=env_ids) - self.enable_shadow( - torch.as_tensor(float(self.cfg.enable_shadow)), env_ids=env_ids - ) - - # Position (all types except direction) - if light_type != "direction": - self.set_local_pose(torch.as_tensor(self.cfg.init_pos), env_ids=env_ids) - - # Point light: falloff - if light_type == "point": - self.set_falloff(torch.as_tensor(self.cfg.radius), env_ids=env_ids) - - # Directional types: direction vector - if light_type in ("sun", "direction", "spot", "rect", "mesh"): - self.set_direction(torch.as_tensor(self.cfg.direction), env_ids=env_ids) - - # Spot light: cone angles - if light_type == "spot": - self.set_spot_angle( - torch.as_tensor(self.cfg.spot_angle_inner), - torch.as_tensor(self.cfg.spot_angle_outer), - env_ids=env_ids, - ) - - # Rect light: dimensions - if light_type == "rect": - self.set_rect_wh( - torch.as_tensor(self.cfg.rect_width), - torch.as_tensor(self.cfg.rect_height), - env_ids=env_ids, - ) - - # Mesh light: mesh_path is stored in cfg but actual mesh assignment - # is done via set_mesh() which requires a MeshObject. - # Sun-specific angular_radius/halo are reserved for future backend support. -``` - -- [ ] **Step 4: Update `set_local_pose` to handle direction-type lights** - -Add a guard at the beginning of `set_local_pose` (after the docstring, before line 107): - -```python - if self.cfg.light_type == "direction": - logger.warning( - "set_local_pose not applicable to 'direction' light type " - "(infinite distance, direction only). Use set_direction() instead." - ) - return -``` - -- [ ] **Step 5: Verify the module imports correctly** - -```bash -cd /home/dex/workspace/sources/EmbodiChain && python -c "from embodichain.lab.sim.objects.light import Light; print('OK')" -``` - -- [ ] **Step 6: Commit** - -```bash -git add embodichain/lab/sim/objects/light.py -git commit -m "feat: add type-specific setters to Light class - -Add set_direction, set_spot_angle, set_rect_wh, set_mesh, and -enable_shadow methods with runtime type validation. Update reset() -to apply only properties relevant to the light type. Guard -set_local_pose for direction-type lights (no position). - -Co-Authored-By: Claude " -``` - ---- - -### Task 3: Update `SimulationManager.add_light` type mapping - -**Files:** -- Modify: `embodichain/lab/sim/sim_manager.py:800-837` - -**Interfaces:** -- Consumes: `LightCfg` with expanded `light_type` literal (from Task 1), `Light` class with new setters (from Task 2) -- Produces: `add_light` accepts all 6 light types - -- [ ] **Step 1: Replace the `add_light` method** - -Replace lines 800-837: - -```python - # Light type string → dexsim LightType enum mapping - _LIGHT_TYPE_MAP: dict[str, LightType] = { - "point": LightType.POINT, - "sun": LightType.SUN, - "direction": LightType.DIRECTION, - "spot": LightType.SPOT, - "rect": LightType.RECT, - "mesh": LightType.MESH, - } - - def add_light(self, cfg: LightCfg) -> Light: - """Create a light in the scene. - - Supports six light types: ``"point"``, ``"sun"``, ``"direction"``, - ``"spot"``, ``"rect"``, and ``"mesh"``. See :class:`LightCfg` for - type-specific configuration fields. - - Args: - cfg (LightCfg): Configuration for the light, including type, color, - intensity, and type-specific properties. - - Returns: - Light: The created light instance. - - Raises: - ValueError: If ``cfg.light_type`` is not one of the supported types. - """ - if cfg.uid is None: - uid = "light" - cfg.uid = uid - else: - uid = cfg.uid - - if uid in self._lights: - logger.log_error(f"Light {uid} already exists.") - return None - - light_type_str = cfg.light_type - light_type = self._LIGHT_TYPE_MAP.get(light_type_str) - if light_type is None: - supported = ", ".join(self._LIGHT_TYPE_MAP.keys()) - logger.log_error( - f"Unsupported light type: '{light_type_str}'. " - f"Supported types: {supported}." - ) - return None - - # Validation warnings for type-specific constraints - if light_type_str == "mesh" and not cfg.mesh_path: - logger.warning( - f"Mesh light '{uid}' has no mesh_path set. " - f"Use set_mesh() to assign a MeshObject." - ) - if light_type_str == "rect" and (cfg.rect_width <= 0 or cfg.rect_height <= 0): - logger.warning( - f"Rect light '{uid}' has zero or negative dimensions " - f"(width={cfg.rect_width}, height={cfg.rect_height})." - ) - - env_list = [self._env] if len(self._arenas) == 0 else self._arenas - light_list = [] - for i, env in enumerate(env_list): - light_name = f"{uid}_{i}" - light = env.create_light(light_name, light_type) - light_list.append(light) - - batch_lights = Light(cfg=cfg, entities=light_list) - - self._lights[uid] = batch_lights - - return batch_lights -``` - -Note: The `_LIGHT_TYPE_MAP` is a class-level dict on `SimulationManager`. Place it right before `add_light` (after `get_articulation` at line 798). - -- [ ] **Step 2: Verify the mapping works** - -```bash -cd /home/dex/workspace/sources/EmbodiChain && python -c " -from embodichain.lab.sim.sim_manager import SimulationManager -# Check that the map has all 6 types -mgr = SimulationManager -print(mgr._LIGHT_TYPE_MAP) -" -``` - -- [ ] **Step 3: Commit** - -```bash -git add embodichain/lab/sim/sim_manager.py -git commit -m "feat: support all 6 light types in SimulationManager.add_light - -Add _LIGHT_TYPE_MAP mapping string type names to dexsim LightType enum. -Add validation warnings for mesh (no path) and rect (zero dimensions). -Unknown light types now log the list of supported types. - -Co-Authored-By: Claude " -``` - ---- - -### Task 4: Write tests for new light types - -**Files:** -- Modify: `tests/sim/objects/test_light.py` - -**Interfaces:** -- Consumes: All types and setters from Tasks 1-3 -- Produces: Test coverage for all 6 light types - -- [ ] **Step 1: Add new test class `TestLightTypes` after the existing `TestLight` class** - -Insert after line 161 (after `gc.collect()`): - -```python -class TestLightTypes: - """Tests for all six supported light types.""" - - @pytest.fixture(autouse=True) - def setup(self): - """Create a SimulationManager for each test.""" - config = SimulationManagerCfg(headless=True, sim_device="cpu", num_envs=4) - self.sim = SimulationManager(config) - yield - self.sim.destroy() - import embodichain.lab.sim as om - - om.SimulationManager.flush_cleanup_queue() - self.__dict__.clear() - import gc - - gc.collect() - - # ------------------------------------------------------------------ - # Creation tests - # ------------------------------------------------------------------ - - @pytest.mark.parametrize( - "light_type", - ["point", "sun", "direction", "spot", "rect", "mesh"], - ) - def test_create_each_light_type(self, light_type): - """Each of the 6 light types can be created without error.""" - cfg = LightCfg( - uid=f"test_{light_type}", - light_type=light_type, - init_pos=(0.0, 0.0, 3.0), - ) - light = self.sim.add_light(cfg=cfg) - assert light is not None, f"Failed to create light of type '{light_type}'" - assert light.num_instances == 4 - - def test_unknown_light_type_errors(self): - """Passing an invalid light_type logs an error and returns None.""" - cfg = LightCfg(uid="bad", light_type="invalid") - light = self.sim.add_light(cfg=cfg) - assert light is None, "Unknown light type should return None" - - def test_mesh_light_empty_path_warns(self): - """Mesh light with empty mesh_path warns at creation but succeeds.""" - cfg = LightCfg( - uid="mesh_no_path", - light_type="mesh", - init_pos=(0.0, 0.0, 2.0), - ) - light = self.sim.add_light(cfg=cfg) - assert light is not None, "Mesh light should be created even without path" - - # ------------------------------------------------------------------ - # Setter type-validation tests - # ------------------------------------------------------------------ - - def test_set_direction_on_point_warns(self): - """Calling set_direction on a point light logs a warning and no-ops.""" - cfg = LightCfg(uid="pt", light_type="point", init_pos=(0, 0, 2)) - light = self.sim.add_light(cfg=cfg) - direction = torch.tensor([1.0, 0.0, 0.0]) - # Should not raise; should log a warning - try: - light.set_direction(direction) - except Exception as e: - pytest.fail(f"set_direction on point light should warn, not crash: {e}") - - def test_set_spot_angle_on_point_warns(self): - """Calling set_spot_angle on a non-spot light warns and no-ops.""" - cfg = LightCfg(uid="pt2", light_type="point", init_pos=(0, 0, 2)) - light = self.sim.add_light(cfg=cfg) - inner = torch.tensor(15.0) - outer = torch.tensor(30.0) - try: - light.set_spot_angle(inner, outer) - except Exception as e: - pytest.fail(f"set_spot_angle on point light should warn, not crash: {e}") - - def test_set_rect_wh_on_point_warns(self): - """Calling set_rect_wh on a non-rect light warns and no-ops.""" - cfg = LightCfg(uid="pt3", light_type="point", init_pos=(0, 0, 2)) - light = self.sim.add_light(cfg=cfg) - w = torch.tensor(2.0) - h = torch.tensor(3.0) - try: - light.set_rect_wh(w, h) - except Exception as e: - pytest.fail(f"set_rect_wh on point light should warn, not crash: {e}") - - def test_set_local_pose_on_direction_warns(self): - """Calling set_local_pose on a direction light warns and no-ops.""" - cfg = LightCfg( - uid="dir_light", - light_type="direction", - direction=(0.0, -1.0, 0.0), - ) - light = self.sim.add_light(cfg=cfg) - pose = torch.tensor([1.0, 2.0, 3.0]) - try: - light.set_local_pose(pose) - except Exception as e: - pytest.fail( - f"set_local_pose on direction light should warn, not crash: {e}" - ) - - # ------------------------------------------------------------------ - # Type-specific property tests - # ------------------------------------------------------------------ - - def test_spot_light_has_cone_angles(self): - """Spot light creation applies inner/outer cone angles from cfg.""" - cfg = LightCfg( - uid="spot_test", - light_type="spot", - init_pos=(0.0, 0.0, 3.0), - direction=(0.0, 0.0, -1.0), - spot_angle_inner=20.0, - spot_angle_outer=40.0, - ) - light = self.sim.add_light(cfg=cfg) - assert light is not None - # Should not crash on creation; spot_angle applied during reset() - - def test_rect_light_has_dimensions(self): - """Rect light creation applies width/height from cfg.""" - cfg = LightCfg( - uid="rect_test", - light_type="rect", - init_pos=(0.0, 0.0, 3.0), - direction=(0.0, 0.0, -1.0), - rect_width=2.0, - rect_height=1.5, - ) - light = self.sim.add_light(cfg=cfg) - assert light is not None - - def test_direction_light_no_position_in_reset(self): - """Direction light reset does not set position.""" - cfg = LightCfg( - uid="dir_test", - light_type="direction", - direction=(0.0, -1.0, 0.0), - ) - light = self.sim.add_light(cfg=cfg) - assert light is not None - - # ------------------------------------------------------------------ - # Broadcasting tests - # ------------------------------------------------------------------ - - def test_set_direction_broadcasting(self): - """set_direction with (3,) tensor broadcasts to all instances.""" - cfg = LightCfg( - uid="spot_broadcast", - light_type="spot", - init_pos=(0, 0, 3), - direction=(0.0, 0.0, -1.0), - ) - light = self.sim.add_light(cfg=cfg) - new_dir = torch.tensor([1.0, 0.0, 0.0]) - try: - light.set_direction(new_dir) - except Exception as e: - pytest.fail(f"set_direction broadcast failed: {e}") - - def test_set_direction_with_env_ids(self): - """set_direction with (M, 3) tensor applies per-instance.""" - cfg = LightCfg( - uid="spot_env", - light_type="spot", - init_pos=(0, 0, 3), - direction=(0.0, 0.0, -1.0), - ) - light = self.sim.add_light(cfg=cfg) - env_ids = [0, 2] - dirs = torch.tensor([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) - try: - light.set_direction(dirs, env_ids=env_ids) - except Exception as e: - pytest.fail(f"set_direction per-instance failed: {e}") - - def test_enable_shadow(self): - """enable_shadow sets shadow flag on all instances.""" - cfg = LightCfg(uid="shadow_test", light_type="point", init_pos=(0, 0, 2)) - light = self.sim.add_light(cfg=cfg) - try: - light.enable_shadow(torch.tensor(0.0)) # disable - light.enable_shadow(torch.tensor(1.0)) # enable - except Exception as e: - pytest.fail(f"enable_shadow failed: {e}") - - # ------------------------------------------------------------------ - # from_dict compatibility - # ------------------------------------------------------------------ - - def test_from_dict_new_types(self): - """LightCfg.from_dict works with new type fields.""" - cfg = LightCfg.from_dict( - { - "light_type": "spot", - "color": [1.0, 0.9, 0.8], - "intensity": 100.0, - "init_pos": [0.0, 0.0, 3.0], - "direction": [0.0, 0.0, -1.0], - "spot_angle_inner": 25.0, - "spot_angle_outer": 50.0, - "uid": "from_dict_spot", - } - ) - assert cfg.light_type == "spot" - assert cfg.spot_angle_inner == 25.0 - assert cfg.spot_angle_outer == 50.0 - assert cfg.direction == (0.0, 0.0, -1.0) - - # ------------------------------------------------------------------ - # Backward compatibility - # ------------------------------------------------------------------ - - def test_point_light_backward_compat(self): - """Existing point-light config pattern still works.""" - cfg_dict = { - "light_type": "point", - "color": [0.1, 0.1, 0.1], - "radius": 10.0, - "position": [0.0, 0.0, 2.0], - "uid": "point_compat", - } - light = self.sim.add_light(cfg=LightCfg.from_dict(cfg_dict)) - assert light is not None - assert light.num_instances == 4 - - # set_color should still work - base_color = torch.tensor([0.1, 0.1, 0.1]) - try: - light.set_color(base_color) - except Exception as e: - pytest.fail(f"set_color failed: {e}") - - # set_falloff should still work - try: - light.set_falloff(torch.tensor(100.0)) - except Exception as e: - pytest.fail(f"set_falloff failed: {e}") -``` - -- [ ] **Step 2: Run the new test file** - -```bash -cd /home/dex/workspace/sources/EmbodiChain && python -m pytest tests/sim/objects/test_light.py -v -``` - -Expected: All tests in `TestLight` and `TestLightTypes` pass. - -- [ ] **Step 3: Commit** - -```bash -git add tests/sim/objects/test_light.py -git commit -m "test: add comprehensive tests for all 6 light types - -Cover creation, type validation, broadcasting, from_dict, and -backward compatibility for point lights. - -Co-Authored-By: Claude " -``` - ---- - -### Task 5: Run full pre-commit check and fix any issues - -**Files:** -- Modify: (any files that need formatting or lint fixes) - -- [ ] **Step 1: Format with black** - -```bash -cd /home/dex/workspace/sources/EmbodiChain && black embodichain/lab/sim/cfg.py embodichain/lab/sim/objects/light.py embodichain/lab/sim/sim_manager.py tests/sim/objects/test_light.py -``` - -- [ ] **Step 2: Run the full test suite for affected modules** - -```bash -cd /home/dex/workspace/sources/EmbodiChain && python -m pytest tests/sim/objects/test_light.py -v -``` - -- [ ] **Step 3: Verify no import regressions** - -```bash -cd /home/dex/workspace/sources/EmbodiChain && python -c " -from embodichain.lab.sim.cfg import LightCfg -from embodichain.lab.sim.objects import Light -from embodichain.lab.sim.sim_manager import SimulationManager -print('All imports successful') -print(f'Supported types: {list(SimulationManager._LIGHT_TYPE_MAP.keys())}') -" -``` - -Expected: `All imports successful` + list of all 6 types. - -- [ ] **Step 4: Final commit if any formatting changes were needed** - -```bash -git add -u && git commit -m "chore: format and finalize light type expansion - -Co-Authored-By: Claude " -``` diff --git a/docs/superpowers/specs/2026-07-05-light-types-design.md b/docs/superpowers/specs/2026-07-05-light-types-design.md deleted file mode 100644 index 1ea20db1..00000000 --- a/docs/superpowers/specs/2026-07-05-light-types-design.md +++ /dev/null @@ -1,217 +0,0 @@ -# Light Type Expansion — Design Spec - -**Date:** 2026-07-05 -**Status:** approved -**Scope:** Core pipeline — `LightCfg`, `Light` class, `SimulationManager.add_light` - -## Motivation - -EmbodiChain currently supports only `point` lights via `LightCfg(light_type="point")`. The dexsim rendering backend exposes 6 light types via Python bindings (`POINT`, `SUN`, `DIRECTION`, `SPOT`, `RECT`, `MESH`), each with distinct properties. The existing `LightCfg` has an explicit TODO: *"to be added more light type, such as spot, sun, etc."* - -This spec covers adding support for all 6 backend light types to the core configuration and runtime API. Gym-layer config (`EnvLightCfg`) and light randomization are out of scope for this round. - -## Design Decisions - -| Decision | Choice | Rationale | -|---|---|---| -| Config structure | Single `LightCfg` with flat optional fields | Simpler, backward-compatible, `from_dict` works automatically | -| Runtime API | Single `Light` class with all setters | Warns on incompatible type; avoids class explosion | -| Parametric vs asset lights | Two-tier: 5 parametric types get tensor batching; `mesh` is string-set-only | Aligns with Isaac Lab's design philosophy — mesh lights are emissive geometry, not parametric primitives | -| Error handling | Warn on misconfiguration, error only on unknown type | Lets users iterate without crashes | - -## Light Types - -| Type | Category | Key Properties | -|---|---|---| -| `point` | Parametric | position, falloff (radius) | -| `sun` | Parametric | position, direction, angular_radius, halo_size, halo_falloff | -| `direction` | Parametric | direction only (infinite distance, no position) | -| `spot` | Parametric | position, direction, spot_angle_inner, spot_angle_outer | -| `rect` | Parametric | position, direction, rect_width, rect_height | -| `mesh` | Asset | position, direction, mesh_path (set-once, no tensor batching) | - -## `LightCfg` — Config Fields - -**File:** `embodichain/lab/sim/cfg.py` - -All fields are flat on the existing `LightCfg` class. Every new field has a default — existing point-light code works unchanged. - -```python -@configclass -class LightCfg(ObjectBaseCfg): - light_type: Literal["point", "sun", "direction", "spot", "rect", "mesh"] = "point" - - # Universal - color: tuple[float, float, float] = (1.0, 1.0, 1.0) - intensity: float = 30.0 - enable_shadow: bool = True - - # Point light - radius: float = 10.0 - - # Directional (sun, direction, spot, rect, mesh) - direction: tuple[float, float, float] = (0.0, 0.0, -1.0) - - # Sun - angular_radius: float = 0.5 - halo_size: float = 10.0 - halo_falloff: float = 3.0 - - # Spot - spot_angle_inner: float = 30.0 - spot_angle_outer: float = 45.0 - - # Rect - rect_width: float = 1.0 - rect_height: float = 1.0 - - # Mesh - mesh_path: str = "" -``` - -### Field Applicability Matrix - -| Field | point | sun | direction | spot | rect | mesh | -|---|---|---|---|---|---|---| -| `color` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| `intensity` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| `enable_shadow` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| `radius` | ✓ | — | — | — | — | — | -| `direction` | — | ✓ | ✓ | ✓ | ✓ | ✓ | -| `angular_radius` | — | ✓ | — | — | — | — | -| `halo_size` | — | ✓ | — | — | — | — | -| `halo_falloff` | — | ✓ | — | — | — | — | -| `spot_angle_inner` | — | — | — | ✓ | — | — | -| `spot_angle_outer` | — | — | — | ✓ | — | — | -| `rect_width` | — | — | — | — | ✓ | — | -| `rect_height` | — | — | — | — | ✓ | — | -| `mesh_path` | — | — | — | — | — | ✓ | - -## `Light` Class — Runtime API - -**File:** `embodichain/lab/sim/objects/light.py` - -### Existing Methods (unchanged signature) - -- `set_color(colors, env_ids=None)` — vector3 broadcast -- `set_intensity(intensities, env_ids=None)` — scalar broadcast -- `set_falloff(falloffs, env_ids=None)` — scalar broadcast (point only, warns otherwise) -- `set_local_pose(pose, env_ids=None, to_matrix=False)` — updated for directional types -- `get_local_pose(to_matrix=False)` — unchanged -- `reset(env_ids=None)` — updated to apply type-specific properties - -### New Methods - -| Method | Tensor Shape | Broadcast Pattern | Applies To | -|---|---|---|---| -| `set_direction(directions, env_ids=None)` | (3,) or (M, 3) | `_apply_vector3` | sun, direction, spot, rect, mesh | -| `set_spot_angle(inner, outer, env_ids=None)` | scalar each | `_apply_scalar` × 2 | spot | -| `set_angular_radius(radii, env_ids=None)` | scalar or (M,) | `_apply_scalar` | sun | -| `set_halo_size(sizes, env_ids=None)` | scalar or (M,) | `_apply_scalar` | sun | -| `set_halo_falloff(falloffs, env_ids=None)` | scalar or (M,) | `_apply_scalar` | sun | -| `set_rect_size(widths, heights, env_ids=None)` | scalar each | `_apply_scalar` × 2 | rect | -| `set_mesh_path(path, env_ids=None)` | `str` | single string, no tensor | mesh | -| `enable_shadow(flags, env_ids=None)` | scalar or (M,) | `_apply_scalar` | all | - -### Runtime Validation Pattern - -Each setter checks `self.cfg.light_type` and warns + no-ops if the property doesn't apply: - -```python -def set_spot_angle(self, inner, outer, env_ids=None): - if self.cfg.light_type != "spot": - logger.warning( - f"set_spot_angle not applicable to light type '{self.cfg.light_type}', ignoring." - ) - return - self._apply_scalar(inner, env_ids, "set_spot_angle_inner") - self._apply_scalar(outer, env_ids, "set_spot_angle_outer") -``` - -### `reset()` Behavior - -`reset()` applies only the properties relevant to `self.cfg.light_type`: - -- **point:** color, intensity, radius, init_pos, shadow -- **sun:** color, intensity, init_pos, direction, angular_radius, halo_size, halo_falloff, shadow -- **direction:** color, intensity, direction, shadow -- **spot:** color, intensity, init_pos, direction, spot_angle_inner, spot_angle_outer, shadow -- **rect:** color, intensity, init_pos, direction, rect_width, rect_height, shadow -- **mesh:** color, intensity, init_pos, direction, mesh_path, shadow - -## `SimulationManager.add_light` — Backend Mapping - -**File:** `embodichain/lab/sim/sim_manager.py` - -### Type Mapping - -```python -_LIGHT_TYPE_MAP: dict[str, LightType] = { - "point": LightType.POINT, - "sun": LightType.SUN, - "direction": LightType.DIRECTION, - "spot": LightType.SPOT, - "rect": LightType.RECT, - "mesh": LightType.MESH, -} -``` - -### Creation Flow - -1. Validate `cfg.light_type` against `_LIGHT_TYPE_MAP` — error on unknown type -2. Create one backend light per env via `env.create_light(f"{uid}_{i}", light_type)` -3. Apply initial properties from `cfg` based on light type (see `reset()` table above) -4. Wrap in `Light(cfg=cfg, entities=light_list)` and store in `self._lights[uid]` - -### Validation Warnings (at creation time) - -| Condition | Action | -|---|---| -| Unknown `light_type` | `logger.log_error`, return None | -| `mesh` with empty `mesh_path` | `logger.warning` | -| `rect` with zero `rect_width` or `rect_height` | `logger.warning` | - -## Backward Compatibility - -All existing call sites continue to work without modification: - -- `LightCfg(uid="light", intensity=10.0, init_pos=(0, 0, 2.0))` — `light_type` defaults to `"point"` -- `LightCfg.from_dict({"light_type": "point", ...})` — flat fields map 1:1 -- `sim.add_light(cfg=LightCfg(...))` — unchanged API -- `light.set_color(...)`, `light.set_intensity(...)`, `light.set_falloff(...)` — unchanged -- `light.set_local_pose(...)`, `light.get_local_pose(...)` — unchanged for point lights - -## Out of Scope - -- `EnvLightCfg` gym-layer changes -- Light randomization (`visual.py`) for new light types -- `indirect` lighting changes (IBL, emission light — already supported) -- USD import/export for new light types - -## Testing - -**File:** `tests/sim/objects/test_light.py` (extend) - -| Test | Coverage | -|---|---| -| `test_point_light_backward_compat` | Existing tests pass unchanged | -| `test_create_all_light_types` | All 6 types created via `add_light` | -| `test_light_type_specific_properties` | Type-specific props applied at creation | -| `test_incompatible_setter_warns` | Wrong-type setter logs warning, no-ops | -| `test_unknown_light_type_errors` | Invalid type logs error | -| `test_directional_light_no_position` | `set_local_pose` on direction warns | -| `test_mesh_light_empty_path_warns` | Empty `mesh_path` warns at creation | -| `test_reset_applies_type_specific_props` | `reset()` restores only relevant props | -| `test_set_direction_broadcasting` | (3,) tensor broadcasts to all instances | -| `test_from_dict_new_types` | `from_dict` works with new fields | -| `test_set_rect_size` | (M, 2) tensor per-instance | -| `test_set_spot_angle` | Inner/outer tensor per-instance | - -## Files Changed - -| File | Change | -|---|---| -| `embodichain/lab/sim/cfg.py` | Expand `LightCfg` fields | -| `embodichain/lab/sim/objects/light.py` | Add setters, update `reset()` | -| `embodichain/lab/sim/sim_manager.py` | Expand type mapping in `add_light` | -| `tests/sim/objects/test_light.py` | Add tests for new types | diff --git a/embodichain/lab/sim/cfg.py b/embodichain/lab/sim/cfg.py index 81128bd0..91c08993 100644 --- a/embodichain/lab/sim/cfg.py +++ b/embodichain/lab/sim/cfg.py @@ -810,7 +810,8 @@ class LightCfg(ObjectBaseCfg): - ``"rect"``: Per-environment rectangular area light with position, direction, width, and height. Created as a batched light. - ``"mesh"``: Per-environment mesh-based emissive light. Requires a - :class:`~dexsim.models.MeshObject` via :meth:`~Light.set_mesh` + :class:`~dexsim.models.MeshObject` via + :meth:`embodichain.lab.sim.objects.light.Light.set_mesh` (not tensor-batched). Created as a batched light. .. attention:: @@ -893,7 +894,8 @@ class LightCfg(ObjectBaseCfg): mesh_path: str = "" """Asset path for mesh-based emissive lights. Only used when ``light_type="mesh"``. - The actual mesh assignment is done via :meth:`Light.set_mesh` which accepts a + The actual mesh assignment is done via + :meth:`embodichain.lab.sim.objects.light.Light.set_mesh` which accepts a :class:`dexsim.models.MeshObject`. This field stores the path for reference.""" diff --git a/embodichain/lab/sim/objects/light.py b/embodichain/lab/sim/objects/light.py index 0ab75dce..06526733 100644 --- a/embodichain/lab/sim/objects/light.py +++ b/embodichain/lab/sim/objects/light.py @@ -584,9 +584,11 @@ def reset(self, env_ids: Sequence[int] | None = None) -> None: Applies only the properties relevant to ``self.cfg.light_type``. .. attention:: - For global lights (:attr:`is_global` is True), ``env_ids`` is - normalized to ``None`` because there is only one instance. - Passing per-environment indices is silently ignored. + When this light has only one instance (global lights such as + ``"sun"`` and ``"direction"``, or any light in a single-environment + scene), ``env_ids`` is normalized to ``None`` because there is only + one instance to update. Passing per-environment indices is silently + ignored. Args: env_ids (Sequence[int] | None): The environment IDs to reset. diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 27932d54..cc997090 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -711,17 +711,17 @@ def set_default_global_lighting(self) -> None: pointing downward along the -Z axis. """ # Environment emission light - self.set_emission_light([1.0, 1.0, 1.0], 120.0) + self.set_emission_light([1.0, 1.0, 1.0], 100.0) # Directional light as global scene light dir_light_cfg = LightCfg( - uid="global_light", + uid="default_global_light", light_type="direction", intensity=8.0, - direction=(0.0, 0, -1.0), + direction=(0.0, 0.0, -1.0), color=(1.0, 0.95, 0.85), + enable_shadow=False, ) - self.add_light(dir_light_cfg) def set_default_background(self) -> None: """Set default background.""" @@ -871,7 +871,6 @@ def add_light(self, cfg: LightCfg) -> Light: f"Unsupported light type: '{light_type_str}'. " f"Supported types: {supported}." ) - return None # Validation warnings for type-specific constraints if light_type_str == "mesh" and not cfg.mesh_path: diff --git a/embodichain/lab/sim/utility/sim_utils.py b/embodichain/lab/sim/utility/sim_utils.py index 993e4df9..7cefbab4 100644 --- a/embodichain/lab/sim/utility/sim_utils.py +++ b/embodichain/lab/sim/utility/sim_utils.py @@ -308,7 +308,7 @@ def load_mesh_objects_from_cfg( for i, env in enumerate(env_list): if max_convex_hull_num > 1: - obj = env.load_actor_with_coacd( + obj = env.load_actor_with_acd( fpath, duplicate=True, attach_scene=True, diff --git a/tests/sim/objects/test_light.py b/tests/sim/objects/test_light.py index 072157cb..0b7bbd79 100644 --- a/tests/sim/objects/test_light.py +++ b/tests/sim/objects/test_light.py @@ -14,6 +14,8 @@ # limitations under the License. # ---------------------------------------------------------------------------- +from __future__ import annotations + import pytest import torch from embodichain.lab.sim import SimulationManager, SimulationManagerCfg @@ -31,7 +33,7 @@ def setup_method(self): "light_type": "point", "color": [0.1, 0.1, 0.1], "radius": 10.0, - "position": [0.0, 0.0, 2.0], + "init_pos": [0.0, 0.0, 2.0], "uid": "point_light", } self.light = self.sim.add_light(cfg=LightCfg.from_dict(cfg_dict)) @@ -432,10 +434,12 @@ def test_point_light_backward_compat(self): "light_type": "point", "color": [0.1, 0.1, 0.1], "radius": 10.0, - "position": [0.0, 0.0, 2.0], + "init_pos": [0.0, 0.0, 2.0], "uid": "point_compat", } - light = self.sim.add_light(cfg=LightCfg.from_dict(cfg_dict)) + cfg = LightCfg.from_dict(cfg_dict) + assert cfg.init_pos == [0.0, 0.0, 2.0] + light = self.sim.add_light(cfg=cfg) assert light is not None assert light.num_instances == 4 From f0fd7201edd02171163494d791eee7a753704824 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Wed, 8 Jul 2026 15:52:15 +0800 Subject: [PATCH 18/18] wip --- embodichain/lab/sim/sim_manager.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index cc997090..625ff930 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -711,17 +711,18 @@ def set_default_global_lighting(self) -> None: pointing downward along the -Z axis. """ # Environment emission light - self.set_emission_light([1.0, 1.0, 1.0], 100.0) + self.set_emission_light([1.0, 1.0, 1.0], 120.0) # Directional light as global scene light dir_light_cfg = LightCfg( uid="default_global_light", - light_type="direction", + light_type="sun", intensity=8.0, direction=(0.0, 0.0, -1.0), color=(1.0, 0.95, 0.85), - enable_shadow=False, + enable_shadow=True, ) + self.add_light(dir_light_cfg) def set_default_background(self) -> None: """Set default background.""" @@ -736,7 +737,7 @@ def set_default_background(self) -> None: uid=mat_name, base_color_texture=color_texture, roughness_texture=roughness_texture, - roughness=0.9, + roughness=1.0, ) )