diff --git a/agent_context/MAP.yaml b/agent_context/MAP.yaml index 9f5910950..efddeb2e5 100644 --- a/agent_context/MAP.yaml +++ b/agent_context/MAP.yaml @@ -13,7 +13,7 @@ topics: NRDCfg, DLSS, NRD, frame_time_delta_ms, explicit physics stepping, arena, DexSim, PhysicsBackendCfg, DefaultPhysicsCfg, NewtonPhysicsCfg, VisualObject, VisualObjectCfg, VisualMaterial, VisualMaterialCfg, VisualMaterialInst, preview-material, 材质预览, UsdSourceCfg, source_prim_path, add_visual_object, 纯渲染对象, AutoSolverCfg, DexUniSolverCfg, DexUni, runtime control, deformable, particle set, ArticulationJointKinematics, get_parent_joint_chain, asset_physics_mode, joint_drive_props, enable_gravity, - fixed-cadence playback, quaternion, xyzw] + fixed-cadence playback, FluidObjectCfg, FluidSpawnCfg, FluidObject, SPHSolverCfg, revolved_volume, fill_level, 粒子采样, fluid, 流体, quaternion, xyzw] paths: [topics/simulation-system/simulation-system.md] source_of_truth: - embodichain/lab/sim/motion/__init__.py @@ -22,6 +22,7 @@ topics: - embodichain/cli/_visualization.py - embodichain/lab/sim/__init__.py - embodichain/lab/sim/cfg/ + - embodichain/compute/geometry/particles.py - embodichain/lab/sim/physics/ - embodichain/lab/sim/spawn/ - embodichain/lab/sim/material.py @@ -37,7 +38,7 @@ topics: - embodichain/lab/sim/objects/gizmo.py - embodichain/lab/gym/envs/base_env.py watch_paths: [embodichain/lab/sim/sim_manager.py, embodichain/lab/sim/cfg/, embodichain/lab/sim/physics/, - embodichain/lab/sim/spawn/, embodichain/lab/sim/material.py, embodichain/lab/sim/_material_presets.py, embodichain/lab/scripts/preview_material.py, tests/lab/scripts/test_preview_material.py, embodichain/lab/sim/objects/, tests/sim/] + embodichain/lab/sim/spawn/, embodichain/lab/sim/material.py, embodichain/lab/sim/_material_presets.py, embodichain/lab/scripts/preview_material.py, tests/lab/scripts/test_preview_material.py, embodichain/lab/sim/objects/, embodichain/compute/geometry/particles.py, tests/compute/test_particles.py, tests/sim/] related_topics: [env-framework, robot-system, sensor-system, sim-visualization, ik-solvers, motion-planning, atomic-actions, rl-learning, configclass-pattern, robot-workspace] status: active diff --git a/agent_context/topics/simulation-system/fluids.md b/agent_context/topics/simulation-system/fluids.md new file mode 100644 index 000000000..51016ff70 --- /dev/null +++ b/agent_context/topics/simulation-system/fluids.md @@ -0,0 +1,88 @@ +# Initial-fill fluids + +`cfg/fluid.py` owns source, object and render configuration. Fluids use DexUni, +which dispatches DFSPH when the scene contains fluid particles. Optional +numerical settings belong to DexSim `SPHSolverCfg`, nested under +`NewtonPhysicsCfg.solver_cfg.sph_cfg`; omitting them uses native DFSPH defaults. +`SimulationManager.add_fluid_object()` declares through SpawnScene and binds +`objects/fluid.py` in place at `prepare()`. + +The fluid declaration owns density and spacing. Default native SPH values for +`rest_density` and `particle_diameter` are placeholders, equally for mappings +and native config objects; non-default solver values must match the fluid. +DexSim derives the effective solver values from the fluid descriptors. + +## Runtime requirements + +Fluid simulation requires Newton CUDA, a DexSim build exposing +`DexUniSolverCfg.sph_cfg`, no gradients and fixed particle topology. +Multiple environments require a DexSim SPH solver that explicitly advertises +both world isolation and selected-world reset; arena spacing alone does not +establish isolation. The development build +`0.5.1+0b8422cc6f6717058bd15371e61bd35cdbbe3c0c` supports both. The published +`0.5.1rc1` artifact does not provide the DexUni/DFSPH composition and cannot +run this fluid path. + +The current native DexUni/DFSPH implementation is a prototype: DFSPH follows +the rigid step with one-way rigid-to-fluid interaction. When rigid bodies are +present, it requires the pure MuJoCo rigid path; static collision shapes do not +require a rigid-body solver. It cannot mix fluids with cloth or soft-body particles. +Online robot pouring, Gym declarations and replay belong to the dependent task +layer; two-way fluid feedback is not implied by DexUni's rigid/soft coupling. + +## State and sampling + +Fluid state uses world-frame `(instances, particles, 3|6)` Torch snapshots. +Writes use Scene particle batches, invalidate captured graphs and reset +supported solver history. The legacy single-world solver rebaselines optional +SDF boundary pose history. Reset restores the immutable initial state or +transforms a saved local template without stepping the world. Explicit state +writes do not refresh density diagnostics until the next SPH step. + +A spawn reference resolves a declared rigid body's initial pose once; it is +not a persistent attachment. Sources include explicit points/NPZ, primitives, +capped Z-axis revolved profiles and closed fill-volume meshes. Profile points +are ordered `(local Z, radius)` pairs. `fill_level` clips the full source Z +extent before sampling; clearance does not inset that free surface. Mesh +sources describe closed cavities, rather than container walls. Point imports +reject volume-only profile/fill-level controls. + +Sampling math belongs to `compute/geometry/particles.py`, independent of lab. +The manager samples once at declaration and retains the local template; +binding and reset do not reopen source files. Particle count is fixed after +preparation, and all fluids share density and spacing. Adding, replacing or +removing rigid assets after preparing a particle scene requires a new scene. + +`FluidObject(cfg, device)` creates an unregistered facade without sampling or +state allocation. The manager resolves the source recipe and reference pose, +then privately initializes the facade's local particle template, initial +rotation and write-invalidation hook. Spawn receives the same sampled points +and resolved pose. Derived caches never become additional config fields; +binding and reset reuse them while the input config remains a source recipe. + +## Rendering and validation + +Native fluids require FastRT (`fast-rt`) or OfflineRT (`rt`). Hybrid's Vulkan +primary surface lacks the OptiX liquid geometry, so the manager rejects Hybrid +before sampling or declaration, including an already initialized Hybrid +process. Use `no-render` for physics only; fluid mode `none` requires it. +Display clipping changes only a render copy. Viser consumes detached particle +point clouds. NoRender needs no optical material. Advanced optical parameters +follow the renderer requirements of the [material system](materials.md). + +`scripts/tutorials/sim/create_fluid.py` demonstrates procedural box sampling, +five primitive container walls, prepare, step, state reads and reset. Pass +`--num-envs` to use multiple independent worlds on a capable SPH build. The +fixed container uses static SDF collision shapes, so DFSPH can collide with it +without running a rigid-body solver. It needs no robot, task configuration or downloaded assets: + +```bash +python scripts/tutorials/sim/create_fluid.py --headless --renderer no-render +``` + +Focused checks are `tests/compute/test_particles.py`, +`tests/sim/test_fluid_cfg.py`, `tests/sim/objects/test_fluid_object.py` and +`tests/visualization/test_scene_exporter.py`. Serial CUDA lifecycle coverage is +`tests/sim/objects/test_fluid_runtime.py`; `test_fluid_visibility.py` checks +camera pixels after stepping and direct state writes. Tutorial checks remain +syntax/CLI smoke checks rather than tutorial import tests. diff --git a/agent_context/topics/simulation-system/physics-configuration.md b/agent_context/topics/simulation-system/physics-configuration.md index 62646316c..3eab43177 100644 --- a/agent_context/topics/simulation-system/physics-configuration.md +++ b/agent_context/topics/simulation-system/physics-configuration.md @@ -45,6 +45,8 @@ supports full-surface rigid-soft feedback in current DexSim builds. Native validation owns compatible contact modes and option combinations. Prescribed kinematic trajectories cannot respond to contact reactions. Forward solver expert options through `NewtonPhysicsCfg.solver_cfg`; DexSim owns their validation and dispatch. +The current [DFSPH composition](fluids.md) remains one-way even when DexUni's +rigid/soft coupling options are set to two-way. Articulation gravity compensation and passive damping are separate creation settings; see [articulation adapters](articulation-adapters.md). `async_sync`, `rigid_transform_sync_mode`, `capture_acquire_timeout` and diff --git a/agent_context/topics/simulation-system/rendering.md b/agent_context/topics/simulation-system/rendering.md index 8b90fca3c..00432ede8 100644 --- a/agent_context/topics/simulation-system/rendering.md +++ b/agent_context/topics/simulation-system/rendering.md @@ -96,6 +96,10 @@ The Newton backend step hook suppresses automatic DexSim publication during manager-owned updates to avoid publishing twice. The manager honors explicit `sync_to_renderer=True` even without consumers; `None`/`False` publish only for consumers. Raw DexSim `World.update()` retains its configured automatic policy. +When fluids are present, publication orders Newton and Torch work before the +native CUDA staging copy and waits for that copy before a camera reads the frame. +After an explicit fluid-state write, it first drains prior staged updates so a +busy native double buffer cannot silently discard the newer state. Render-thread recording only consumes already-published state and must never call the blocking physics-to-render bridge. Marker publication while paused uses `capture_visualization(force=True)` without advancing physics. @@ -147,6 +151,10 @@ Configuration tests do not prove GPU/NGX/NRD runtime support, which initializes on a rendered frame. Read exact renderer and algorithm defaults in the config source. +Optical settings use `VisualMaterialCfg.parameters` for direct and shape-owned +materials. Resource definitions and their reset baselines are owned by the +[material system](materials.md). + ## Startup summaries `sim/_startup_summary.py` collects read-only rows. Manager engine output follows diff --git a/agent_context/topics/simulation-system/simulation-system.md b/agent_context/topics/simulation-system/simulation-system.md index 0dbd10542..42e6c5c5d 100644 --- a/agent_context/topics/simulation-system/simulation-system.md +++ b/agent_context/topics/simulation-system/simulation-system.md @@ -126,6 +126,7 @@ tests need non-symmetric rotations to expose component-order mistakes. | Preparation, topology changes, reset, cleanup, controls | [Lifecycle](lifecycle.md); manager and `spawn/scene.py` | | Backend/device selection, source overlays, collision/mass policy | [Physics configuration](physics-configuration.md); `cfg/`, `spawn/descriptors.py` | | Joint state order, drive lowering, mimic coupling, native adaptation | [Articulation and batch adapters](articulation-adapters.md); `objects/backends/`, `objects/articulation.py` | +| Initial-fill fluid configuration, state and rendering | [Fluids](fluids.md); `cfg/fluid.py`, `objects/fluid.py` | | Volume/surface state, topology and kinematic nodes | [Deformables](deformables.md); `cfg/deformable.py`, `objects/deformable/` | | Camera synchronization, native window/offscreen denoising, startup readiness | [Rendering and diagnostics](rendering.md); manager, `cfg/simulation.py`, `_startup_summary.py` | | Robot definition, control parts, robot presets | [Robot system](../robot-system/robot-system.md) | diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst index 7541dd47f..63b2e6fd5 100644 --- a/docs/source/api_reference/public_api.rst +++ b/docs/source/api_reference/public_api.rst @@ -22,6 +22,16 @@ decoding and compiling the constrained Task Program schema surface. compile_mllm_task_program decode_mllm_task_program +embodichain.compute.geometry.particles +-------------------------------------- + +Deterministic lattice sampling for primitive, revolved and closed mesh fill volumes. + +.. autosummary:: + + embodichain.compute.geometry.particles.sample_volume_particles + +.. autofunction:: embodichain.compute.geometry.particles.sample_volume_particles embodichain.data ---------------- @@ -683,6 +693,37 @@ embodichain.lab.gym.envs.embodied_env EmbodiedEnvCfg EmbodiedEnv +embodichain.lab.sim.cfg.fluid +----------------------------- + +Fixed-count fluid sources, physical properties and native particle or surface display. + +.. autosummary:: + + embodichain.lab.sim.cfg.fluid.FluidSpawnCfg + embodichain.lab.sim.cfg.fluid.FluidRenderCfg + embodichain.lab.sim.cfg.fluid.FluidObjectCfg + +.. autoclass:: embodichain.lab.sim.cfg.fluid.FluidSpawnCfg + +.. autoclass:: embodichain.lab.sim.cfg.fluid.FluidRenderCfg + +.. autoclass:: embodichain.lab.sim.cfg.fluid.FluidObjectCfg + +embodichain.lab.sim.objects.fluid +--------------------------------- + +Scene-owned fluid state snapshots, explicit writes and initial-state restoration. + +.. autosummary:: + + embodichain.lab.sim.objects.fluid.FluidObject + embodichain.lab.sim.objects.fluid.FluidObjectData + +.. autoclass:: embodichain.lab.sim.objects.fluid.FluidObject + +.. autoclass:: embodichain.lab.sim.objects.fluid.FluidObjectData + embodichain.lab.sim.scene.expansion.layout ------------------------------------------ diff --git a/embodichain/compute/geometry/particles.py b/embodichain/compute/geometry/particles.py new file mode 100644 index 000000000..efa1ca6c8 --- /dev/null +++ b/embodichain/compute/geometry/particles.py @@ -0,0 +1,157 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Deterministic volume sampling, independent of simulation objects.""" + +from __future__ import annotations + +import math +from typing import Sequence +import numpy as np + +__all__ = ["sample_volume_particles"] + + +def sample_volume_particles( + kind: str, + spacing: float, + *, + size: Sequence[float] = (0.05, 0.05, 0.05), + radius: float = 0.025, + height: float = 0.05, + clearance: float = 0.0, + profile: Sequence[Sequence[float]] | None = None, + fill_level: float = 1.0, + mesh_path: str | None = None, + max_particles: int = 1_000_000, +) -> np.ndarray: + """Sample a local primitive, revolved profile or closed mesh on a cubic lattice. + + Args: + kind: ``box``, ``sphere``, ``cylinder`` (Z axis), + ``revolved_volume`` (Z-axis profile), or ``mesh_volume``. + spacing: Positive lattice spacing, metres. + size: Full box dimensions, metres. + radius: Sphere or cylinder radius, metres. + height: Full cylinder height, metres. + clearance: Inward distance from the volume boundary, metres. + profile: For ``revolved_volume``, at least two ``(z, radius)`` pairs + with strictly increasing Z and nonnegative radii, in local metres. + Linear segments define the side wall; both ends are capped. + fill_level: Fraction of the source's full Z extent to fill, in ``(0, 1]``. + The free-surface cutoff is independent of solid-wall clearance. + mesh_path: Closed fill-volume mesh for ``mesh_volume``. + max_particles: Maximum candidate-grid size before allocation. + + Returns: + Contiguous float32 local positions of shape ``(N, 3)``. + """ + if not math.isfinite(spacing) or spacing <= 0: + raise ValueError("spacing must be finite and positive.") + if not math.isfinite(clearance) or clearance < 0: + raise ValueError("clearance must be finite and nonnegative.") + if not math.isfinite(fill_level) or not 0 < fill_level <= 1: + raise ValueError("fill_level must be finite and in (0, 1].") + if type(max_particles) is not int or max_particles <= 0: + raise ValueError("max_particles must be a positive integer.") + mesh = None + section = None + if kind == "revolved_volume": + section = np.asarray(profile, dtype=float) + if ( + section.ndim != 2 + or section.shape[1] != 2 + or len(section) < 2 + or not np.isfinite(section).all() + or np.any(np.diff(section[:, 0]) <= 0) + or np.any(section[:, 1] < 0) + or not np.any(section[:, 1] > 0) + ): + raise ValueError( + "profile requires finite (z, radius) pairs with increasing Z and nonnegative radii." + ) + elif profile is not None: + raise ValueError("profile is only valid for revolved_volume.") + if kind == "mesh_volume": + import trimesh + + if mesh_path is None: + raise ValueError("mesh_volume requires mesh_path.") + mesh = trimesh.load(mesh_path, force="mesh") + if not mesh.is_watertight or not mesh.is_volume: + raise ValueError("Fluid fill mesh must be a closed, oriented volume.") + lower, upper = mesh.bounds + else: + if mesh_path is not None: + raise ValueError("mesh_path is only valid for mesh_volume.") + if kind == "box": + extent = np.asarray(size, dtype=float) + elif kind == "sphere": + extent = np.array([2 * radius] * 3) + elif kind == "cylinder": + extent = np.array([2 * radius, 2 * radius, height]) + elif section is not None: + max_radius = section[:, 1].max() + extent = np.array([2 * max_radius, 2 * max_radius, np.ptp(section[:, 0])]) + else: + raise ValueError(f"Unsupported fluid volume {kind!r}.") + if extent.shape != (3,) or not np.isfinite(extent).all() or (extent <= 0).any(): + raise ValueError("Volume dimensions must be finite and positive.") + lower, upper = -extent / 2, extent / 2 + if section is not None: + lower[2], upper[2] = section[0, 0], section[-1, 0] + surface_z = lower[2] + fill_level * (upper[2] - lower[2]) + lower, upper = lower + clearance, upper - clearance + upper[2] = min(upper[2], surface_z) + counts = [max(0, math.ceil(float(v) / spacing)) for v in upper - lower] + if not all(counts): + raise ValueError("clearance leaves an empty fill volume.") + if math.prod(counts) > max_particles: + raise ValueError("Fluid candidate grid exceeds max_particles.") + axes = [lo + (np.arange(n) + 0.5) * spacing for lo, n in zip(lower, counts)] + points = np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1).reshape(-1, 3) + keep = np.all(points < upper, axis=1) + if kind == "sphere": + keep &= np.linalg.norm(points, axis=1) < radius - clearance + elif kind == "cylinder": + keep &= np.linalg.norm(points[:, :2], axis=1) < radius - clearance + elif section is not None: + radial = np.linalg.norm(points[:, :2], axis=1) + keep &= radial < np.interp(points[:, 2], section[:, 0], section[:, 1]) + if clearance: + # Rotational symmetry reduces side-wall distance to the meridian + # plane. Include all segments, including steep or concave profiles. + meridian = np.column_stack((points[:, 2], radial)) + distance2 = np.full(len(points), np.inf) + for start, end in zip(section[:-1], section[1:]): + segment = end - start + t = np.clip( + (meridian - start) @ segment / np.dot(segment, segment), 0, 1 + ) + delta = meridian - (start + t[:, None] * segment) + distance2 = np.minimum(distance2, np.einsum("ij,ij->i", delta, delta)) + keep &= distance2 >= clearance**2 + elif mesh is not None: + import trimesh + + keep &= mesh.contains(points) + if clearance: + _, distance, _ = trimesh.proximity.closest_point(mesh, points) + keep &= distance >= clearance + points = points[keep] + if not len(points): + raise ValueError("Fluid volume contains no particles at this spacing.") + return np.ascontiguousarray(points, dtype=np.float32) diff --git a/embodichain/lab/sim/_startup_summary.py b/embodichain/lab/sim/_startup_summary.py index ea5687d56..677230af8 100644 --- a/embodichain/lab/sim/_startup_summary.py +++ b/embodichain/lab/sim/_startup_summary.py @@ -269,6 +269,7 @@ def scene_rows(sim: SimulationManager) -> list[Row]: ), ("Scene / env", "Object groups", str(len(sim._rigid_object_groups))), ("Scene / env", "Deformables", str(len(sim._deformable_objects))), + ("Scene / env", "Fluids", str(len(getattr(sim, "_fluid_objects", {})))), ("Scene / env", "Sensors", str(len(sim._sensors))), ( "Scene", diff --git a/embodichain/lab/sim/cfg/__init__.py b/embodichain/lab/sim/cfg/__init__.py index 51e2b2f0f..230021653 100644 --- a/embodichain/lab/sim/cfg/__init__.py +++ b/embodichain/lab/sim/cfg/__init__.py @@ -39,6 +39,7 @@ link_attrs_from_dict, ) from .asset import AssetPhysicsMode, ObjectBaseCfg, _resolve_asset_physics_mode +from .fluid import FluidObjectCfg, FluidSpawnCfg, FluidRenderCfg from .source import UsdSourceCfg from .deformable import ( SurfaceElementPropertiesCfg, @@ -88,6 +89,9 @@ from .robot import RobotCfg, RobotPresetCfg # noqa: E402 __all__ = [ + "FluidObjectCfg", + "FluidSpawnCfg", + "FluidRenderCfg", "DEFAULT_RENDERER", "AssetPhysicsMode", "DenoisingCfg", diff --git a/embodichain/lab/sim/cfg/fluid.py b/embodichain/lab/sim/cfg/fluid.py new file mode 100644 index 000000000..aa3a94b2f --- /dev/null +++ b/embodichain/lab/sim/cfg/fluid.py @@ -0,0 +1,186 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Initial-fill fluid configuration; numerical solvers remain owned by DexSim.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Literal +import math +import numpy as np + +from embodichain.utils import configclass +from ..material import VisualMaterialCfg +from .asset import ObjectBaseCfg + +__all__ = ["FluidSpawnCfg", "FluidRenderCfg", "FluidObjectCfg"] + + +@configclass +class FluidSpawnCfg: + """Deterministic local particle source, with no runtime emission. + + Mesh sources describe a closed *fill volume*, not the solid container wall. + File paths are resolved by the caller; NPZ sources use the ``positions`` key. + ``reference_uid`` resolves a declared rigid body's initial pose once. + """ + + kind: Literal[ + "points", "box", "sphere", "cylinder", "revolved_volume", "mesh_volume" + ] = "points" + positions: Any = None + file_path: str | None = None + size: tuple[float, float, float] = (0.05, 0.05, 0.05) + radius: float = 0.025 + height: float = 0.05 + clearance: float = 0.0 + profile: tuple[tuple[float, float], ...] | None = None + """Piecewise-linear ``(local Z, radius)`` pairs for a capped revolved fill volume.""" + fill_level: float = 1.0 + """Filled fraction of the complete source Z extent; only for volume sources.""" + reference_uid: str | None = None + initial_velocity: tuple[float, float, float] = (0.0, 0.0, 0.0) + max_particles: int = 1_000_000 + """Bound the candidate grid allocation before sampling.""" + + def sample(self, spacing: float) -> np.ndarray: + """Return independent finite local positions of shape ``(N, 3)``. + + Args: + spacing: Positive lattice spacing in metres. + + Returns: + Contiguous float32 positions in deterministic lattice order. + """ + from embodichain.compute.geometry.particles import sample_volume_particles + + if not math.isfinite(spacing) or spacing <= 0: + raise ValueError("spacing must be finite and positive.") + if type(self.max_particles) is not int or self.max_particles <= 0: + raise ValueError("max_particles must be a positive integer.") + if self.kind == "points": + if self.profile is not None or self.fill_level != 1.0: + raise ValueError( + "points sources cannot apply profile or fill_level; use a volume source." + ) + if (self.positions is None) == (self.file_path is None): + raise ValueError( + "points requires exactly one of positions or file_path." + ) + if self.file_path is not None: + with np.load(self.file_path, allow_pickle=False) as source: + points = np.asarray(source["positions"], dtype=np.float32).copy() + else: + points = np.asarray(self.positions, dtype=np.float32).copy() + else: + if self.positions is not None: + raise ValueError("positions is only valid for kind='points'.") + points = sample_volume_particles( + self.kind, + spacing, + size=self.size, + radius=self.radius, + height=self.height, + clearance=self.clearance, + profile=self.profile, + fill_level=self.fill_level, + mesh_path=self.file_path, + max_particles=self.max_particles, + ) + if ( + points.ndim != 2 + or points.shape[1] != 3 + or not len(points) + or not np.isfinite(points).all() + ): + raise ValueError("Fluid positions must be finite, nonempty (N, 3) points.") + if len(points) > self.max_particles: + raise ValueError("Fluid source exceeds max_particles.") + return np.ascontiguousarray(points) + + +@configclass +class FluidRenderCfg: + """Native particle/surface display, independent of physical particle state. + + ``mesh_surface`` forwards DexSim FluidMeshSurfaceRenderDesc fields directly. + Optional world-space clipping affects only a detached render copy. + Native fluid geometry requires ``fast-rt`` or ``rt``; Hybrid's Vulkan + primary surface does not contain the OptiX liquid instances. + ``mode="none"`` currently requires the simulation NoRender backend. + """ + + mode: Literal["none", "particles", "mesh_surface"] = "particles" + render_radius: float | None = None + mesh_surface: dict[str, Any] = {} + material: VisualMaterialCfg | None = None + clip_below_z: float | None = None + + def __post_init__(self) -> None: + if isinstance(self.material, Mapping): + self.material = VisualMaterialCfg.from_dict(dict(self.material)) + if self.mode not in {"none", "particles", "mesh_surface"}: + raise ValueError("Unsupported fluid render mode.") + for name in ("render_radius", "clip_below_z"): + value = getattr(self, name) + if value is not None and not math.isfinite(value): + raise ValueError(f"{name} must be finite.") + if self.render_radius is not None and self.render_radius <= 0: + raise ValueError("render_radius must be positive.") + + +@configclass +class FluidObjectCfg(ObjectBaseCfg): + """A fixed-count DexUni/DFSPH fluid, declared before scene preparation. + + Density and spacing determine mass and must agree across fluid objects. + Solver viscosity, surface tension and iteration controls belong exclusively + to DexSim's SPHSolverCfg supplied through DexUniSolverCfg.sph_cfg. + + This config owns density and spacing. Native SPH defaults for those fields + are placeholders in both mapping and native ``sph_cfg`` values; non-default + solver values must match the fluid declaration. + """ + + density: float = 1000.0 + particle_spacing: float = 0.0015 + particle_radius: float | None = None + spawn: FluidSpawnCfg = FluidSpawnCfg() + render: FluidRenderCfg = FluidRenderCfg() + + def __post_init__(self) -> None: + if isinstance(self.spawn, Mapping): + self.spawn = FluidSpawnCfg(**self.spawn) + if isinstance(self.render, Mapping): + self.render = FluidRenderCfg(**self.render) + for name in ("density", "particle_spacing", "particle_radius"): + value = getattr(self, name) + if value is not None and (not math.isfinite(value) or value <= 0): + raise ValueError(f"{name} must be finite and positive.") + velocity = np.asarray(self.spawn.initial_velocity) + if velocity.shape != (3,) or not np.isfinite(velocity).all(): + raise ValueError("initial_velocity must contain three finite values.") + if ( + not isinstance(self.spawn.max_particles, int) + or self.spawn.max_particles <= 0 + ): + raise ValueError("max_particles must be a positive integer.") + + @classmethod + def from_dict(cls, init_dict: dict[str, Any]) -> FluidObjectCfg: + """Decode the same typed configuration used by Python callers.""" + return cls(**init_dict) diff --git a/embodichain/lab/sim/cfg/physics.py b/embodichain/lab/sim/cfg/physics.py index f5afff584..c3e3f5b75 100644 --- a/embodichain/lab/sim/cfg/physics.py +++ b/embodichain/lab/sim/cfg/physics.py @@ -511,6 +511,10 @@ def to_dexsim_cfg( def _normalize_newton_solver_type(solver_type: str) -> str: """Normalize public EmbodiChain and DexSim Newton solver aliases.""" key = solver_type.replace("-", "_").lower() + if key in {"sph", "sphsolvercfg"}: + logger.log_error( + "Standalone SPH is not supported by this mapping; configure DexUni with sph_cfg instead." + ) aliases = { "auto": "dexuni", "autosolver": "dexuni", diff --git a/embodichain/lab/sim/objects/__init__.py b/embodichain/lab/sim/objects/__init__.py index b948e3591..81f166ed6 100644 --- a/embodichain/lab/sim/objects/__init__.py +++ b/embodichain/lab/sim/objects/__init__.py @@ -29,6 +29,7 @@ RigidBodyGroupData, RigidObjectGroupCfg, ) +from .fluid import FluidObject, FluidObjectData from .deformable import ( DeformableObject, DeformableObjectData, diff --git a/embodichain/lab/sim/objects/fluid.py b/embodichain/lab/sim/objects/fluid.py new file mode 100644 index 000000000..e1bdf9e0a --- /dev/null +++ b/embodichain/lab/sim/objects/fluid.py @@ -0,0 +1,271 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Declared fixed-count fluids with Scene-owned particle state.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Callable, Sequence +import numpy as np +import torch + +from embodichain.lab.sim.cfg.fluid import FluidObjectCfg +from embodichain.lab.sim.objects.deformable.data import DeformableObjectData +from embodichain.utils.math import matrix_from_quat + +__all__ = ["FluidObject", "FluidObjectData"] + + +class FluidObjectData: + """Detached world-frame state snapshots, shaped ``(E, N, 3|6)``. + + The shared Scene particle batch adapter is composed without inheriting + any deformable connectivity or fixed render topology. + """ + + def __init__( + self, entities: Sequence[Any], scene: Any, device: torch.device + ) -> None: + self._state = DeformableObjectData(entities, scene, device) + + @property + def particle_pos_w(self) -> torch.Tensor: + """Current positions in metres; modifying the result does not write state.""" + return self._state.nodal_pos_w + + @property + def particle_vel_w(self) -> torch.Tensor: + """Current world-frame velocities in metres per second.""" + return self._state.nodal_vel_w + + @property + def particle_state_w(self) -> torch.Tensor: + """Concatenated positions and velocities, with shape ``(E, N, 6)``.""" + return self._state.nodal_state_w + + @property + def default_particle_state_w(self) -> torch.Tensor: + """Independent copy of the finalized initial state.""" + return self._state.default_nodal_state_w + + +class FluidObject: + """One logical fluid declared and bound by SimulationManager. + + Particle count is fixed. There is no physical rigid root pose or persistent + attachment to the initial container. Pose reset transforms the rest template. + """ + + def __init__( + self, + cfg: FluidObjectCfg, + device: torch.device = torch.device("cpu"), + ) -> None: + """Create an unregistered facade without sampling or allocating state. + + Args: + cfg: Particle-source recipe and initial physical/render settings. + device: Device for state and resolved source caches after declaration. + """ + self.cfg = deepcopy(cfg) + self.uid = self.cfg.uid + self.device = torch.device(device) + self._rest_positions_local: torch.Tensor | None = None + self._initial_rotation: torch.Tensor | None = None + self._entities: list[Any] = [] + self._spawn_result = None + self._data: FluidObjectData | None = None + self._declared_num_instances: int | None = None + self._on_write: Callable[[Sequence[int]], None] | None = None + + def _initialize_spawn_source( + self, + rest_positions_local: np.ndarray, + initial_rotation: np.ndarray, + *, + on_write: Callable[[Sequence[int]], None], + ) -> None: + """Cache Manager-resolved source data without changing the input config.""" + if self._rest_positions_local is not None: + raise RuntimeError("Fluid source is already initialized.") + positions = np.asarray(rest_positions_local, dtype=np.float32) + rotation = np.asarray(initial_rotation, dtype=np.float32) + if positions.ndim != 2 or positions.shape[1] != 3 or not len(positions): + raise ValueError("Fluid source requires nonempty (N, 3) positions.") + if rotation.shape != (3, 3) or not np.isfinite(rotation).all(): + raise ValueError("Fluid source requires a finite (3, 3) initial rotation.") + if not callable(on_write): + raise TypeError("Fluid state invalidation callback must be callable.") + self._rest_positions_local = torch.as_tensor( + positions.copy(), device=self.device + ) + self._initial_rotation = torch.as_tensor(rotation.copy(), device=self.device) + self._on_write = on_write + + def _initialize_spawn_declaration(self, num_instances: int) -> None: + if num_instances <= 0: + raise ValueError("FluidObject requires a positive instance count.") + if self._declared_num_instances not in (None, num_instances): + raise RuntimeError("FluidObject instance count is already declared.") + self._declared_num_instances = num_instances + + @property + def is_declared(self) -> bool: + """Whether state is still unavailable pending ``prepare()``.""" + return self._data is None + + @property + def num_instances(self) -> int: + """Number of scene-owned replicas.""" + if self._declared_num_instances is None: + raise RuntimeError("FluidObject must be registered through SpawnScene.") + return self._declared_num_instances + + @property + def num_particles(self) -> int: + """Fixed particle count per replica.""" + if self._rest_positions_local is None: + raise RuntimeError( + "Fluid source requires SimulationManager.add_fluid_object()." + ) + return len(self._rest_positions_local) + + @property + def particle_mass(self) -> float: + """Uniform particle mass in kilograms.""" + return self.cfg.density * self.cfg.particle_spacing**3 + + @property + def nominal_volume(self) -> float: + """Rest volume in cubic metres, independent of render reconstruction.""" + return self.num_particles * self.cfg.particle_spacing**3 + + @property + def data(self) -> FluidObjectData: + """Return prepared particle data, or raise before binding.""" + if self._data is None: + raise RuntimeError("Fluid state requires SimulationManager.prepare().") + return self._data + + def attach_spawn_handles(self, entities: Sequence[Any]) -> None: + """Attach the replicas materialized by Spawn.""" + if len(entities) != self.num_instances: + raise RuntimeError("Fluid replica count does not match the declaration.") + self._entities = list(entities) + + def bind_spawn(self, scene: Any) -> None: + """Bind Scene batch state and capture configured initial velocities.""" + if self._data is not None: + return + if ( + self._rest_positions_local is None + or self._initial_rotation is None + or self._on_write is None + ): + raise RuntimeError("Fluid source must be initialized before binding.") + data = FluidObjectData(self._entities, scene, self.device) + if data._state.n_nodes != self.num_particles: + raise RuntimeError("Spawn changed the fluid particle count.") + state = data.particle_state_w + velocity = torch.as_tensor( + self.cfg.spawn.initial_velocity, device=self.device, dtype=torch.float32 + ) + state[..., 3:] = velocity @ self._initial_rotation.T + data._state._apply_nodal_state( + state[..., :3], state[..., 3:], list(range(self.num_instances)) + ) + data._state._default_nodal_state_w = state.clone() + self._spawn_result = scene + self._data = data + + def _indices(self, env_ids: Sequence[int] | None) -> list[int]: + if env_ids is None: + return list(range(self.num_instances)) + ids = torch.as_tensor(env_ids) + if ids.ndim != 1 or ( + ids.numel() and ids.dtype not in (torch.int32, torch.int64) + ): + raise ValueError("env_ids must be a one-dimensional integer selection.") + result = ids.tolist() + if len(set(result)) != len(result) or any( + i < 0 or i >= self.num_instances for i in result + ): + raise ValueError("env_ids must be unique valid instance indices.") + return result + + def write_particle_state_to_sim( + self, + state: torch.Tensor, + env_ids: Sequence[int] | None = None, + ) -> None: + """Write selected world-frame positions and velocities without stepping. + + Args: + state: Finite floating tensor ``(selected E, N, 6)``. + env_ids: Unique instance indices; None selects all instances. + """ + data = self.data + ids = self._indices(env_ids) + if not isinstance(state, torch.Tensor) or not state.is_floating_point(): + raise TypeError("Fluid state must be a floating Torch tensor.") + if tuple(state.shape) != (len(ids), self.num_particles, 6): + raise ValueError("Fluid state must have shape (selected E, N, 6).") + if not torch.isfinite(state).all(): + raise ValueError("Fluid state must be finite.") + if not ids: + return + assert self._on_write is not None + self._on_write(ids) + data._state._apply_nodal_state(state[..., :3], state[..., 3:], ids) + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Restore selected initial particle states without advancing time.""" + data = self.data + ids = self._indices(env_ids) + self.write_particle_state_to_sim(data.default_particle_state_w[ids], ids) + + def reset_to_pose( + self, pose_w: torch.Tensor, env_ids: Sequence[int] | None = None + ) -> None: + """Place the local rest template at world poses ``xyz + xyzw``. + + Args: + pose_w: Finite ``(selected E, 7)`` poses with unit quaternions. + env_ids: Unique instance indices, or None for all. + """ + _ = self.data + ids = self._indices(env_ids) + if tuple(pose_w.shape) != (len(ids), 7) or not torch.isfinite(pose_w).all(): + raise ValueError("pose_w must be finite (selected E, 7).") + pose = pose_w.to(device=self.device, dtype=torch.float32) + if not torch.allclose( + pose[:, 3:].norm(dim=-1), + torch.ones(len(ids), device=self.device), + atol=1e-5, + ): + raise ValueError("pose_w requires unit xyzw quaternions.") + rotation = matrix_from_quat(pose[:, 3:]) + assert self._rest_positions_local is not None + points = ( + torch.einsum("eij,nj->eni", rotation, self._rest_positions_local) + + pose[:, None, :3] + ) + velocity = torch.as_tensor( + self.cfg.spawn.initial_velocity, device=self.device, dtype=torch.float32 + ) + velocities = (rotation @ velocity)[..., None, :].expand_as(points) + self.write_particle_state_to_sim(torch.cat((points, velocities), dim=-1), ids) diff --git a/embodichain/lab/sim/physics/newton.py b/embodichain/lab/sim/physics/newton.py index ac1f8544e..20f4b933f 100644 --- a/embodichain/lab/sim/physics/newton.py +++ b/embodichain/lab/sim/physics/newton.py @@ -19,6 +19,7 @@ import importlib from typing import TYPE_CHECKING +from collections.abc import Sequence import weakref import warp as wp @@ -27,8 +28,10 @@ if TYPE_CHECKING: import dexsim + import torch from embodichain.lab.sim.sim_manager import SimulationManagerCfg + from embodichain.lab.sim.objects.fluid import FluidObject __all__ = ["NewtonPhysicsBackend"] @@ -58,6 +61,9 @@ def __init__(self, manager) -> None: self._differentiable_runtime = None self._runtime_device: str | None = None self._configured_solver_type: str | None = None + self._fluid_render_clip_key: tuple[object, ...] | None = None + self._fluid_render_clip_z: torch.Tensor | None = None + self._fluid_render_dirty = False @property def solver_type(self) -> str | None: @@ -111,7 +117,192 @@ def sync_render_state(self, result: "dexsim.scene.Scene") -> None: raise RuntimeError( "Newton backend is unavailable for render-state synchronization." ) - backend.sync_to_dexsim(result.world) + fluids = getattr(self._manager, "_fluid_objects", {}) + clipped = [f for f in fluids.values() if f.cfg.render.clip_below_z is not None] + native_stream = producer_stream = None + if fluids and str(backend.state_0.particle_q.device).startswith("cuda"): + import torch + + # DexSim stages fluid positions asynchronously on CUDA's default + # stream. Order the Newton step and clipping before that copy, and + # complete the copy before a camera consumes the staged geometry. + device = torch.device(str(backend.state_0.particle_q.device)) + producer_stream = torch.cuda.current_stream(device) + warp_stream = wp.stream_to_torch(backend.state_0.particle_q.device) + if producer_stream.cuda_stream != warp_stream.cuda_stream: + producer_stream.wait_stream(warp_stream) + native_stream = torch.cuda.default_stream(device) + if self._fluid_render_dirty: + # A previous staged copy may still occupy the native double + # buffer. Drain it before submitting a newer explicit write; + # otherwise the renderer can silently discard the new state. + flush = getattr(result.world, "_flush_render_updates", None) + if not callable(flush): + raise RuntimeError( + "This DexSim build cannot synchronize fluid render updates." + ) + status = flush() + if status is not None and int(status) < 0: + raise RuntimeError("Could not drain previous fluid render updates.") + if not clipped: + self._fluid_render_clip_key = None + self._fluid_render_clip_z = None + positions = None + else: + ranges = [] + for fluid in clipped: + for entity in fluid._entities: + start = entity._physics_binding.particle_start + ranges.append( + ( + start, + start + entity.particle_count, + fluid.cfg.render.clip_below_z, + ) + ) + positions = self._clip_fluid_render_positions( + wp.to_torch(backend.state_0.particle_q), tuple(ranges) + ) + if native_stream is not None: + if native_stream.cuda_stream != producer_stream.cuda_stream: + native_stream.wait_stream(producer_stream) + try: + if positions is None: + backend.sync_to_dexsim(result.world) + else: + backend.render_sync.sync( + result.world, + backend.state_0.body_q, + wp.from_torch(positions, dtype=wp.vec3), + simulation_time=backend.sim_time, + ) + finally: + if native_stream is not None: + native_stream.synchronize() + self._fluid_render_dirty = False + + def _clip_fluid_render_positions( + self, + positions: torch.Tensor, + ranges: tuple[tuple[int, int, float], ...], + ) -> torch.Tensor: + """Create an owned render copy with one comparison and one selection. + + A full-buffer fluid uses a scalar cutoff. Mixed particle scenes cache + immutable per-particle cutoffs; changing layout, dtype, device or CUDA + stream replaces that cache. Each output owns fresh storage so a later + publication cannot overwrite data still consumed by the render bridge. + """ + import torch + + if len(ranges) == 1 and ranges[0][:2] == (0, len(positions)): + self._fluid_render_clip_key = None + self._fluid_render_clip_z = None + return torch.where( + positions[:, 2:3] < ranges[0][2], float("nan"), positions + ) + + # Cache reads and initialization stay on the same Torch stream. A stream + # change gets a new tensor, leaving the old allocation's lifetime ordered + # on its creation stream without adding a device-wide synchronization. + stream = ( + torch.cuda.current_stream(positions.device).cuda_stream + if positions.is_cuda + else None + ) + key = (positions.shape, positions.dtype, positions.device, stream, ranges) + if key != self._fluid_render_clip_key: + cutoffs = positions.new_full((len(positions), 1), float("-inf")) + for start, end, height in ranges: + cutoffs[start:end].fill_(height) + self._fluid_render_clip_z = cutoffs + self._fluid_render_clip_key = key + assert self._fluid_render_clip_z is not None + return torch.where( + positions[:, 2:3] < self._fluid_render_clip_z, float("nan"), positions + ) + + @property + def supports_dexuni_fluids(self) -> bool: + """Whether DexSim exposes the composed DexUni/DFSPH configuration.""" + if self.solver_type != "dexuni": + return False + from dexsim.engine.newton_physics import DexUniSolverCfg + + return "sph_cfg" in getattr(DexUniSolverCfg, "__dataclass_fields__", {}) + + @property + def supports_fluid_worlds(self) -> bool: + """Whether the loaded SPH artifact supports isolated worlds and reset.""" + if not self.supports_dexuni_fluids: + return False + from dexsim.engine.newton_physics.solver_runtime import ensure_solver_available + + solver = ensure_solver_available("sph") + return bool( + getattr(solver, "supports_world_isolation", False) + and getattr(solver, "supports_world_reset", False) + ) + + def invalidate_fluid_state(self, env_ids: Sequence[int] | None = None) -> None: + """Clear selected SPH history after an explicit state write. + + Args: + env_ids: Local worlds to clear, or all local worlds when omitted. + Shared boundary history remains unchanged. Legacy single-world + artifacts rebaseline their optional SDF history directly. + """ + from dexsim.engine.newton_physics.backend_registry import get_newton_backend + + backend = get_newton_backend(self._manager._world) + if backend is None or backend.solver_type != "dexuni": + raise RuntimeError("Prepared DexUni fluid backend required.") + solver = getattr(backend, "sph_solver", None) + if solver is None: + raise RuntimeError("Prepared DexUni SPH solver required.") + backend.invalidate_cuda_graph("fluid state write") + self._fluid_render_dirty = True + if getattr(solver, "supports_world_reset", False): + mask = [ + env_ids is None or i in env_ids + for i in range(backend.model.world_count) + ] + [False] + solver.reset( + backend._runtime.current_state, + world_mask=wp.array(mask, dtype=wp.bool, device=backend.model.device), + flags=0, + ) + return + boundary = solver.sdf_boundary + if boundary is not None and boundary._body_q_prev is not None: + boundary._body_q_prev.assign(backend.state_0.body_q) + + def fluid_diagnostics(self, fluid: FluidObject) -> dict[str, object]: + """Read detached native SPH diagnostics without publishing or stepping.""" + from dexsim.engine.newton_physics.backend_registry import get_newton_backend + import torch + + backend = get_newton_backend(self._manager._world) + solver = getattr(backend, "sph_solver", None) + if solver is None: + raise RuntimeError("Prepared DexUni SPH solver required.") + density = wp.to_torch(solver.particle_density) + values = [] + for entity in fluid._entities: + start = entity._physics_binding.particle_start + values.append(density[start : start + entity.particle_count].clone()) + return { + "particle_density": torch.stack(values), + "volume_map_enabled": solver.volume_map_boundary is not None, + "sdf_penalty_enabled": solver.sdf_boundary is not None, + "boundary_shape_count": ( + 0 + if solver.volume_map_boundary is None + else solver.volume_map_boundary.shape_count + ), + "rest_density_kg_m3": solver.rest_density, + "viscosity": solver.viscosity, + } def _step(self, physics_dt: float) -> None: """Let the manager batch window and offscreen consumers after stepping.""" @@ -126,6 +317,10 @@ def prepare_for_teardown(self) -> None: if self._runtime_device is not None and self._runtime_device.startswith("cuda"): wp.synchronize_device(self._runtime_device) + self._fluid_render_clip_key = None + self._fluid_render_clip_z = None + self._fluid_render_dirty = False + world = getattr(self._manager, "_world", None) if world is None: return diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index b61241f59..fc6d5e30a 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -16,6 +16,10 @@ from __future__ import annotations +from collections.abc import Mapping +from embodichain.lab.sim.cfg.fluid import FluidObjectCfg +from embodichain.lab.sim.objects.fluid import FluidObject + import os import gc import sys @@ -613,6 +617,7 @@ def _initialize_simulation( self._gizmos: dict[str, object] = {} self._markers: dict[str, _AxisMarkerGroup] = {} self._constraints: dict[str, RigidConstraint] = {} + self._fluid_objects: dict[str, FluidObject] = {} self._visual_materials: dict[str, VisualMaterial] = {} self._texture_cache: dict[str, torch.Tensor | list[torch.Tensor]] = {} @@ -989,6 +994,7 @@ def asset_uids(self) -> List[str]: uid_list.extend(list(self._rigid_objects.keys())) uid_list.extend(list(self._rigid_object_groups.keys())) uid_list.extend(list(self._deformable_objects.keys())) + uid_list.extend(list(self._fluid_objects.keys())) uid_list.extend(list(self._articulations.keys())) return uid_list @@ -1057,6 +1063,7 @@ def start_visualization(self) -> VisualizationRuntime | None: getattr(self, "_rigid_objects", {}), getattr(self, "_rigid_object_groups", {}), getattr(self, "_deformable_objects", {}), + getattr(self, "_fluid_objects", {}), getattr(self, "_articulations", {}), getattr(self, "_robots", {}), getattr(self, "_sensors", {}), @@ -2408,6 +2415,7 @@ def get_asset( | VisualObject | RigidObjectGroup | DeformableObject + | FluidObject | Articulation | None ): @@ -2436,6 +2444,8 @@ def get_asset( return self._rigid_object_groups[uid] if uid in self._deformable_objects: return self._deformable_objects[uid] + if uid in self._fluid_objects: + return self._fluid_objects[uid] if uid in self._articulations: return self._articulations[uid] @@ -2913,6 +2923,152 @@ def _declare_rigid_objects( self.prepare() return [item[3] for item in declarations] + def add_fluid_object(self, cfg: FluidObjectCfg) -> FluidObject: + """Declare a fixed-count Newton DFSPH fluid. + + Args: + cfg: Fluid source, initial state, physical and render properties. + + Returns: + A facade bound in place by ``prepare()``. + """ + from dexsim.spawn import FluidDesc, FluidRenderMode, FluidMeshSurfaceRenderDesc + from embodichain.lab.sim.spawn.descriptors import _pose_from_cfg + + if self.physics.name != "newton" or self.device.type != "cuda": + raise NotImplementedError("Fluid objects require Newton on CUDA.") + if ( + getattr(self, "_active_newton_solver_type", None) != "dexuni" + or self.sim_config.physics_cfg.requires_grad + ): + raise NotImplementedError( + "Fluid objects require non-differentiable DexUni." + ) + if not getattr(self.physics, "supports_dexuni_fluids", False): + raise NotImplementedError( + "Fluid objects require a DexSim build supporting DexUniSolverCfg.sph_cfg." + ) + if getattr(self, "_deformable_objects", {}): + raise NotImplementedError("DexUni fluids cannot coexist with deformables.") + if self.num_envs != 1 and not getattr( + self.physics, "supports_fluid_worlds", False + ): + raise NotImplementedError( + "Batch fluids require a DexSim SPH build with world isolation and selected-world reset." + ) + if self.spawn_result is not None: + raise NotImplementedError("Declare fluids before prepare().") + if not isinstance(cfg, FluidObjectCfg): + raise TypeError("Expected FluidObjectCfg.") + cfg = cfg.copy() + cfg.validate() + cfg.__post_init__() + if ( + self.sim_config.render_cfg.renderer == "hybrid" + or SimulationManager._get_process_renderer() == "hybrid" + ): + raise NotImplementedError( + "Newton fluid rendering requires renderer='fast-rt' or 'rt'. " + "Hybrid does not publish the OptiX liquid geometry to its Vulkan primary surface. " + "Use renderer='no-render' for physics-only runs. " + "Select FastRT or OfflineRT before creating the first World; " + "start a new process if Hybrid is already initialized." + ) + if cfg.render.mode == "none" and self.has_native_renderer: + raise NotImplementedError( + "Fluid mode='none' currently requires renderer='no-render'." + ) + if not cfg.uid or cfg.uid in self.asset_uids: + raise ValueError( + "Fluid uid must be nonempty and unique across scene assets." + ) + for other in self._fluid_objects.values(): + if (cfg.density, cfg.particle_spacing) != ( + other.cfg.density, + other.cfg.particle_spacing, + ): + raise ValueError("All fluids must share density and particle_spacing.") + configured = self.sim_config.physics_cfg.solver_cfg + configured = ( + configured.get("sph_cfg") + if isinstance(configured, Mapping) + else getattr(configured, "sph_cfg", None) + ) + from dexsim.engine.newton_physics import SPHSolverCfg + + defaults = SPHSolverCfg() + for key, value in ( + ("rest_density", cfg.density), + ("particle_diameter", cfg.particle_spacing), + ): + existing = ( + configured.get(key) + if isinstance(configured, Mapping) + else getattr(configured, key, None) + ) + if ( + existing is not None + and not np.isclose(existing, getattr(defaults, key), rtol=1e-7, atol=0) + and not np.isclose(existing, value, rtol=1e-7, atol=0) + ): + raise ValueError( + f"Fluid {key} conflicts with solver_cfg.sph_cfg.{key}." + ) + points = cfg.spawn.sample(cfg.particle_spacing) + pose = _pose_from_cfg(cfg) + if cfg.spawn.reference_uid is not None: + reference = self._rigid_objects.get(cfg.spawn.reference_uid) + if reference is None: + raise ValueError( + "Fluid reference_uid must name an already declared rigid object." + ) + pose = _pose_from_cfg(reference.cfg) @ pose + mesh_surface = FluidMeshSurfaceRenderDesc(**cfg.render.mesh_surface) + material = None + if self.has_native_renderer and cfg.render.material is not None: + material_cfg = cfg.render.material.copy() + material_cfg.uid = f"{cfg.uid}_fluid_material" + material = self.create_visual_material(material_cfg).inst.mat + descriptor = FluidDesc( + name=cfg.uid, + positions=points, + pose=pose, + density=cfg.density, + particle_spacing=cfg.particle_spacing, + particle_radius=cfg.particle_radius, + render_radius=cfg.render.render_radius, + render_mode=FluidRenderMode( + "particles" if cfg.render.mode == "none" else cfg.render.mode + ), + mesh_surface=mesh_surface, + render_material=material, + per_env=True, + ) + fluid = FluidObject(cfg, self.device) + fluid._initialize_spawn_source( + points, pose[:3, :3], on_write=self._invalidate_fluid_state + ) + self._spawn_scene.declare("fluid_object", cfg.uid, descriptor, facade=fluid) + self._fluid_objects[cfg.uid] = fluid + self.notify_visualization_topology_changed() + return fluid + + def _invalidate_fluid_state(self, env_ids: Sequence[int] | None = None) -> None: + self.physics.invalidate_fluid_state(env_ids=env_ids) + if self._render_state_published is not None: + self._render_state_published = False + + def get_fluid_object(self, uid: str) -> FluidObject | None: + """Return the fluid registered under ``uid``, or None.""" + return self._fluid_objects.get(uid) + + def get_fluid_diagnostics(self, uid: str) -> dict[str, object]: + """Return detached diagnostics for a prepared fluid without stepping.""" + fluid = self._fluid_objects[uid] + if fluid.is_declared: + raise RuntimeError("Fluid diagnostics require prepare().") + return self.physics.fluid_diagnostics(fluid) + def add_deformable_object(self, cfg: DeformableObjectCfg) -> DeformableObject: """Declare a volume or surface deformable in the scene. @@ -2950,6 +3106,8 @@ def add_deformable_object(self, cfg: DeformableObjectCfg) -> DeformableObject: "Newton deformable particle sets currently require a CUDA device." ) solver_type = self._active_newton_solver_type + if solver_type == "dexuni" and getattr(self, "_fluid_objects", {}): + raise NotImplementedError("DexUni fluids cannot coexist with deformables.") supported_solvers = {"auto", "dexuni", "xpbd", "semi_implicit", "vbd"} if solver_type not in supported_solvers: raise NotImplementedError( @@ -5034,6 +5192,9 @@ def reset_objects_state( for uid, rigid_obj_group in self._rigid_object_groups.items(): if uid not in excluded_uids: rigid_obj_group.reset(env_ids) + for uid, fluid_obj in self._fluid_objects.items(): + if uid not in excluded_uids: + fluid_obj.reset(env_ids) for uid, deformable_obj in self._deformable_objects.items(): if uid not in excluded_uids: deformable_obj.reset(env_ids) @@ -5219,6 +5380,7 @@ def _deferred_destroy(self) -> None: "_rigid_objects", "_rigid_object_groups", "_deformable_objects", + "_fluid_objects", "_articulations", "_robots", ): @@ -5281,6 +5443,7 @@ def _sever_wrapper_refs(obj_registry): _sever_wrapper_refs("_constraints") _sever_wrapper_refs("_rigid_object_groups") _sever_wrapper_refs("_deformable_objects") + _sever_wrapper_refs("_fluid_objects") _sever_wrapper_refs("_articulations") _sever_wrapper_refs("_robots") _sever_wrapper_refs("_sensors") diff --git a/embodichain/lab/sim/spawn/scene.py b/embodichain/lab/sim/spawn/scene.py index ed4bea6ad..bcd6392c0 100644 --- a/embodichain/lab/sim/spawn/scene.py +++ b/embodichain/lab/sim/spawn/scene.py @@ -37,6 +37,7 @@ "articulation", "soft_object", "cloth_object", + "fluid_object", ] @@ -175,7 +176,21 @@ def declare( self._assets[uid] = declaration return - if kind == "rigid_object_group": + if kind == "fluid_object": + declaration.descriptor = self.builder.add_fluid( + descriptor.name, + descriptor.positions, + pose=descriptor.pose, + density=descriptor.density, + particle_spacing=descriptor.particle_spacing, + particle_radius=descriptor.particle_radius, + render_radius=descriptor.render_radius, + render_mode=descriptor.render_mode, + mesh_surface=descriptor.mesh_surface, + material=descriptor.render_material, + per_env=descriptor.per_env, + ) + elif kind == "rigid_object_group": declaration.descriptor = tuple( self.builder.add_object(member) for member in descriptor ) @@ -261,7 +276,7 @@ def remove(self, uid: str) -> None: if getattr(declaration, "declaration_only", False): del self._assets[uid] return - if declaration.kind in {"soft_object", "cloth_object"}: + if declaration.kind in {"soft_object", "cloth_object", "fluid_object"}: raise NotImplementedError( "DexSim Spawn does not yet expose pending removal for " f"{declaration.kind.replace('_', ' ')}." diff --git a/embodichain/lab/visualization/scene_exporter.py b/embodichain/lab/visualization/scene_exporter.py index f3e08f2e8..1eb8ddaca 100644 --- a/embodichain/lab/visualization/scene_exporter.py +++ b/embodichain/lab/visualization/scene_exporter.py @@ -772,10 +772,29 @@ def _prepare_overlays(self, overlays: SceneOverlays | None) -> SceneOverlays: else None ) marker_frames = self._capture_axis_marker_overlays(reserved_frame_ids) - if overlays is None: - return SceneOverlays(frames=marker_frames) + overlays = overlays or SceneOverlays() + fluid_points = [] + for uid, fluid in getattr(self._sim, "_fluid_objects", {}).items(): + if fluid.is_declared or fluid.cfg.render.mode == "none": + continue + state = fluid.data.particle_pos_w + for env_id in self._env_ids: + points = state[env_id].detach().cpu().numpy().copy() + if fluid.cfg.render.clip_below_z is not None: + points = points[points[:, 2] >= fluid.cfg.render.clip_below_z] + fluid_points.append( + PointCloudOverlay( + overlay_id=f"fluid/{uid}/{env_id}", + points=points, + point_size=2 + * ( + fluid.cfg.render.render_radius + or fluid.cfg.particle_spacing / 2 + ), + ) + ) point_clouds: list[PointCloudOverlay] = [] - for point_cloud in overlays.point_clouds: + for point_cloud in overlays.point_clouds + tuple(fluid_points): point_count = point_cloud.points.shape[0] if point_count <= self.cfg.point_cloud_max_points: point_clouds.append(point_cloud) diff --git a/scripts/benchmark/scene/fluid_render_sync.py b/scripts/benchmark/scene/fluid_render_sync.py new file mode 100644 index 000000000..0e8a0b569 --- /dev/null +++ b/scripts/benchmark/scene/fluid_render_sync.py @@ -0,0 +1,489 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Compare fluid render clipping and optional native surface publication. + +Run: python -m scripts.benchmark.scene.fluid_render_sync --device cuda:0 --native +Inputs are device resident. Physics stepping is excluded. The optional native +cases measure bridge publication and a complete camera frame separately. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable +from datetime import datetime +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +import platform +import statistics +import time +from types import SimpleNamespace + +import numpy as np +import psutil +import torch +import warp as wp + +from embodichain.lab.sim.physics.newton import NewtonPhysicsBackend + + +def _legacy_clip( + positions: torch.Tensor, ranges: tuple[tuple[int, int, float], ...] +) -> torch.Tensor: + """Reproduce the original clone and per-fluid boolean assignment.""" + output = positions.clone() + for start, end, height in ranges: + selected = output[start:end] + selected[selected[:, 2] < height] = float("nan") + return output + + +def _synchronize(device: torch.device) -> None: + """Complete all participating CUDA streams before recording wall time.""" + if device.type == "cuda": + torch.cuda.synchronize(device) + + +def _measure( + operation: Callable[[], object], device: torch.device, args: argparse.Namespace +) -> dict[str, object]: + """Measure steady-state completed calls and a separate retained output.""" + cuda = device.type == "cuda" + _synchronize(device) + initial = torch.cuda.memory_allocated(device) if cuda else 0 + start = time.perf_counter() + result = operation() + _synchronize(device) + first_call_ms = (time.perf_counter() - start) * 1000 + del result + for _ in range(args.warmup): + result = operation() + _synchronize(device) + del result + persistent = torch.cuda.memory_allocated(device) - initial if cuda else None + samples = [] + for _ in range(args.repeats): + _synchronize(device) + start = time.perf_counter() + result = operation() + _synchronize(device) + samples.append((time.perf_counter() - start) * 1000) + del result + + process = psutil.Process() + cpu_before = process.memory_info().rss + gpu_before = torch.cuda.memory_allocated(device) if cuda else 0 + if cuda: + torch.cuda.reset_peak_memory_stats(device) + result = operation() + _synchronize(device) + mib = 1024**2 + memory = { + "cpu_delta_mb": (process.memory_info().rss - cpu_before) / mib, + "gpu_delta_mb": ( + (torch.cuda.memory_allocated(device) - gpu_before) / mib if cuda else None + ), + "peak_gpu_mb": torch.cuda.max_memory_allocated(device) / mib if cuda else None, + "extra_peak_gpu_mb": ( + (torch.cuda.max_memory_allocated(device) - gpu_before) / mib + if cuda + else None + ), + "warmup_gpu_delta_mb": persistent / mib if persistent is not None else None, + } + del result + return { + "cost_time_ms": statistics.median(samples), + "p95_ms": float(np.percentile(samples, 95)), + "first_call_ms": first_call_ms, + **memory, + } + + +def _paired_latencies( + operations: dict[str, Callable[[], object]], + device: torch.device, + args: argparse.Namespace, +) -> dict[str, dict[str, float]]: + """Alternate equivalent calls to reduce GPU clock and thermal-order bias.""" + samples: dict[str, list[float]] = {name: [] for name in operations} + order = list(operations) + for index in range(args.warmup + args.repeats): + for name in order if index % 2 == 0 else reversed(order): + _synchronize(device) + start = time.perf_counter() + result = operations[name]() + _synchronize(device) + if index >= args.warmup: + samples[name].append((time.perf_counter() - start) * 1000) + del result + return { + name: { + "cost_time_ms": statistics.median(values), + "p95_ms": float(np.percentile(values, 95)), + } + for name, values in samples.items() + } + + +def _benchmark_clipping( + count: int, fluids: int, device: torch.device, args: argparse.Namespace +) -> tuple[list[dict[str, object]], list[dict[str, object]]]: + """Compare equivalent clipping masks, including partial particle ranges.""" + generator = torch.Generator(device=device).manual_seed(42) + positions = torch.randn((count, 3), device=device, generator=generator) + before = positions.clone() + # Multiple ranges leave gaps to exercise particles outside the clipped fluids. + ranges = tuple( + ( + count * i // fluids, + count * (i + 1) // fluids - (1 if fluids > 1 else 0), + 0.1 * i, + ) + for i in range(fluids) + ) + backend = NewtonPhysicsBackend(SimpleNamespace()) + operations = { + "legacy": lambda: _legacy_clip(positions, ranges), + "vectorized": lambda: backend._clip_fluid_render_positions(positions, ranges), + } + expected = positions.clone() + heights = torch.full((count,), float("-inf"), device=device) + for start, end, height in ranges: + heights[start:end] = height + expected[positions[:, 2] < heights] = float("nan") + perf_rows, metric_rows = [], [] + for algorithm, operation in operations.items(): + # Warmup is part of _measure; correctness checks must not prefill its cache. + perf = _measure(operation, device, args) + cutoff = backend._fluid_render_clip_z if algorithm == "vectorized" else None + perf["cutoff_cache_mb"] = ( + cutoff.numel() * cutoff.element_size() / 1024**2 + if cutoff is not None + else 0.0 + ) + actual = operation() + torch.testing.assert_close(actual, expected, equal_nan=True, rtol=0, atol=0) + torch.testing.assert_close(positions, before, rtol=0, atol=0) + identity = { + "scope": "clipping", + "particles": count, + "fluids": fluids, + "algorithm": algorithm, + } + perf_rows.append({**identity, **perf}) + metric_rows.append({**identity, "success_rate": 1.0, "source_unchanged": True}) + del actual + paired = _paired_latencies(operations, device, args) + for row in perf_rows: + row.update(paired[str(row["algorithm"])]) + return perf_rows, metric_rows + + +def _benchmark_native( + count: int, device: torch.device, args: argparse.Namespace +) -> tuple[list[dict[str, object]], list[dict[str, object]]]: + """Measure full native surface publication with identical quality settings.""" + from dexsim.engine.newton_physics.backend_registry import get_newton_backend + from embodichain.lab.sim import SimulationManager, SimulationManagerCfg + from embodichain.lab.sim.cfg import ( + FluidObjectCfg, + FluidRenderCfg, + FluidSpawnCfg, + NewtonPhysicsCfg, + RenderCfg, + ) + from embodichain.lab.sim.sensors import CameraCfg + + side = int(np.ceil(count ** (1 / 3))) + points = np.column_stack(np.unravel_index(np.arange(count), (side,) * 3)) + points = np.asarray((points - side / 2) * 0.003, dtype=np.float32) + sim = SimulationManager( + SimulationManagerCfg( + headless=True, + device=str(device), + num_envs=1, + render_cfg=RenderCfg(renderer="fast-rt"), + physics_cfg=NewtonPhysicsCfg( + num_substeps=1, + collision_cfg=None, + solver_cfg={ + "solver_type": "dexuni", + "sph_cfg": { + "sph_method": "dfsph", + "volume_map_boundary_enabled": False, + }, + }, + ), + ) + ) + fluid = camera = None + try: + fluid = sim.add_fluid_object( + FluidObjectCfg( + uid="water", + particle_spacing=0.003, + spawn=FluidSpawnCfg(positions=points), + render=FluidRenderCfg( + mode="mesh_surface", + clip_below_z=0.0, + mesh_surface={"gpu_enabled": True, "max_res": 128}, + ), + ) + ) + camera = sim.add_sensor( + CameraCfg( + uid="benchmark_camera", + width=320, + height=240, + intrinsics=(350, 350, 160, 120), + extrinsics=CameraCfg.ExtrinsicsCfg( + eye=(0.18, -0.24, 0.28), target=(0, 0, 0), up=(0, 0, 1) + ), + ) + ) + sim.prepare() + native = get_newton_backend(sim.spawn_result.world) + positions = wp.to_torch(native.state_0.particle_q) + before = positions.clone() + + def legacy() -> None: + # Include the manager/backend lookup and range traversal performed + # by the previous public sync_render_state implementation. + result = sim.spawn_result + current = get_newton_backend(result.world) + clipped_fluids = [ + item + for item in sim._fluid_objects.values() + if item.cfg.render.clip_below_z is not None + ] + producer = torch.cuda.current_stream(device) + warp_stream = wp.stream_to_torch(current.state_0.particle_q.device) + if producer.cuda_stream != warp_stream.cuda_stream: + producer.wait_stream(warp_stream) + clipped = wp.to_torch(current.state_0.particle_q).clone() + for item in clipped_fluids: + for entity in item._entities: + start = entity._physics_binding.particle_start + selected = clipped[start : start + entity.particle_count] + selected[selected[:, 2] < item.cfg.render.clip_below_z] = float( + "nan" + ) + staging = torch.cuda.default_stream(device) + if staging.cuda_stream != producer.cuda_stream: + staging.wait_stream(producer) + try: + current.render_sync.sync( + result.world, + current.state_0.body_q, + wp.from_torch(clipped, dtype=wp.vec3), + simulation_time=current.sim_time, + ) + finally: + staging.synchronize() + if sim._render_state_published is not None: + sim._render_state_published = True + + perf_rows, metric_rows = [], [] + for scope in ("native bridge publication", "native camera frame"): + operations = {} + for algorithm, publish in { + "legacy": legacy, + "vectorized": sim.sync_render_state, + }.items(): + + def operation(publish: Callable[[], None] = publish) -> object: + publish() + if scope == "native camera frame": + sim._world.render_camera_group([camera.group_id]) + camera.update(fetch_only=True) + return camera.get_data()["color"] + return None + + operations[algorithm] = operation + perf = _measure(operation, device, args) + cutoff = ( + sim.physics._fluid_render_clip_z + if algorithm == "vectorized" + else None + ) + perf["cutoff_cache_mb"] = ( + cutoff.numel() * cutoff.element_size() / 1024**2 + if cutoff is not None + else 0.0 + ) + torch.testing.assert_close(positions, before, rtol=0, atol=0) + identity = { + "scope": scope, + "particles": count, + "fluids": 1, + "algorithm": algorithm, + } + perf_rows.append({**identity, **perf}) + metric_rows.append( + {**identity, "success_rate": 1.0, "source_unchanged": True} + ) + paired = _paired_latencies(operations, device, args) + for row in perf_rows: + if row["scope"] == scope: + row.update(paired[str(row["algorithm"])]) + return perf_rows, metric_rows + finally: + fluid = camera = None + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() + + +def _write_report( + path: Path, + perf_rows: list[dict[str, object]], + metric_rows: list[dict[str, object]], + notes: list[str], +) -> None: + """Write exactly three tables, including every evaluated algorithm.""" + leaderboard = [] + for algorithm in sorted({str(row["algorithm"]) for row in metric_rows}): + results = [row for row in metric_rows if row["algorithm"] == algorithm] + leaderboard.append( + { + "algorithm": algorithm, + "overall_success_rate": statistics.mean( + float(row["success_rate"]) for row in results + ), + "cases": len(results), + } + ) + leaderboard.sort(key=lambda row: -float(row["overall_success_rate"])) + leaderboard = [{"rank": index, **row} for index, row in enumerate(leaderboard, 1)] + lines = ["# Fluid render synchronization benchmark", "", *notes, ""] + for title, rows in ( + ("Time & Memory", perf_rows), + ("Success & Other Metrics", metric_rows), + ("Leaderboard", leaderboard), + ): + lines.extend([f"## {title}", ""]) + if rows: + headers = list(rows[0]) + lines.extend( + [ + "| " + " | ".join(headers) + " |", + "| " + " | ".join(["---"] * len(headers)) + " |", + ] + ) + for row in rows: + values = [ + f"{row[key]:.6f}" if isinstance(row[key], float) else str(row[key]) + for key in headers + ] + lines.append("| " + " | ".join(values) + " |") + else: + lines.extend(["| status |", "| --- |", "| skipped |"]) + lines.append("") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def run_all_benchmarks(args: argparse.Namespace) -> None: + """Run clipping/native scopes and save one reproducible Markdown report.""" + device = torch.device(args.device) + notes = [ + f"- Generated: {datetime.now().isoformat(timespec='seconds')}", + f"- CPU: {platform.processor()}; Torch {torch.__version__}; Warp {wp.__version__}", + f"- Device: {device}; dtype=float32; seed=42; CPU threads={torch.get_num_threads()}", + f"- Warmup={args.warmup}; repeats={args.repeats}; median/p95 completed-call wall time.", + "- Latency uses paired alternating implementation order. Memory/cache statistics use a separate warmed pass.", + "- First-call timing precedes warmup and includes cutoff-cache setup; process/World startup and input generation are excluded.", + "- Device-resident inputs; device-wide CUDA synchronization; no physics steps. Native camera scope includes 320x240 FastRT rendering and image fetch.", + "- CPU memory is retained RSS delta. GPU memory covers the Torch allocator only, excluding native/Warp allocations.", + "- Incremental peak is measured separately after warmup. Cutoff cache reports logical buffer bytes; warmup GPU delta may include unrelated lazy allocation/release.", + "- Clipping quality checks exact positions/NaN masks and source preservation. Native success checks completed publication and unchanged physics; camera pixels are covered by test_fluid_visibility.py.", + "- Native surface settings: FastRT, spp=1, mesh_surface GPU enabled, max_res=128; other RenderCfg/FluidMeshSurfaceRenderDesc defaults.", + ] + try: + notes.append(f"- DexSim distribution: {version('dexsim_engine')}") + except PackageNotFoundError: + notes.append("- DexSim distribution metadata unavailable.") + notes.extend(f"- {note}" for note in args.note) + perf_rows, metric_rows = [], [] + if device.type == "cuda" and not torch.cuda.is_available(): + notes.append("- Skipped: CUDA is unavailable.") + else: + if device.type == "cuda": + notes.append(f"- GPU: {torch.cuda.get_device_name(device)}") + with torch.no_grad(): + for count in args.sizes: + for fluids in args.fluid_counts: + perf, metrics = _benchmark_clipping(count, fluids, device, args) + perf_rows.extend(perf) + metric_rows.extend(metrics) + if args.native and device.type == "cuda": + try: + from dexsim.engine.newton_physics import DexUniSolverCfg + except ImportError: + DexUniSolverCfg = None + if "sph_cfg" not in getattr( + DexUniSolverCfg, "__dataclass_fields__", {} + ): + notes.append( + "- Native publication skipped: DexUni/DFSPH is unavailable." + ) + else: + for count in args.native_sizes: + perf, metrics = _benchmark_native(count, device, args) + perf_rows.extend(perf) + metric_rows.extend(metrics) + elif args.native: + notes.append("- Native publication skipped: CUDA is required.") + _write_report(args.output, perf_rows, metric_rows, notes) + for row in perf_rows: + print( + f"{row['scope']}: n={row['particles']} fluids={row['fluids']} " + f"{row['algorithm']} {row['cost_time_ms']:.6f} ms" + ) + print(f"Markdown report saved: {args.output}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--device", default="cuda:0") + parser.add_argument( + "--sizes", nargs="+", type=int, default=[1728, 76607, 1_000_000] + ) + parser.add_argument("--fluid-counts", nargs="+", type=int, default=[1, 8]) + parser.add_argument("--native", action="store_true") + parser.add_argument("--native-sizes", nargs="+", type=int, default=[1728, 76607]) + parser.add_argument("--warmup", type=int, default=10) + parser.add_argument("--repeats", type=int, default=60) + parser.add_argument( + "--note", action="append", default=[], help="Additional measurement conditions" + ) + parser.add_argument( + "--output", + type=Path, + default=Path("outputs/benchmarks") + / f"fluid_render_sync_{datetime.now():%Y%m%d_%H%M%S}.md", + ) + args = parser.parse_args() + if ( + args.warmup < 1 + or args.repeats < 1 + or min(args.sizes + args.native_sizes + args.fluid_counts) < 1 + ): + parser.error("sizes, fluid counts, warmup and repeats must be positive") + if min(args.sizes) < max(args.fluid_counts): + parser.error("particle counts must be at least the number of fluid ranges") + run_all_benchmarks(args) diff --git a/scripts/tutorials/sim/create_fluid.py b/scripts/tutorials/sim/create_fluid.py new file mode 100644 index 000000000..9e837b379 --- /dev/null +++ b/scripts/tutorials/sim/create_fluid.py @@ -0,0 +1,160 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Drop a procedural water block into a box using DexUni and DFSPH. + +Run ``python scripts/tutorials/sim/create_fluid.py`` for a reconstructed mesh surface, or +add ``--headless --renderer no-render`` for physics only. The scene uses five +primitive walls and needs no task assets, robot, Gym or Task Program. +""" + +from __future__ import annotations + +import argparse + +from embodichain.cli.sim import add_sim_args_to_parser + +__all__ = ["build_parser", "main"] + + +def build_parser() -> argparse.ArgumentParser: + """Return simulation options without loading or initializing the engine.""" + parser = argparse.ArgumentParser(description=__doc__) + add_sim_args_to_parser(parser) + parser.set_defaults(device="cuda", physics="newton", renderer="fast-rt") + parser.add_argument("--steps", type=int, default=480) + return parser + + +def main(args: argparse.Namespace | None = None) -> None: + """Create, step, inspect and reset one fluid per environment. + + Args: + args: Parsed options; None reads the command line. + """ + parser = build_parser() + if args is None: + args = parser.parse_args() + if args.physics != "newton" or not str(args.device).startswith("cuda"): + parser.error("Fluids require --physics newton and a CUDA device.") + if args.num_envs < 1: + parser.error("--num-envs must be positive.") + if args.renderer not in {"fast-rt", "rt", "no-render"}: + parser.error("Choose --renderer fast-rt, rt or no-render for fluids.") + if args.steps <= 0: + parser.error("--steps must be positive.") + + import torch + import trimesh + + from embodichain.lab.sim import SimulationManager, SimulationManagerCfg + from embodichain.lab.sim.cfg import ( + FluidObjectCfg, + FluidRenderCfg, + FluidSpawnCfg, + NewtonPhysicsCfg, + RenderCfg, + RigidObjectCfg, + ) + from embodichain.lab.sim.shapes import MeshCfg, MeshCollisionCfg + from embodichain.lab.visualization import visualization_cfg_from_args + + spacing = 0.01 + sim = SimulationManager( + SimulationManagerCfg( + device=args.device, + num_envs=args.num_envs, + headless=args.headless, + gpu_id=args.gpu_id, + physics_dt=1 / 240, + render_cfg=RenderCfg(renderer=args.renderer), + physics_cfg=NewtonPhysicsCfg( + num_substeps=4, + collision_cfg=None, + solver_cfg={ + "solver_type": "dexuni", + "sph_cfg": { + "sph_method": "dfsph", + "particle_diameter": spacing, + "particle_support_radius": 2.25 * spacing, + }, + }, + ), + visualization=visualization_cfg_from_args(args), + ) + ) + fluid = None + try: + # SPH queries texture SDFs; render boxes alone are not fluid boundaries. + for uid, position, size in ( + ("floor", (0, 0, 0), (0.32, 0.32, 0.02)), + ("left", (-0.16, 0, 0.15), (0.02, 0.32, 0.3)), + ("right", (0.16, 0, 0.15), (0.02, 0.32, 0.3)), + ("front", (0, -0.16, 0.15), (0.32, 0.02, 0.3)), + ("back", (0, 0.16, 0.15), (0.32, 0.02, 0.3)), + ): + mesh = trimesh.creation.box(extents=size) + sim.add_rigid_object( + RigidObjectCfg( + uid=uid, + body_type="static", + init_pos=position, + shape=MeshCfg( + vertices=mesh.vertices, + triangles=mesh.faces, + collision=MeshCollisionCfg(approximation="sdf"), + ), + ) + ) + fluid = sim.add_fluid_object( + FluidObjectCfg( + uid="water", + particle_spacing=spacing, + init_pos=(0, 0, 0.16), + spawn=FluidSpawnCfg(kind="box", size=(0.12, 0.12, 0.12)), + render=FluidRenderCfg( + mode="none" if args.renderer == "no-render" else "mesh_surface" + ), + ) + ) + sim.prepare() + initial = fluid.data.default_particle_state_w + print( + f"Created {fluid.num_particles} fluid particles per environment " + f"in {fluid.num_instances} environments." + ) + if not args.headless: + sim.open_window() + for _ in range(args.steps): + sim.update(step=1) + state = fluid.data.particle_state_w + if not bool(torch.isfinite(state).all()): + raise RuntimeError("Fluid state is not finite.") + print( + f"Mean particle height per environment after stepping: " + f"{state[..., 2].mean(dim=1).tolist()} m" + ) + fluid.reset() + torch.testing.assert_close(fluid.data.particle_state_w, initial) + print("Restored the initial fluid state without advancing physics.") + finally: + fluid = None + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() + + +if __name__ == "__main__": + main() diff --git a/tests/compute/test_particles.py b/tests/compute/test_particles.py new file mode 100644 index 000000000..04855da01 --- /dev/null +++ b/tests/compute/test_particles.py @@ -0,0 +1,133 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Numerical volume sampling for file-free particle initialization.""" + +from __future__ import annotations + +import numpy as np +import pytest +from embodichain.compute.geometry.particles import sample_volume_particles + +pytestmark = pytest.mark.no_sim + + +def test_constant_revolved_profile_matches_cylinder(): + actual = sample_volume_particles( + "revolved_volume", + 0.004, + profile=[[-0.02, 0.015], [0.02, 0.015]], + clearance=0.001, + ) + expected = sample_volume_particles( + "cylinder", 0.004, radius=0.015, height=0.04, clearance=0.001 + ) + np.testing.assert_array_equal(actual, expected) + + +def test_tapered_fill_volume_and_mass_converge_with_resolution(): + # A linear wall allows an independent integral of its inward offset. + profile = [[-0.04, 0.02], [0.04, 0.03]] + clearance = 0.002 + fill_level = 0.8 + slope = (0.03 - 0.02) / 0.08 + lower_z = -0.04 + clearance + surface_z = -0.04 + fill_level * 0.08 + lower_radius = 0.02 + slope * clearance - clearance * np.sqrt(1 + slope**2) + depth = surface_z - lower_z + expected_volume = np.pi * ( + lower_radius**2 * depth + + lower_radius * slope * depth**2 + + slope**2 * depth**3 / 3 + ) + counts = [] + for spacing in (0.002, 0.001): + points = sample_volume_particles( + "revolved_volume", + spacing, + profile=profile, + clearance=clearance, + fill_level=fill_level, + ) + radius = np.linalg.norm(points[:, :2], axis=1) + wall_distance = (0.02 + slope * (points[:, 2] + 0.04) - radius) / np.sqrt( + 1 + slope**2 + ) + assert np.all(wall_distance >= clearance - 1e-8) + assert np.all(points[:, 2] > lower_z) + assert np.all(points[:, 2] < surface_z) + assert len(points) * spacing**3 == pytest.approx(expected_volume, rel=0.025) + counts.append(len(points)) + assert counts[1] / counts[0] == pytest.approx(8, rel=0.025) + + +def test_lower_fill_level_keeps_lattice_origin_and_has_no_solid_free_surface_margin(): + kwargs = dict(size=(0.08, 0.08, 0.1), clearance=0.02) + full = sample_volume_particles("box", 0.01, **kwargs) + half = sample_volume_particles("box", 0.01, fill_level=0.5, **kwargs) + np.testing.assert_array_equal(half, full[full[:, 2] < 0]) + assert half[:, 2].max() == pytest.approx(-0.005) + + +def test_multi_segment_profile_is_deterministic_and_can_narrow_again(): + profile = [[-0.03, 0.02], [0.0, 0.01], [0.03, 0.025]] + kwargs = dict(profile=profile, clearance=0.001) + points = sample_volume_particles("revolved_volume", 0.004, **kwargs) + np.testing.assert_array_equal( + points, sample_volume_particles("revolved_volume", 0.004, **kwargs) + ) + assert np.all( + np.linalg.norm(points[:, :2], axis=1) + < np.interp(points[:, 2], np.array(profile)[:, 0], np.array(profile)[:, 1]) + ) + assert points.flags.c_contiguous + + +@pytest.mark.parametrize( + "profile", + [ + None, + [], + [[0, 1]], + [[0, 1], [0, 2]], + [[1, 1], [0, 2]], + [[0, -1], [1, 1]], + [[0, 0], [1, 0]], + [[0, 1], [float("nan"), 1]], + [[0, 1, 2], [1, 1, 2]], + ], +) +def test_invalid_profiles_fail_before_grid_allocation(profile): + with pytest.raises(ValueError, match="profile"): + sample_volume_particles("revolved_volume", 0.001, profile=profile) + + +@pytest.mark.parametrize("fill_level", [0, -0.1, 1.01, float("inf"), float("nan")]) +def test_invalid_fill_levels_are_rejected(fill_level): + with pytest.raises(ValueError, match="fill_level"): + sample_volume_particles("box", 0.01, fill_level=fill_level) + + +def test_profile_source_obeys_candidate_limit_before_allocation(): + with pytest.raises(ValueError, match="max_particles"): + sample_volume_particles( + "revolved_volume", 1e-9, profile=[[0, 1], [1, 1]], max_particles=100 + ) + + +def test_profile_on_another_volume_is_not_ignored(): + with pytest.raises(ValueError, match="profile"): + sample_volume_particles("box", 0.01, profile=[[0, 1], [1, 1]]) diff --git a/tests/sim/objects/test_fluid_object.py b/tests/sim/objects/test_fluid_object.py new file mode 100644 index 000000000..b02033f7f --- /dev/null +++ b/tests/sim/objects/test_fluid_object.py @@ -0,0 +1,218 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Fluid state contracts exercised without native simulation.""" + +from __future__ import annotations + +from types import SimpleNamespace +import numpy as np +import pytest +import torch +from embodichain.lab.sim.cfg import FluidObjectCfg, FluidSpawnCfg +from embodichain.lab.sim.objects.fluid import FluidObject + +pytestmark = pytest.mark.no_sim + + +class ParticleBatch: + def __init__(self, scene, entities): + self.scene = scene + self.ids = [e.index for e in entities] + + def fetch_particle_positions(self, out): + out.copy_(self.scene.state[self.ids, :, :3].reshape(-1, 3)) + + def fetch_particle_velocities(self, out): + out.copy_(self.scene.state[self.ids, :, 3:].reshape(-1, 3)) + + def apply_particle_positions(self, value): + self.scene.state[self.ids, :, :3] = value.reshape(len(self.ids), -1, 3) + + def apply_particle_velocities(self, value): + self.scene.state[self.ids, :, 3:] = value.reshape(len(self.ids), -1, 3) + + +@pytest.fixture +def fluid(): + points = np.array([[0.01, 0, 0], [0, 0.01, 0]], dtype=np.float32) + scene = SimpleNamespace(state=torch.zeros(2, 2, 6)) + scene.state[:, :, :3] = torch.from_numpy(points) + scene.create_particle_set_batch = lambda entities: ParticleBatch(scene, entities) + result = FluidObject( + FluidObjectCfg(uid="water", spawn=FluidSpawnCfg(positions=points)), + torch.device("cpu"), + ) + result._initialize_spawn_source( + points, np.eye(3, dtype=np.float32), on_write=lambda ids: None + ) + result._initialize_spawn_declaration(2) + result.attach_spawn_handles( + [SimpleNamespace(index=i, particle_count=2) for i in range(2)] + ) + result.bind_spawn(scene) + return result + + +def test_reads_do_not_alias_live_or_default_state(fluid): + before = fluid.data.particle_state_w + detached = fluid.data.particle_pos_w + detached.fill_(12) + torch.testing.assert_close(fluid.data.particle_state_w, before) + default = fluid.data.default_particle_state_w + default.fill_(13) + torch.testing.assert_close(fluid.data.default_particle_state_w, before) + + +def test_selected_write_and_reset_preserve_other_instance(fluid): + original = fluid.data.particle_state_w + changed = original[[1]].clone() + 4 + fluid.write_particle_state_to_sim(changed, [1]) + torch.testing.assert_close(fluid.data.particle_state_w[0], original[0]) + torch.testing.assert_close(fluid.data.particle_state_w[1], changed[0]) + fluid.reset([1]) + torch.testing.assert_close(fluid.data.particle_state_w, original) + + +def test_reset_to_pose_rotates_template_without_changing_default(fluid): + original = fluid.data.default_particle_state_w + q = 2**-0.5 + fluid.reset_to_pose(torch.tensor([[1, 2, 3, 0, 0, q, q]]), [1]) + expected = torch.tensor([[1, 2.01, 3], [0.99, 2, 3]]) + torch.testing.assert_close(fluid.data.particle_pos_w[1], expected) + torch.testing.assert_close(fluid.data.default_particle_state_w, original) + + +@pytest.mark.parametrize("ids", [[0, 0], [-1], [2], [0.5]]) +def test_invalid_selection_does_not_mutate(fluid, ids): + before = fluid.data.particle_state_w + with pytest.raises(ValueError): + fluid.write_particle_state_to_sim(torch.zeros(len(ids), 2, 6), ids) + torch.testing.assert_close(fluid.data.particle_state_w, before) + + +def test_empty_selection_and_invalid_state(fluid): + fluid.write_particle_state_to_sim(torch.empty(0, 2, 6), []) + with pytest.raises(ValueError, match="finite"): + fluid.write_particle_state_to_sim(torch.full((1, 2, 6), float("nan")), [0]) + with pytest.raises(ValueError, match="shape"): + fluid.write_particle_state_to_sim(torch.zeros(1, 3, 6), [0]) + + +def test_integer_initial_velocity_is_normalized(): + points = np.array([[0, 0, 0]], dtype=np.float32) + scene = SimpleNamespace(state=torch.zeros(1, 1, 6)) + scene.create_particle_set_batch = lambda entities: ParticleBatch(scene, entities) + fluid = FluidObject( + FluidObjectCfg( + uid="water", + spawn=FluidSpawnCfg(positions=points, initial_velocity=(1, 0, 0)), + ), + torch.device("cpu"), + ) + fluid._initialize_spawn_source( + points, np.eye(3, dtype=np.float32), on_write=lambda ids: None + ) + fluid._initialize_spawn_declaration(1) + fluid.attach_spawn_handles([SimpleNamespace(index=0, particle_count=1)]) + fluid.bind_spawn(scene) + torch.testing.assert_close(fluid.data.particle_vel_w, torch.tensor([[[1.0, 0, 0]]])) + fluid.reset_to_pose(torch.tensor([[0.0, 0, 0, 0, 0, 0, 1]])) + torch.testing.assert_close(fluid.data.particle_vel_w, torch.tensor([[[1.0, 0, 0]]])) + + +@pytest.mark.parametrize("inside_frame", [False, True]) +def test_fluid_write_respects_render_frame_cache_scope(inside_frame): + from embodichain.lab.sim import SimulationManager + + calls = [] + sim = SimpleNamespace( + physics=SimpleNamespace(invalidate_fluid_state=lambda **kwargs: None), + _render_state_published=True if inside_frame else None, + sync_render_state=lambda: calls.append("publish"), + ) + SimulationManager._invalidate_fluid_state(sim) + SimulationManager._ensure_render_state(sim) + SimulationManager._ensure_render_state(sim) + assert len(calls) == (1 if inside_frame else 2) + assert sim._render_state_published is (True if inside_frame else None) + + +def test_constructor_only_copies_config_without_sampling(monkeypatch): + from unittest.mock import Mock + + sample = Mock(side_effect=AssertionError("Construction must not sample.")) + monkeypatch.setattr(FluidSpawnCfg, "sample", sample) + cfg = FluidObjectCfg(uid="water", spawn=FluidSpawnCfg(kind="box")) + + fluid = FluidObject(cfg) + cfg.spawn.size = (9.0, 9.0, 9.0) + + assert fluid.device == torch.device("cpu") + assert fluid.is_declared + assert fluid.cfg.spawn.size == (0.05, 0.05, 0.05) + sample.assert_not_called() + with pytest.raises(RuntimeError, match="source"): + _ = fluid.num_particles + with pytest.raises(RuntimeError, match="registered"): + _ = fluid.num_instances + + +def test_binding_requires_a_resolved_source(): + from unittest.mock import Mock + + fluid = FluidObject(FluidObjectCfg(uid="water")) + fluid._initialize_spawn_declaration(1) + fluid.attach_spawn_handles([SimpleNamespace(index=0, particle_count=1)]) + scene = SimpleNamespace(create_particle_set_batch=Mock()) + + with pytest.raises(RuntimeError, match="source"): + fluid.bind_spawn(scene) + + scene.create_particle_set_batch.assert_not_called() + + +def test_source_cache_is_independent_and_rotates_initial_velocity(): + points = np.array([[0.01, 0.0, 0.0]], dtype=np.float32) + rotation = np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) + fluid = FluidObject( + FluidObjectCfg( + uid="water", + init_rot=(0.0, 0.0, 90.0), + spawn=FluidSpawnCfg(positions=points, initial_velocity=(1, 0, 0)), + ) + ) + writes = [] + fluid._initialize_spawn_source(points, rotation, on_write=writes.append) + fluid._initialize_spawn_declaration(1) + scene = SimpleNamespace(state=torch.tensor([[[0.0, 0.01, 0.0, 0.0, 0.0, 0.0]]])) + scene.create_particle_set_batch = lambda entities: ParticleBatch(scene, entities) + fluid.attach_spawn_handles([SimpleNamespace(index=0, particle_count=1)]) + points.fill(99) + rotation.fill(0) + + fluid.bind_spawn(scene) + + torch.testing.assert_close( + fluid.data.particle_vel_w, torch.tensor([[[0.0, 1.0, 0.0]]]) + ) + fluid.reset_to_pose(torch.tensor([[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]])) + torch.testing.assert_close( + fluid.data.particle_pos_w, torch.tensor([[[0.01, 0.0, 0.0]]]) + ) + assert writes == [[0]] + with pytest.raises(RuntimeError, match="already"): + fluid._initialize_spawn_source(points, rotation, on_write=writes.append) diff --git a/tests/sim/objects/test_fluid_runtime.py b/tests/sim/objects/test_fluid_runtime.py new file mode 100644 index 000000000..20e982252 --- /dev/null +++ b/tests/sim/objects/test_fluid_runtime.py @@ -0,0 +1,214 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Small CUDA integration test of the public fluid lifecycle.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest +import torch +from embodichain.lab.sim import SimulationManager, SimulationManagerCfg +from embodichain.lab.sim.cfg import ( + FluidObjectCfg, + FluidSpawnCfg, + FluidRenderCfg, + NewtonPhysicsCfg, + RenderCfg, +) + +pytestmark = [pytest.mark.gpu, pytest.mark.subprocess_sim] + + +class TestFluidRuntime: + @pytest.mark.parametrize( + "num_envs,automatic", + [(1, False), (2, False), (1, True)], + ids=["single-explicit", "two-explicit", "single-default"], + ) + def test_prepare_write_reset_and_repeat( + self, tmp_path: Path, num_envs: int, automatic: bool + ) -> None: + from dexsim.engine.newton_physics import DexUniSolverCfg + + if "sph_cfg" not in getattr(DexUniSolverCfg, "__dataclass_fields__", {}): + pytest.skip("This DexSim build does not compose SPH through DexUni.") + if num_envs > 1: + from dexsim.engine.newton_physics.solver_runtime import ( + ensure_solver_available, + ) + + solver = ensure_solver_available("sph") + if not ( + getattr(solver, "supports_world_isolation", False) + and getattr(solver, "supports_world_reset", False) + ): + pytest.skip("This DexSim SPH build supports only a single world.") + + def run_worlds(count: int, use_default: bool) -> dict: + result_path = tmp_path / f"lifecycle-{count}-{use_default}.json" + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve()), + str(result_path), + str(count), + str(int(use_default)), + ], + capture_output=True, + text=True, + timeout=180, + ) + assert result.returncode == 0, result.stdout + result.stderr + state = json.loads(result_path.read_text()) + assert state["num_particles"] == 64 + assert state["num_envs"] == count + assert state["automatic"] is use_default + return state + + result = run_worlds(num_envs, automatic) + if num_envs > 1: + reference = run_worlds(1, automatic) + torch.testing.assert_close( + torch.tensor(result["first_velocity"]), + torch.tensor(reference["first_velocity"]), + rtol=1e-4, + atol=1e-5, + ) + + +def _run_fluid_lifecycle(num_envs: int, automatic: bool) -> dict[str, object]: + """Run the complete NoRender lifecycle in a fresh native process.""" + from dexsim.engine.newton_physics import DexUniSolverCfg, SPHSolverCfg + from dexsim.engine.newton_physics.backend_registry import get_newton_backend + from embodichain.lab.sim.cfg import RigidObjectCfg + from embodichain.lab.sim.shapes import CubeCfg + + sim = SimulationManager( + SimulationManagerCfg( + headless=True, + device="cuda", + num_envs=num_envs, + # The two fluid volumes overlap in world space. SPH worlds must + # remain isolated even when their particles are nearby. + arena_space=0.02, + physics_dt=0.001, + render_cfg=RenderCfg(renderer="no-render"), + physics_cfg=NewtonPhysicsCfg( + num_substeps=2, + collision_cfg=None, + solver_cfg=( + None + if automatic + else DexUniSolverCfg( + sph_cfg=SPHSolverCfg( + sph_method="dfsph", + particle_support_radius=0.0225, + volume_map_boundary_enabled=False, + ) + ) + ), + ), + ) + ) + fluid = rigid = moving = None + try: + rigid = sim.add_rigid_object( + RigidObjectCfg( + uid="boundary", body_type="kinematic", shape=CubeCfg(size=[0.1] * 3) + ) + ) + moving = sim.add_rigid_object( + RigidObjectCfg( + uid="moving", + body_type="dynamic", + init_pos=(0.2, 0.0, 1.0), + shape=CubeCfg(size=[0.04] * 3), + ) + ) + fluid = sim.add_fluid_object( + FluidObjectCfg( + uid="water", + particle_spacing=0.01, + init_pos=(0.0, 0.0, 1.0), + spawn=FluidSpawnCfg(kind="box", size=(0.04, 0.04, 0.04)), + render=FluidRenderCfg(mode="none"), + ) + ) + with pytest.raises(RuntimeError, match="prepare"): + _ = fluid.data + sim.prepare() + backend = get_newton_backend(sim.spawn_result.world) + assert backend.solver_type == "dexuni" + assert backend.sph_solver is not None + assert backend.cfg.solver_cfg.sph_cfg.sph_method == "dfsph" + assert sim.get_fluid_object("water") is fluid + assert sim.get_asset("water") is fluid + assert fluid.num_particles == 64 + original = fluid.data.default_particle_state_w + assert original.shape == (num_envs, 64, 6) + if num_envs > 1: + assert torch.cdist(original[0, :, :3], original[1, :, :3]).min() < 0.0225 + sim.update(step=3, render_final_step=False) + first = fluid.data.particle_state_w + assert torch.isfinite(first).all() + assert torch.all(first[..., 2].mean(dim=1) < original[..., 2].mean(dim=1)) + diagnostics = sim.get_fluid_diagnostics("water") + assert diagnostics["particle_density"].shape == (num_envs, 64) + assert torch.isfinite(diagnostics["particle_density"]).all() + if num_envs > 1: + torch.testing.assert_close( + first[0, :, 3:], first[1, :, 3:], rtol=1e-4, atol=1e-5 + ) + body_before = moving.body_state.clone() + fluid.reset(env_ids=[0]) + torch.testing.assert_close(moving.body_state, body_before, rtol=0, atol=0) + selected = fluid.data.particle_state_w + torch.testing.assert_close(selected[0], original[0], rtol=0, atol=0) + torch.testing.assert_close(selected[1:], first[1:], rtol=0, atol=0) + sim.update(step=3, render_final_step=False) + continued = fluid.data.particle_state_w + torch.testing.assert_close(continued[0], first[0], rtol=1e-5, atol=1e-6) + assert continued[1, :, 2].mean() < first[1, :, 2].mean() + sim.reset_objects_state() + torch.testing.assert_close(fluid.data.particle_state_w, original) + sim.update(step=3, render_final_step=False) + torch.testing.assert_close( + fluid.data.particle_state_w, first, rtol=1e-5, atol=1e-6 + ) + with pytest.raises(NotImplementedError, match="before prepare"): + sim.add_fluid_object(FluidObjectCfg(uid="late")) + return { + "num_particles": fluid.num_particles, + "num_envs": num_envs, + "automatic": automatic, + "first_velocity": first[0, :, 3:].cpu().tolist(), + } + finally: + fluid = rigid = moving = None + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() + + +if __name__ == "__main__": + Path(sys.argv[1]).write_text( + json.dumps(_run_fluid_lifecycle(int(sys.argv[2]), bool(int(sys.argv[3])))) + + "\n" + ) diff --git a/tests/sim/objects/test_fluid_visibility.py b/tests/sim/objects/test_fluid_visibility.py new file mode 100644 index 000000000..acfc10ab9 --- /dev/null +++ b/tests/sim/objects/test_fluid_visibility.py @@ -0,0 +1,222 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""FastRT fluid primary visibility, independent of physical particle counts.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +pytestmark = [pytest.mark.gpu, pytest.mark.subprocess_sim] + + +@pytest.mark.parametrize("clipping", [False, True]) +def test_fluid_surface_changes_record_camera_pixels_in_every_environment( + tmp_path, clipping +): + from dexsim.engine.newton_physics import DexUniSolverCfg + + if "sph_cfg" not in getattr(DexUniSolverCfg, "__dataclass_fields__", {}): + pytest.skip("This DexSim build does not compose SPH through DexUni.") + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve()), + str(tmp_path), + str(int(clipping)), + ], + capture_output=True, + text=True, + timeout=180, + ) + assert result.returncode == 0, result.stdout + result.stderr + results = json.loads((tmp_path / "visibility.json").read_text()) + assert results + for row in results: + assert row["visible_blue_pixels"] > 100 + for hidden, restored in zip( + row["hidden_blue_pixels"], row["restored_blue_pixels"] + ): + assert row["visible_blue_pixels"] > 4 * hidden + assert restored > 100 and restored > 4 * hidden + + +def _run_visibility_worker(output: Path, clipping: bool) -> None: + import numpy as np + import torch + from PIL import Image + from dexsim.engine.newton_physics.solver_runtime import ensure_solver_available + from embodichain.lab.sim import SimulationManager, SimulationManagerCfg + from embodichain.lab.sim.cfg import ( + FluidObjectCfg, + FluidSpawnCfg, + FluidRenderCfg, + NewtonPhysicsCfg, + RenderCfg, + ) + from embodichain.lab.sim.material import VisualMaterialCfg + from embodichain.lab.sim.sensors import CameraCfg + + solver = ensure_solver_available("sph") + num_envs = ( + 2 + if getattr(solver, "supports_world_isolation", False) + and getattr(solver, "supports_world_reset", False) + else 1 + ) + sim = SimulationManager( + SimulationManagerCfg( + headless=True, + physics_dt=1 / 1440, + device="cuda", + num_envs=num_envs, + arena_space=1.0, + render_cfg=RenderCfg(renderer="fast-rt", spp=8), + physics_cfg=NewtonPhysicsCfg( + num_substeps=1, + collision_cfg=None, + solver_cfg={ + "solver_type": "dexuni", + "sph_cfg": { + "sph_method": "dfsph", + "volume_map_boundary_enabled": False, + "particle_support_radius": 0.00675, + "pressure_relaxation": 0.2, + "max_velocity": 2.0, + }, + }, + ), + ) + ) + fluid = camera = None + try: + fluid = sim.add_fluid_object( + FluidObjectCfg( + uid="visible_water", + particle_spacing=0.003, + init_pos=(0, 0, 0.15), + spawn=FluidSpawnCfg(kind="box", size=(0.06,) * 3), + render=FluidRenderCfg( + mode="mesh_surface", + render_radius=0.0009, + clip_below_z=0.05 if clipping else None, + material=VisualMaterialCfg( + base_color=[0.05, 0.35, 0.9, 1], + roughness=0.1, + ), + mesh_surface={ + "gpu_enabled": True, + "cell_size": 0.00162, + "max_res": 128, + "kernel_radius": 0.0045, + "iso_density": 0.57, + "density_smooth_iters": 2, + "min_component_tris": 32, + }, + ), + ) + ) + camera = sim.add_sensor( + CameraCfg( + uid="record", + width=320, + height=240, + intrinsics=(350, 350, 160, 120), + visualization_role="record", + extrinsics=CameraCfg.ExtrinsicsCfg( + eye=(0.18, -0.24, 0.28), target=(0, 0, 0.15), up=(0, 0, 1) + ), + ) + ) + sim.prepare() + sim.update(step=2) + + def capture(): + sim.render_camera_group([camera.group_id]) + camera.update(fetch_only=True) + return camera.get_data()["color"][..., :3].cpu().numpy().copy() + + visible = capture() + before = fluid.data.particle_state_w + hidden_state = before.clone() + hidden_state[..., 0] += 10 + hidden_frames = [] + restored_frames = [] + # Several publications may occur before the next camera consumes one. + for state in (before, hidden_state): + fluid.write_particle_state_to_sim(state) + sim.sync_render_state() + hidden_frames.append(capture()) + fluid.write_particle_state_to_sim(before) + restored_frames.append(capture()) + for _ in range(3): + fluid.write_particle_state_to_sim(hidden_state) + hidden_frames.append(capture()) + # Visibility captures do not advance physics. + fluid.write_particle_state_to_sim(before) + restored_frames.append(capture()) + stream = torch.cuda.Stream(device=fluid.device) + stream.wait_stream(torch.cuda.current_stream(fluid.device)) + with torch.cuda.stream(stream): + fluid.write_particle_state_to_sim(hidden_state) + # Force the renderer's staging copy to wait for producer work. + torch.cuda._sleep(20_000_000) + hidden_frames.append(capture()) + fluid.write_particle_state_to_sim(before) + restored_frames.append(capture()) + torch.testing.assert_close(fluid.data.particle_state_w, before, rtol=0, atol=0) + + def blue_pixels(frame): + frame = frame.astype(np.int16) + return int( + ( + (frame[..., 2] > frame[..., 0] + 40) + & (frame[..., 2] > frame[..., 1] + 20) + ).sum() + ) + + results = [] + for env_id in range(num_envs): + Image.fromarray(visible[env_id]).save(output / f"visible_env{env_id}.png") + Image.fromarray(hidden_frames[-1][env_id]).save( + output / f"hidden_env{env_id}.png" + ) + results.append( + { + "env_id": env_id, + "visible_blue_pixels": blue_pixels(visible[env_id]), + "hidden_blue_pixels": [ + blue_pixels(frame[env_id]) for frame in hidden_frames + ], + "restored_blue_pixels": [ + blue_pixels(frame[env_id]) for frame in restored_frames + ], + } + ) + (output / "visibility.json").write_text(json.dumps(results, indent=2) + "\n") + finally: + fluid = camera = None + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() + + +if __name__ == "__main__": + _run_visibility_worker(Path(sys.argv[1]), bool(int(sys.argv[2]))) diff --git a/tests/sim/objects/test_rigid_constraint.py b/tests/sim/objects/test_rigid_constraint.py index bfb7cb9db..1be6779b5 100644 --- a/tests/sim/objects/test_rigid_constraint.py +++ b/tests/sim/objects/test_rigid_constraint.py @@ -264,6 +264,7 @@ def __init__(self, num_envs=4, arenas=None): self._rigid_objects = {} self._rigid_object_groups = {} self._deformable_objects = {} + self._fluid_objects = {} self._articulations = {} self._constraints = {} self.device = torch.device("cpu") diff --git a/tests/sim/objects/test_visual_object.py b/tests/sim/objects/test_visual_object.py index f2f95854f..8cdbd9a2e 100644 --- a/tests/sim/objects/test_visual_object.py +++ b/tests/sim/objects/test_visual_object.py @@ -280,6 +280,7 @@ def test_add_usd_classifies_visuals_without_promoting_collision_only_objects(tmp "_rigid_objects", "_rigid_object_groups", "_deformable_objects", + "_fluid_objects", "_articulations", "_robots", "_lights", @@ -492,3 +493,38 @@ def test_visual_material_read_respects_selected_environments(): visual, _ = _visual() assert visual.get_visual_material_inst([1]) == [None] assert visual.get_visual_material_inst([]) == [] + + +def test_visual_binding_preserves_resource_materials_on_reset(): + from embodichain.lab.sim.material import VisualMaterialCfg + + material_cfg = VisualMaterialCfg( + parameters={ + "transmission": 0.85, + "volume_thickness": 0.08, + "volume_attenuation_distance": 0.5, + "volume_attenuation_color": (0.2, 0.6, 0.8), + }, + ) + visual = VisualObject( + VisualObjectCfg( + uid="glass", + shape=MeshCfg(fpath="/assets/glass.obj", visual_material=material_cfg), + ) + ) + visual._initialize_spawn_declaration(1) + handle = _Handle(0.0) + originals = [MagicMock(name="first"), MagicMock(name="second")] + for i, material in enumerate(originals): + material.get_name.return_value = f"material_{i}" + material.get_template.return_value.get_name.return_value = f"template_{i}" + handle.render.materials = originals.copy() + visual.attach_spawn_handles([handle]) + + visual.bind_spawn(SimpleNamespace(topology_revision=1, world=_World([handle]))) + handle.render.materials = [None, None] + visual.reset() + + assert handle.render.materials == originals + for material in originals: + material.set_pbr_param.assert_not_called() diff --git a/tests/sim/spawn/test_descriptors.py b/tests/sim/spawn/test_descriptors.py index 426a02b44..16a5570ec 100644 --- a/tests/sim/spawn/test_descriptors.py +++ b/tests/sim/spawn/test_descriptors.py @@ -2674,3 +2674,53 @@ def test_rigid_descriptor_rejects_orientation_without_principal_moments() -> Non ) with pytest.raises(ValueError, match="requires explicit principal inertia"): rigid_desc_from_cfg(cfg) + + +@pytest.mark.parametrize( + "kind", ["rigid", "visual", "volume", "surface", "surface_visual"] +) +def test_shape_optical_transmission_reaches_spawn_material(kind): + import json + from embodichain.lab.sim.cfg import ( + RigidObjectCfg, + VisualObjectCfg, + VolumeDeformableObjectCfg, + SurfaceDeformableObjectCfg, + ) + from embodichain.lab.sim.material import VisualMaterialCfg + from embodichain.lab.sim.spawn.descriptors import ( + visual_desc_from_cfg, + volume_deformable_desc_from_cfg, + surface_deformable_desc_from_cfg, + ) + + cfg_type, compile_descriptor = { + "rigid": (RigidObjectCfg, rigid_desc_from_cfg), + "visual": (VisualObjectCfg, visual_desc_from_cfg), + "volume": (VolumeDeformableObjectCfg, volume_deformable_desc_from_cfg), + "surface": (SurfaceDeformableObjectCfg, surface_deformable_desc_from_cfg), + "surface_visual": ( + SurfaceDeformableObjectCfg, + surface_deformable_desc_from_cfg, + ), + }[kind] + cfg = cfg_type( + uid="glass", + shape=MeshCfg( + fpath="/assets/glass.obj", + visual_material=VisualMaterialCfg(parameters={"transmission": 0.85}), + ), + ) + + if kind == "surface_visual": + cfg.visual_shape = MeshCfg( + fpath="/assets/render.obj", visual_material=cfg.shape.visual_material + ) + cfg.shape.visual_material = VisualMaterialCfg(parameters={"transmission": 0.1}) + + _, materials = compile_descriptor(cfg) + + assert ( + json.loads(next(iter(materials.values())).parameters_json)["transmission"] + == 0.85 + ) diff --git a/tests/sim/spawn/test_scene.py b/tests/sim/spawn/test_scene.py index 2dc2f62c7..923e2ee67 100644 --- a/tests/sim/spawn/test_scene.py +++ b/tests/sim/spawn/test_scene.py @@ -26,6 +26,7 @@ from embodichain.lab.sim.objects import ( Articulation, DeformableObject, + FluidObject, RigidObject, RigidObjectGroup, Robot, @@ -279,7 +280,7 @@ def test_rigid_object_receives_instance_count_only_when_declared() -> None: @pytest.mark.parametrize( "facade_type", - [RigidObject, Articulation, Robot, RigidObjectGroup, DeformableObject], + [RigidObject, Articulation, Robot, RigidObjectGroup, DeformableObject, FluidObject], ) def test_object_constructors_hide_spawn_lifecycle_parameters( facade_type: type[object], @@ -288,6 +289,9 @@ def test_object_constructors_hide_spawn_lifecycle_parameters( assert "declared_num_instances" not in parameters assert "spawn_result" not in parameters + assert "positions" not in parameters + assert "pose" not in parameters + assert "on_write" not in parameters def test_runtime_config_attaches_articulation_before_preparing_it() -> None: diff --git a/tests/sim/test_fluid_cfg.py b/tests/sim/test_fluid_cfg.py new file mode 100644 index 000000000..b1ab4c12f --- /dev/null +++ b/tests/sim/test_fluid_cfg.py @@ -0,0 +1,565 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Fluid declaration and solver configuration contracts.""" + +from __future__ import annotations + +import numpy as np +import pytest +from embodichain.lab.sim.cfg import ( + FluidObjectCfg, + FluidSpawnCfg, + NewtonPhysicsCfg, +) +from embodichain.compute.geometry.particles import sample_volume_particles + +pytestmark = pytest.mark.no_sim + + +def test_fluid_round_trip_keeps_native_render_options(): + cfg = FluidObjectCfg.from_dict( + { + "uid": "water", + "spawn": {"kind": "box", "size": [0.02] * 3}, + "render": {"mode": "mesh_surface", "mesh_surface": {"iso_density": 0.57}}, + } + ) + restored = FluidObjectCfg.from_dict(cfg.to_dict()) + np.testing.assert_array_equal(cfg.spawn.sample(0.005), restored.spawn.sample(0.005)) + assert restored.render.mesh_surface == {"iso_density": 0.57} + restored.render.mesh_surface["iso_density"] = 0.8 + assert cfg.render.mesh_surface["iso_density"] == 0.57 + + +def test_native_sph_and_yaml_mapping_use_same_solver(): + from dexsim.engine.newton_physics import DexUniSolverCfg, SPHSolverCfg + + if "sph_cfg" not in getattr(DexUniSolverCfg, "__dataclass_fields__", {}): + pytest.skip("This DexSim build does not compose SPH through DexUni.") + + source = SPHSolverCfg(sph_method="dfsph", viscosity=0.0006) + native = NewtonPhysicsCfg(solver_cfg=DexUniSolverCfg(sph_cfg=source)).to_dexsim_cfg( + 0 + ) + mapping = NewtonPhysicsCfg( + solver_cfg={ + "solver_type": "dexuni", + "sph_cfg": {"sph_method": "dfsph", "viscosity": 0.0006}, + } + ).to_dexsim_cfg(0) + assert type(native.solver_cfg) is DexUniSolverCfg + assert type(mapping.solver_cfg) is DexUniSolverCfg + assert native.solver_cfg.sph_cfg == source + assert mapping.solver_cfg.sph_cfg == source + + +def test_standalone_sph_mapping_points_to_dexuni_migration(): + with pytest.raises(RuntimeError, match="configure DexUni with sph_cfg"): + NewtonPhysicsCfg(solver_cfg={"solver_type": "sph"}).to_dexsim_cfg(0) + + +@pytest.mark.parametrize("kind", ["box", "sphere", "cylinder"]) +def test_lattice_is_deterministic_and_inside_volume(kind): + points = sample_volume_particles( + kind, 0.01, size=(0.04, 0.04, 0.04), radius=0.02, height=0.04 + ) + np.testing.assert_array_equal( + points, + sample_volume_particles( + kind, 0.01, size=(0.04, 0.04, 0.04), radius=0.02, height=0.04 + ), + ) + assert np.all(np.abs(points) < 0.02) + if kind == "sphere": + assert np.all(np.linalg.norm(points, axis=1) < 0.02) + if kind == "cylinder": + assert np.all(np.linalg.norm(points[:, :2], axis=1) < 0.02) + + +def test_grid_limit_is_checked_before_allocation(): + with pytest.raises(ValueError, match="max_particles"): + sample_volume_particles("box", 1e-9) + + +@pytest.mark.parametrize( + "positions", [[], [[float("nan"), 0, 0]], [[0, 1]], [[[0, 0, 0]]]] +) +def test_invalid_particle_sources_rejected(positions): + with pytest.raises(ValueError): + FluidSpawnCfg(positions=positions).sample(0.001) + + +def test_npz_points_are_loaded_without_pickle(tmp_path): + path = tmp_path / "fill.npz" + np.savez(path, positions=np.array([[1, 2, 3]], dtype=np.float32)) + np.testing.assert_array_equal( + FluidSpawnCfg(file_path=str(path)).sample(0.01), [[1, 2, 3]] + ) + + +def test_fill_mesh_must_be_closed(tmp_path): + import trimesh + + path = tmp_path / "open.obj" + trimesh.Trimesh( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0]], faces=[[0, 1, 2]] + ).export(path) + with pytest.raises(ValueError, match="closed"): + sample_volume_particles("mesh_volume", 0.01, mesh_path=str(path)) + + +@pytest.mark.parametrize("backend,num_envs", [("default", 1), ("newton", 2)]) +def test_manager_rejects_unsupported_fluid_worlds_before_declaration(backend, num_envs): + from types import SimpleNamespace + import torch + from embodichain.lab.sim import SimulationManager + + manager = SimpleNamespace( + physics=SimpleNamespace(name=backend), + device=torch.device("cuda"), + num_envs=num_envs, + ) + with pytest.raises(NotImplementedError): + SimulationManager.add_fluid_object(manager, FluidObjectCfg(uid="water")) + + +@pytest.mark.parametrize( + "solver_cfg", + [None, {"solver_type": "dexuni", "sph_cfg": {"sph_method": "dfsph"}}], +) +def test_fluid_declaration_accepts_dexuni_dispatch(solver_cfg, monkeypatch): + from unittest.mock import Mock + from embodichain.lab.sim import SimulationManager + + manager = _fluid_manager_for_renderer("no-render") + manager._active_newton_solver_type = "dexuni" + manager.physics.supports_dexuni_fluids = True + manager.sim_config.physics_cfg.solver_cfg = solver_cfg + monkeypatch.setattr(SimulationManager, "_get_process_renderer", lambda: None) + monkeypatch.setattr("embodichain.lab.sim.sim_manager.FluidObject", Mock()) + cfg = FluidObjectCfg( + uid="water", spawn=FluidSpawnCfg(positions=[[0, 0, 0]]), render={"mode": "none"} + ) + + SimulationManager.add_fluid_object(manager, cfg) + + manager._spawn_scene.declare.assert_called_once() + + +def test_dexuni_nested_sph_density_conflict_is_rejected(monkeypatch): + from embodichain.lab.sim import SimulationManager + + manager = _fluid_manager_for_renderer("no-render") + manager._active_newton_solver_type = "dexuni" + manager.physics.supports_dexuni_fluids = True + manager.sim_config.physics_cfg.solver_cfg = { + "solver_type": "dexuni", + "sph_cfg": {"sph_method": "dfsph", "rest_density": 1200.0}, + } + monkeypatch.setattr(SimulationManager, "_get_process_renderer", lambda: None) + + with pytest.raises(ValueError, match="rest_density conflicts"): + SimulationManager.add_fluid_object(manager, FluidObjectCfg(uid="water")) + manager._spawn_scene.declare.assert_not_called() + + +@pytest.mark.parametrize("solver,capability", [("sph", True), ("dexuni", False)]) +def test_fluid_requires_a_dexuni_build_with_sph_support(solver, capability): + from embodichain.lab.sim import SimulationManager + + manager = _fluid_manager_for_renderer("no-render") + manager._active_newton_solver_type = solver + manager.physics.supports_dexuni_fluids = capability + with pytest.raises(NotImplementedError, match="DexUni"): + SimulationManager.add_fluid_object(manager, FluidObjectCfg(uid="water")) + manager._spawn_scene.declare.assert_not_called() + + +def test_dexuni_fluid_rejects_existing_deformables(): + from embodichain.lab.sim import SimulationManager + + manager = _fluid_manager_for_renderer("no-render") + manager._deformable_objects = {"cloth": object()} + with pytest.raises(NotImplementedError, match="cannot coexist"): + SimulationManager.add_fluid_object(manager, FluidObjectCfg(uid="water")) + manager._spawn_scene.declare.assert_not_called() + + +def test_dexuni_deformable_rejects_existing_fluid(): + from types import SimpleNamespace + import torch + from embodichain.lab.sim import SimulationManager + + manager = SimpleNamespace( + physics=SimpleNamespace(name="newton", supports_surface_deformables=True), + device=torch.device("cuda"), + _active_newton_solver_type="dexuni", + _fluid_objects={"water": object()}, + ) + with pytest.raises(NotImplementedError, match="cannot coexist"): + SimulationManager.add_deformable_object( + manager, SimpleNamespace(deformable_type="surface") + ) + + +@pytest.mark.parametrize("has_sph_cfg", [False, True]) +def test_dexuni_fluid_capability_tracks_native_config(monkeypatch, has_sph_cfg): + from types import SimpleNamespace + import dexsim.engine.newton_physics as native + from embodichain.lab.sim.physics.newton import NewtonPhysicsBackend + + fields = {"sph_cfg": object()} if has_sph_cfg else {} + fake_cfg = type("FakeDexUniCfg", (), {"__dataclass_fields__": fields}) + monkeypatch.setattr(native, "DexUniSolverCfg", fake_cfg) + backend = NewtonPhysicsBackend(SimpleNamespace()) + backend._configured_solver_type = "dexuni" + assert backend.supports_dexuni_fluids is has_sph_cfg + + +def test_fluid_reset_invalidates_sph_without_resetting_dexuni_bodies(monkeypatch): + from types import SimpleNamespace + from unittest.mock import Mock + + import warp as wp + from dexsim.engine.newton_physics import backend_registry + from embodichain.lab.sim.physics.newton import NewtonPhysicsBackend + + wp.init() + sph = SimpleNamespace(supports_world_reset=True, reset=Mock()) + solid = SimpleNamespace(reset=Mock()) + native = SimpleNamespace( + solver_type="dexuni", + sph_solver=sph, + solver=solid, + invalidate_cuda_graph=Mock(), + model=SimpleNamespace(world_count=2, device="cpu"), + _runtime=SimpleNamespace(current_state=object()), + ) + monkeypatch.setattr(backend_registry, "get_newton_backend", lambda _: native) + backend = NewtonPhysicsBackend(SimpleNamespace(_world=object())) + + backend.invalidate_fluid_state([1]) + + native.invalidate_cuda_graph.assert_called_once() + solid.reset.assert_not_called() + assert sph.reset.call_args.args[0] is native._runtime.current_state + assert sph.reset.call_args.kwargs["world_mask"].numpy().tolist() == [ + False, + True, + False, + ] + assert sph.reset.call_args.kwargs["flags"] == 0 + + +def test_points_source_validates_spacing(): + with pytest.raises(ValueError, match="spacing"): + FluidSpawnCfg(positions=[[0, 0, 0]]).sample(0) + + +def test_closed_mesh_sampling_matches_equivalent_box(tmp_path): + import trimesh + + mesh = tmp_path / "fill.obj" + trimesh.creation.box(extents=[0.04] * 3).export(mesh) + actual = sample_volume_particles("mesh_volume", 0.01, mesh_path=str(mesh)) + expected = sample_volume_particles("box", 0.01, size=(0.04,) * 3) + np.testing.assert_allclose(actual, expected) + + +def test_profile_source_round_trip_rebuilds_without_a_particle_file(): + cfg = FluidObjectCfg.from_dict( + { + "uid": "water", + "spawn": { + "kind": "revolved_volume", + "profile": [[-0.02, 0.01], [0.03, 0.02]], + "fill_level": 0.8, + "clearance": 0.001, + "reference_uid": "cup", + }, + } + ) + restored = FluidObjectCfg.from_dict(cfg.to_dict()) + assert restored.spawn.file_path is None + points = restored.spawn.sample(0.004) + np.testing.assert_array_equal(points, cfg.spawn.sample(0.004)) + assert points.dtype == np.float32 + assert points[:, 2].max() < -0.02 + 0.8 * 0.05 + assert len(restored.spawn.sample(0.002)) > len(points) + + +@pytest.mark.parametrize("extra", [{"profile": [[0, 1], [1, 1]]}, {"fill_level": 0.5}]) +def test_points_reject_geometry_filters_instead_of_silently_ignoring_them(extra): + with pytest.raises(ValueError, match="points sources"): + FluidSpawnCfg(positions=[[0, 0, 0]], **extra).sample(0.001) + + +def _fluid_manager_for_renderer(renderer): + from types import SimpleNamespace + from unittest.mock import Mock + import torch + from embodichain.lab.sim import SimulationManagerCfg + from embodichain.lab.sim.cfg import RenderCfg + + return SimpleNamespace( + physics=SimpleNamespace( + name="newton", configure_world=Mock(), supports_dexuni_fluids=True + ), + device=torch.device("cuda"), + num_envs=1, + _active_newton_solver_type="dexuni", + spawn_result=None, + sim_config=SimulationManagerCfg( + headless=True, + physics_cfg=NewtonPhysicsCfg(), + render_cfg=RenderCfg(renderer=renderer), + ), + has_native_renderer=renderer != "no-render", + asset_uids=[], + _fluid_objects={}, + _rigid_objects={}, + _spawn_scene=SimpleNamespace(declare=Mock()), + create_visual_material=Mock(), + _invalidate_fluid_state=Mock(), + notify_visualization_topology_changed=Mock(), + ) + + +@pytest.mark.parametrize("mode", ["particles", "mesh_surface", "none"]) +@pytest.mark.parametrize( + "requested,process_renderer", + [ + ("hybrid", None), + ("fast-rt", "hybrid"), + ("rt", "hybrid"), + ("auto", "hybrid"), + ("no-render", "hybrid"), + ], +) +def test_manager_rejects_hybrid_fluid_rendering_before_sampling( + requested, process_renderer, mode, monkeypatch +): + from unittest.mock import Mock + from embodichain.lab.sim import SimulationManager + + monkeypatch.setattr( + SimulationManager, "_get_process_renderer", lambda: process_renderer + ) + sample = Mock(side_effect=AssertionError("Fluid must be rejected before sampling")) + monkeypatch.setattr(FluidSpawnCfg, "sample", sample) + manager = _fluid_manager_for_renderer(requested) + cfg = FluidObjectCfg(uid="water", render={"mode": mode}) + with pytest.raises(NotImplementedError, match="fast-rt.*Vulkan.*new process"): + SimulationManager.add_fluid_object(manager, cfg) + sample.assert_not_called() + manager.create_visual_material.assert_not_called() + manager._spawn_scene.declare.assert_not_called() + + +@pytest.mark.parametrize("process_renderer", [None, "hybrid"]) +def test_auto_selected_hybrid_rejects_fluid_before_sampling( + process_renderer, monkeypatch, tmp_path +): + from unittest.mock import Mock + from embodichain.lab.sim import SimulationManager, cfg as sim_cfg + + monkeypatch.setattr(sim_cfg, "DEFAULT_RENDERER", "auto") + monkeypatch.setattr( + SimulationManager, "_get_process_renderer", lambda: process_renderer + ) + monkeypatch.setattr( + "embodichain.lab.sim.utility.render_utils.select_default_renderer", + lambda _: "hybrid", + ) + manager = _fluid_manager_for_renderer("auto") + manager._requires_native_renderer = True + manager._material_cache_dir = tmp_path + SimulationManager._convert_sim_config(manager, manager.sim_config) + assert manager.sim_config.render_cfg.renderer == "hybrid" + sample = Mock(side_effect=AssertionError("Fluid must be rejected before sampling")) + monkeypatch.setattr(FluidSpawnCfg, "sample", sample) + with pytest.raises(NotImplementedError, match="fast-rt.*Vulkan"): + SimulationManager.add_fluid_object(manager, FluidObjectCfg(uid="water")) + sample.assert_not_called() + + +@pytest.mark.parametrize( + "requested,process_renderer,mode", + [ + ("fast-rt", None, "particles"), + ("fast-rt", "fast-rt", "mesh_surface"), + ("rt", "rt", "mesh_surface"), + ("no-render", "no-render", "none"), + ("no-render", "no-render", "particles"), + ], +) +def test_fluid_renderer_gate_allows_supported_backends( + requested, process_renderer, mode, monkeypatch +): + from unittest.mock import Mock + from embodichain.lab.sim import SimulationManager + + monkeypatch.setattr( + SimulationManager, "_get_process_renderer", lambda: process_renderer + ) + facade = Mock() + monkeypatch.setattr( + "embodichain.lab.sim.sim_manager.FluidObject", Mock(return_value=facade) + ) + manager = _fluid_manager_for_renderer(requested) + cfg = FluidObjectCfg( + uid="water", spawn=FluidSpawnCfg(kind="box"), render={"mode": mode} + ) + assert SimulationManager.add_fluid_object(manager, cfg) is facade + assert manager._fluid_objects["water"] is facade + manager._spawn_scene.declare.assert_called_once() + + +@pytest.mark.parametrize("representation", ["native", "mapping", "full_mapping"]) +@pytest.mark.parametrize("density,spacing", [(1000.0, 0.0015), (800.0, 0.003)]) +def test_solver_defaults_allow_fluid_owned_density_and_spacing( + representation, density, spacing, monkeypatch +): + from dataclasses import asdict + from unittest.mock import Mock + from dexsim.engine.newton_physics import DexUniSolverCfg, SPHSolverCfg + from embodichain.lab.sim import SimulationManager + + sph = SPHSolverCfg(sph_method="dfsph") + if representation == "native": + if "sph_cfg" not in getattr(DexUniSolverCfg, "__dataclass_fields__", {}): + pytest.skip("This DexSim build does not compose SPH through DexUni.") + solver = DexUniSolverCfg(sph_cfg=sph) + elif representation == "mapping": + solver = {"solver_type": "dexuni", "sph_cfg": {"sph_method": "dfsph"}} + else: + solver = {"solver_type": "dexuni", "sph_cfg": asdict(sph)} + manager = _fluid_manager_for_renderer("no-render") + manager.sim_config.physics_cfg.solver_cfg = solver + monkeypatch.setattr(SimulationManager, "_get_process_renderer", lambda: None) + monkeypatch.setattr("embodichain.lab.sim.sim_manager.FluidObject", Mock()) + cfg = FluidObjectCfg( + uid="water", + density=density, + particle_spacing=spacing, + spawn=FluidSpawnCfg(positions=[[0.0, 0.0, 0.0]]), + render={"mode": "none"}, + ) + + SimulationManager.add_fluid_object(manager, cfg) + + descriptor = manager._spawn_scene.declare.call_args.args[2] + assert descriptor.density == density + assert descriptor.particle_spacing == spacing + if representation != "mapping": + values = asdict(sph) if representation == "native" else solver["sph_cfg"] + assert values["rest_density"] == SPHSolverCfg().rest_density + assert values["particle_diameter"] == SPHSolverCfg().particle_diameter + + +@pytest.mark.parametrize("representation", ["native", "mapping"]) +@pytest.mark.parametrize("conflict", ["rest_density", "particle_diameter", None]) +def test_solver_nondefault_physical_values_must_match_fluid( + representation, conflict, monkeypatch +): + from dataclasses import asdict + from unittest.mock import Mock + from dexsim.engine.newton_physics import DexUniSolverCfg, SPHSolverCfg + from embodichain.lab.sim import SimulationManager + + density, spacing = 800.0, 0.003 + sph = SPHSolverCfg( + sph_method="dfsph", rest_density=density, particle_diameter=spacing + ) + if conflict is not None: + setattr(sph, conflict, 1200.0 if conflict == "rest_density" else 0.02) + if representation == "mapping": + solver = {"solver_type": "dexuni", "sph_cfg": asdict(sph)} + else: + if "sph_cfg" not in getattr(DexUniSolverCfg, "__dataclass_fields__", {}): + pytest.skip("This DexSim build does not compose SPH through DexUni.") + solver = DexUniSolverCfg(sph_cfg=sph) + manager = _fluid_manager_for_renderer("no-render") + manager.sim_config.physics_cfg.solver_cfg = solver + monkeypatch.setattr(SimulationManager, "_get_process_renderer", lambda: None) + monkeypatch.setattr("embodichain.lab.sim.sim_manager.FluidObject", Mock()) + cfg = FluidObjectCfg( + uid="water", + density=density, + particle_spacing=spacing, + spawn=FluidSpawnCfg(positions=[[0.0, 0.0, 0.0]]), + render={"mode": "none"}, + ) + + if conflict is None: + SimulationManager.add_fluid_object(manager, cfg) + manager._spawn_scene.declare.assert_called_once() + else: + with pytest.raises(ValueError, match=f"Fluid {conflict} conflicts"): + SimulationManager.add_fluid_object(manager, cfg) + manager._spawn_scene.declare.assert_not_called() + + +def test_manager_resolves_source_once_without_rewriting_config(monkeypatch): + from unittest.mock import Mock + from types import SimpleNamespace + from embodichain.lab.sim import SimulationManager + from embodichain.lab.sim.cfg import RigidObjectCfg + from embodichain.lab.sim.spawn.descriptors import _pose_from_cfg + + points = np.array([[0.01, 0.0, 0.0]], dtype=np.float32) + sampled = [] + + def sample(source, spacing): + sampled.append(spacing) + return points.copy() + + monkeypatch.setattr(FluidSpawnCfg, "sample", sample) + monkeypatch.setattr(SimulationManager, "_get_process_renderer", lambda: None) + factory = Mock() + monkeypatch.setattr("embodichain.lab.sim.sim_manager.FluidObject", factory) + manager = _fluid_manager_for_renderer("no-render") + reference = RigidObjectCfg( + uid="cup", init_pos=(0.2, 0.3, 0.4), init_rot=(10.0, 20.0, 30.0) + ) + manager._rigid_objects["cup"] = SimpleNamespace(cfg=reference) + cfg = FluidObjectCfg( + uid="water", + init_pos=(0.01, 0.02, 0.03), + init_rot=(15.0, 25.0, 35.0), + spawn=FluidSpawnCfg(kind="box", reference_uid="cup"), + render={"mode": "none"}, + ) + before = cfg.to_dict() + expected_pose = _pose_from_cfg(reference) @ _pose_from_cfg(cfg) + + fluid = SimulationManager.add_fluid_object(manager, cfg) + + assert sampled == [cfg.particle_spacing] + assert cfg.to_dict() == before + assert len(factory.call_args.args) == 2 + assert isinstance(factory.call_args.args[0], FluidObjectCfg) + assert factory.call_args.args[1] == manager.device + assert factory.call_args.kwargs == {} + fluid._initialize_spawn_source.assert_called_once() + args = fluid._initialize_spawn_source.call_args + np.testing.assert_array_equal(args.args[0], points) + np.testing.assert_allclose(args.args[1], expected_pose[:3, :3]) + assert args.kwargs["on_write"] is manager._invalidate_fluid_state + descriptor = manager._spawn_scene.declare.call_args.args[2] + np.testing.assert_array_equal(descriptor.positions, points) + np.testing.assert_allclose(descriptor.pose, expected_pose) diff --git a/tests/sim/test_fluid_render_sync.py b/tests/sim/test_fluid_render_sync.py new file mode 100644 index 000000000..849b0c4d8 --- /dev/null +++ b/tests/sim/test_fluid_render_sync.py @@ -0,0 +1,189 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Fluid render clipping preserves physics state and publication ownership.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import torch +import warp as wp + +from embodichain.lab.sim.physics.newton import NewtonPhysicsBackend + +pytestmark = pytest.mark.no_sim + + +def _backend() -> NewtonPhysicsBackend: + return NewtonPhysicsBackend(SimpleNamespace(has_native_renderer=True)) + + +@pytest.mark.parametrize( + "ranges", + [((0, 5, 0.0),), ((1, 3, 0.0),), ((0, 2, 0.0), (3, 5, 1.0))], +) +def test_clip_only_hides_selected_particles_below_world_height(ranges): + source = torch.tensor( + [[1, 2, -1], [2, 3, 0], [3, 4, -2], [4, 5, 0.5], [5, 6, 1]], + dtype=torch.float32, + ) + before = source.clone() + expected = source.clone() + for start, end, height in ranges: + for index in range(start, end): + if source[index, 2] < height: + expected[index] = float("nan") + + rendered = _backend()._clip_fluid_render_positions(source, ranges) + + torch.testing.assert_close(rendered, expected, equal_nan=True, rtol=0, atol=0) + torch.testing.assert_close(source, before, rtol=0, atol=0) + assert rendered.data_ptr() != source.data_ptr() + + +def test_repeated_publications_do_not_overwrite_previous_render_input(): + backend = _backend() + source = torch.tensor([[1.0, 2, -1], [3, 4, 1], [5, 6, -2]]) + ranges = ((0, 1, 0.0), (2, 3, -3.0)) + first = backend._clip_fluid_render_positions(source, ranges) + saved = first.clone() + source[:, 2] += 5 + + second = backend._clip_fluid_render_positions(source, ranges) + + torch.testing.assert_close(first, saved, equal_nan=True, rtol=0, atol=0) + torch.testing.assert_close(second, source, rtol=0, atol=0) + assert first.data_ptr() != second.data_ptr() + + +def test_clip_layout_cache_tracks_height_ranges_shape_and_dtype(): + backend = _backend() + source = torch.tensor([[1.0, 2, -1], [3, 4, -2], [5, 6, 1]]) + first_ranges = ((0, 1, 0.0), (2, 3, 2.0)) + backend._clip_fluid_render_positions(source, first_ranges) + cached = backend._fluid_render_clip_z + backend._clip_fluid_render_positions(source + 1, first_ranges) + assert backend._fluid_render_clip_z is cached + + changed = backend._clip_fluid_render_positions(source, ((1, 2, -3.0),)) + torch.testing.assert_close(changed, source, rtol=0, atol=0) + assert backend._fluid_render_clip_z is not cached + + for resized in (source[:2], source.to(torch.float64)): + actual = backend._clip_fluid_render_positions(resized, ((0, 1, -3.0),)) + torch.testing.assert_close(actual, resized, rtol=0, atol=0) + assert actual.dtype == resized.dtype + + +def test_single_fluid_needs_no_per_particle_height_cache(): + backend = _backend() + source = torch.ones(3, 3) + backend._clip_fluid_render_positions(source, ((1, 2, 0.0),)) + + result = backend._clip_fluid_render_positions(source, ((0, 3, 0.0),)) + + torch.testing.assert_close(result, source) + assert backend._fluid_render_clip_z is None + assert backend._fluid_render_clip_key is None + + +def test_native_publication_preserves_body_state_time_and_unclipped_ranges(monkeypatch): + from dexsim.engine.newton_physics import backend_registry + + wp.init() + source = torch.tensor([[1.0, 2, -1], [3, 4, -2], [5, 6, -3], [7, 8, 1]]) + before = source.clone() + published = [] + + def publish(world, body_q, particle_q, *, simulation_time): + published.append((world, body_q, wp.to_torch(particle_q), simulation_time)) + + native = SimpleNamespace( + state_0=SimpleNamespace( + particle_q=wp.from_torch(source, dtype=wp.vec3), body_q=object() + ), + sim_time=4.5, + sync_to_dexsim=Mock(), + render_sync=SimpleNamespace(sync=publish), + ) + monkeypatch.setattr(backend_registry, "get_newton_backend", lambda world: native) + backend = _backend() + backend._manager._fluid_objects = { + "clipped": SimpleNamespace( + cfg=SimpleNamespace(render=SimpleNamespace(clip_below_z=0.0)), + _entities=[ + SimpleNamespace( + _physics_binding=SimpleNamespace(particle_start=start), + particle_count=1, + ) + for start in (0, 3) + ], + ), + "unclipped": SimpleNamespace( + cfg=SimpleNamespace(render=SimpleNamespace(clip_below_z=None)) + ), + } + scene = SimpleNamespace(world=object()) + + backend.sync_render_state(scene) + + world, body_q, particles, sim_time = published[0] + assert world is scene.world + assert body_q is native.state_0.body_q + assert sim_time == native.sim_time + assert torch.isnan(particles[0]).all() + torch.testing.assert_close(particles[1:], source[1:], rtol=0, atol=0) + torch.testing.assert_close(source, before, rtol=0, atol=0) + native.sync_to_dexsim.assert_not_called() + + backend._manager._fluid_objects["clipped"].cfg.render.clip_below_z = None + backend.sync_render_state(scene) + native.sync_to_dexsim.assert_called_once_with(scene.world) + assert backend._fluid_render_clip_z is None + + +def test_teardown_releases_clip_metadata_even_without_a_world(): + backend = _backend() + backend._clip_fluid_render_positions(torch.ones(3, 3), ((1, 2, 0.0),)) + + backend.prepare_for_teardown() + + assert backend._fluid_render_clip_z is None + assert backend._fluid_render_clip_key is None + + +@pytest.mark.gpu +def test_clip_cache_is_safe_across_nondefault_cuda_streams(): + backend = _backend() + source = torch.tensor([[1.0, 2, -1], [3, 4, 1], [5, 6, -2]], device="cuda") + ranges = ((0, 1, 0.0), (2, 3, -3.0)) + streams = [torch.cuda.Stream(), torch.cuda.Stream()] + initial = torch.cuda.current_stream() + results = [] + for index in (0, 1, 0): + stream = streams[index] + stream.wait_stream(initial) + with torch.cuda.stream(stream): + results.append(backend._clip_fluid_render_positions(source, ranges)) + torch.cuda.synchronize() + + expected = source.clone() + expected[0] = float("nan") + for result in results: + torch.testing.assert_close(result, expected, equal_nan=True, rtol=0, atol=0) diff --git a/tests/sim/test_sim_manager.py b/tests/sim/test_sim_manager.py index ab6242789..ce6496b2b 100644 --- a/tests/sim/test_sim_manager.py +++ b/tests/sim/test_sim_manager.py @@ -486,6 +486,7 @@ def _make_runtime_control_sim_manager( sim._robots = {"robot": object()} sim._articulations = {} sim._rigid_objects = {"table": object()} + sim._fluid_objects = {} sim._deformable_objects = { "cloth": SimpleNamespace( cfg=SurfaceDeformableObjectCfg(uid="cloth", particle_flags=[0, 1, 0]) @@ -567,6 +568,7 @@ def test_deferred_destroy_prepares_backend_before_releasing_world( sim._rigid_objects = {} sim._constraints = {} sim._rigid_object_groups = {} + sim._fluid_objects = {} sim._deformable_objects = {} sim._articulations = {} sim._robots = {} @@ -1309,6 +1311,7 @@ def capture(self, **_kwargs: object) -> None: ) sim._rigid_objects = {} sim._rigid_object_groups = {} + sim._fluid_objects = {} sim._deformable_objects = {} sim._articulations = {} sim._robots = {} @@ -2544,6 +2547,7 @@ def test_remove_asset_marks_visualization_topology_dirty() -> None: sim.prepare = MagicMock() sim._rigid_objects = {"cube": rigid_object} sim._rigid_object_groups = {} + sim._fluid_objects = {} sim._deformable_objects = {} sim._articulations = {} sim._robots = {} @@ -2808,6 +2812,7 @@ def test_reset_objects_state_includes_deformable_assets() -> None: sim._rigid_object_groups = {} sim._lights = {} sim._sensors = {} + sim._fluid_objects = {} sim._deformable_objects = { "soft": MagicMock(), "cloth": MagicMock(), @@ -2843,6 +2848,7 @@ def test_newton_reset_clears_all_articulations_in_selected_worlds_together( sim._articulations = {"arm": articulation} sim._rigid_objects = {} sim._rigid_object_groups = {} + sim._fluid_objects = {} sim._deformable_objects = {} sim._lights = {} sim._sensors = {} @@ -2867,6 +2873,7 @@ def test_newton_reset_rejects_excluding_one_articulation_from_selected_worlds( sim._articulations = {"arm": articulation} sim._rigid_objects = {} sim._rigid_object_groups = {} + sim._fluid_objects = {} sim._deformable_objects = {} sim._lights = {} sim._sensors = {} @@ -2895,6 +2902,7 @@ def test_newton_non_mujoco_reset_allows_excluding_an_articulation() -> None: sim._articulations = {"arm": articulation} sim._rigid_objects = {} sim._rigid_object_groups = {} + sim._fluid_objects = {} sim._deformable_objects = {} sim._lights = {} sim._sensors = {} @@ -3127,6 +3135,27 @@ def test_process_no_render_is_detected_after_world_cleanup(monkeypatch) -> None: assert SimulationManager._get_process_renderer() == "no-render" +def test_reset_fluid_selects_rows_and_honors_exclusions(): + sim = object.__new__(SimulationManager) + sim.physics = SimpleNamespace(name="default") + sim.num_envs = 2 + for name in ( + "_robots", + "_articulations", + "_rigid_objects", + "_rigid_object_groups", + "_lights", + "_sensors", + "_deformable_objects", + ): + setattr(sim, name, {}) + fluid, excluded = MagicMock(), MagicMock() + sim._fluid_objects = {"water": fluid, "other": excluded} + sim.reset_objects_state(env_ids=[1], excluded_uids=["other"]) + excluded.reset.assert_not_called() + fluid.reset.assert_called_once_with([1]) + + @pytest.mark.usefixtures("requires_particle_contact_material") def test_targeted_particle_material_schedule_expands_arena_paths() -> None: from dexsim.engine import newton_physics diff --git a/tests/visualization/test_scene_exporter.py b/tests/visualization/test_scene_exporter.py index 9e3d09fb7..36513129c 100644 --- a/tests/visualization/test_scene_exporter.py +++ b/tests/visualization/test_scene_exporter.py @@ -730,6 +730,27 @@ def test_rigid_groups_and_deformable_meshes_are_exported() -> None: ) +def test_fluid_point_overlay_is_detached_and_display_filtered(): + import torch + from types import SimpleNamespace + from embodichain.lab.sim.cfg import FluidObjectCfg, FluidRenderCfg + + sim = _EmptySimulation() + original = torch.tensor([[[0.0, 0, -1], [0.0, 0, 1]]]) + sim._fluid_objects = { + "water": SimpleNamespace( + is_declared=False, + cfg=FluidObjectCfg(render=FluidRenderCfg(clip_below_z=0.0)), + data=SimpleNamespace(particle_pos_w=original), + ) + } + exporter = SceneExporter(sim, VisualizationCfg(backend="viser", env_ids=[0])) + overlays = exporter._prepare_overlays(None) + assert len(overlays.point_clouds) == 1 + np.testing.assert_array_equal(overlays.point_clouds[0].points, [[0, 0, 1]]) + torch.testing.assert_close(original, torch.tensor([[[0.0, 0, -1], [0.0, 0, 1]]])) + + def test_visual_objects_export_pose_and_live_visibility_without_topology_refresh(): """Render-only assets publish their own node kind and visibility state.""" asset = _RigidObject()